From 8d11cabc53ab880686619fb66131cead8a81f762 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Apr 2026 20:20:08 -0700 Subject: [PATCH 001/158] Updated dependencies --- pyproject.toml | 8 +- uv.lock | 475 +++++++++++++++++++++++++++++-------------------- 2 files changed, 284 insertions(+), 199 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5be4804..38c3f70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "commanderbot" description = "A collection of utilities and extensions for discord.py bots." -version = "0.22.0" +version = "0.23.0" requires-python = ">=3.14,<3.15" authors = [ { name = "Ersatz", email = "ersatz0077@gmail.com" }, @@ -13,14 +13,14 @@ readme = { file = "README.md", content-type = "text/markdown" } keywords = ["discord", "bot"] classifiers = [ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.14", - "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.14" ] dependencies = [ # Core dependencies. "discord-py>=2.6.0", "python-dotenv>=1.0.0", + "pydantic>=2.12.0", # These are used for logging. "colorama>=0.4.0", @@ -60,7 +60,7 @@ profile = "black" typeCheckingMode = "basic" [build-system] -requires = ["uv_build"] +requires = ["uv_build<0.12"] build-backend = "uv_build" [tool.uv.build-backend] diff --git a/uv.lock b/uv.lock index f26e496..e75c6a1 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.2" +version = "3.13.5" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -24,42 +24,42 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" }, - { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" }, - { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" }, - { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" }, - { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" }, - { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" }, - { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" }, - { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" }, - { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" }, - { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" }, - { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" }, - { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" }, - { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" }, - { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" }, - { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" }, - { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" }, - { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" }, - { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" }, - { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" }, - { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" }, - { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" }, - { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, ] [[package]] @@ -76,14 +76,11 @@ wheels = [ [[package]] name = "aiosqlite" -version = "0.21.0" +version = "0.22.1" source = { registry = "https://pypi.org/simple/" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, ] [[package]] @@ -98,13 +95,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/c8/c382cb9b929ce77d1ddb712e2922eba48f01ac379b83fa07b3283ceae63d/allay-1.3.0-py3-none-any.whl", hash = "sha256:479ba5d8cd64b7ddde359b26aa586e5475235f9b4048bb43e16c4142dfe60fae", size = 9187, upload-time = "2022-05-15T22:03:42.854Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] @@ -149,7 +155,7 @@ wheels = [ [[package]] name = "black" -version = "25.11.0" +version = "26.3.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "click" }, @@ -159,25 +165,26 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" }, - { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" }, - { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] @@ -203,7 +210,7 @@ wheels = [ [[package]] name = "commanderbot" -version = "0.21.0" +version = "0.23.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -216,6 +223,7 @@ dependencies = [ { name = "jsonpath-ng" }, { name = "mccq" }, { name = "psutil" }, + { name = "pydantic" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "sqlalchemy" }, @@ -239,6 +247,7 @@ requires-dist = [ { name = "jsonpath-ng", specifier = ">=1.7.0" }, { name = "mccq", specifier = ">=1.0.0" }, { name = "psutil", specifier = ">=7.0.0" }, + { name = "pydantic", specifier = ">=2.12.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "sqlalchemy", specifier = ">=1.4.0,<2.0.0" }, @@ -252,15 +261,15 @@ dev = [ [[package]] name = "discord-py" -version = "2.6.4" +version = "2.7.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohttp" }, { name = "audioop-lts" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/9b1dbb9b2fc07616132a526c05af23cfd420381793968a189ee08e12e35f/discord_py-2.6.4.tar.gz", hash = "sha256:44384920bae9b7a073df64ae9b14c8cf85f9274b5ad5d1d07bd5a67539de2da9", size = 1092623, upload-time = "2025-10-08T21:45:43.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/57/9a2d9abdabdc9db8ef28ce0cf4129669e1c8717ba28d607b5ba357c4de3b/discord_py-2.7.1.tar.gz", hash = "sha256:24d5e6a45535152e4b98148a9dd6b550d25dc2c9fb41b6d670319411641249da", size = 1106326, upload-time = "2026-03-03T18:40:46.24Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284, upload-time = "2025-10-08T21:45:41.679Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550, upload-time = "2026-03-03T18:40:44.492Z" }, ] [[package]] @@ -315,19 +324,25 @@ wheels = [ [[package]] name = "greenlet" -version = "3.2.4" +version = "3.4.0" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, - { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, - { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, - { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, + { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, ] [[package]] @@ -341,23 +356,20 @@ wheels = [ [[package]] name = "isort" -version = "7.0.0" +version = "8.0.1" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, ] [[package]] name = "jsonpath-ng" -version = "1.7.0" +version = "1.8.0" source = { registry = "https://pypi.org/simple/" } -dependencies = [ - { name = "ply" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/86/08646239a313f895186ff0a4573452038eed8c86f54380b3ebac34d32fb2/jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c", size = 37838, upload-time = "2024-10-11T15:41:42.404Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/58/250751940d75c8019659e15482d548a4aa3b6ce122c515102a4bfdac50e3/jsonpath_ng-1.8.0.tar.gz", hash = "sha256:54252968134b5e549ea5b872f1df1168bd7defe1a52fed5a358c194e1943ddc3", size = 74513, upload-time = "2026-02-24T14:42:06.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/5a/73ecb3d82f8615f32ccdadeb9356726d6cae3a4bbc840b437ceb95708063/jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6", size = 30105, upload-time = "2024-11-20T17:58:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/03/99/33c7d78a3fb70d545fd5411ac67a651c81602cc09c9cf0df383733f068c5/jsonpath_ng-1.8.0-py3-none-any.whl", hash = "sha256:b8dde192f8af58d646fc031fac9c99fe4d00326afc4148f1f043c601a8cfe138", size = 67844, upload-time = "2026-02-28T00:53:19.637Z" }, ] [[package]] @@ -371,47 +383,47 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.0" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, - { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, - { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, - { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, - { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, - { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, - { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, - { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, - { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, - { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, - { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, - { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, - { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, - { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, - { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, - { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, - { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, - { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, - { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, - { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, - { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, - { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, - { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, - { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, - { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +version = "6.7.1" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] @@ -425,38 +437,29 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.4" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] name = "platformdirs" -version = "4.5.0" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, -] - -[[package]] -name = "ply" -version = "3.11" +version = "4.9.6" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] [[package]] @@ -500,40 +503,106 @@ wheels = [ [[package]] name = "psutil" -version = "7.1.3" +version = "7.2.2" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" }, - { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" }, - { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "pytokens" -version = "0.3.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] [[package]] @@ -589,48 +658,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "yarl" -version = "1.22.0" +version = "1.23.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] From 0362c74d57d458a0d577f7fe0b3e4a66563edbc3 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 22 Apr 2026 03:44:30 -0700 Subject: [PATCH 002/158] Added Pydantic support --- commanderbot/lib/allowed_mentions.py | 152 +++++++++-- commanderbot/lib/color.py | 373 ++++++++++++++++++++++++--- commanderbot/lib/exceptions.py | 36 ++- commanderbot/lib/intents.py | 126 ++++++++- 4 files changed, 621 insertions(+), 66 deletions(-) diff --git a/commanderbot/lib/allowed_mentions.py b/commanderbot/lib/allowed_mentions.py index 147d55c..2f04a8d 100644 --- a/commanderbot/lib/allowed_mentions.py +++ b/commanderbot/lib/allowed_mentions.py @@ -1,47 +1,103 @@ -from typing import Any, Self, cast +import inspect +from typing import Any, Callable, ClassVar, Optional, Self, cast, override import discord from discord.mentions import default +from pydantic import GetCoreSchemaHandler, TypeAdapter +from pydantic_core import core_schema +from commanderbot.lib.exceptions import InstantiationError, NoSuchFactory from commanderbot.lib.from_data_mixin import FromDataMixin from commanderbot.lib.json_serializable import JsonSerializable -__all__ = ("AllowedMentions",) +__all__ = ["AllowedMentions", "AllowedMentionsAdapter"] -class AllowedMentions(discord.AllowedMentions, JsonSerializable, FromDataMixin): - """Extends `discord.AllowedMentions` to simplify de/serialization.""" +class AllowedMentions(discord.AllowedMentions, FromDataMixin, JsonSerializable): + """ + Extends `discord.AllowedMentions` to implement de/serialization and add extra features. + Can be de/serialized using Pydantic or `FromDataMixin`/`JsonSerializeable`. + """ + + # @@ FACTORIES + + _FACTORIES: ClassVar[dict[str, Callable[[], Self]]] = {} + + # @overrides discord.AllowedMentions + @override @classmethod def all(cls) -> Self: + """ + A factory method that returns a :class:`AllowedMentions` with all fields explicitly set to ``True`` + """ return cast(Self, super().all()) + # @overrides discord.AllowedMentions + @override @classmethod def none(cls) -> Self: + """ + A factory method that returns a :class:`AllowedMentions` with all fields set to ``False`` + """ return cast(Self, super().none()) @classmethod def not_everyone(cls) -> Self: + """ + A factory method that returns a :class:`AllowedMentions` with all fields except ``everyone`` set to ``True`` + """ return cls(everyone=False, users=True, roles=True, replied_user=True) @classmethod def only_replies(cls) -> Self: + """ + A factory method that returns a :class:`AllowedMentions` with only the ``replied_user`` field set to ``True`` + """ return cls(everyone=False, users=False, roles=False, replied_user=True) @classmethod def only_users(cls) -> Self: + """ + A factory method that returns a :class:`AllowedMentions` with only the ``users`` field set to ``True`` + """ return cls(everyone=False, users=True, roles=False, replied_user=False) @classmethod def only_roles(cls) -> Self: + """ + A factory method that returns a :class:`AllowedMentions` with only the ``roles`` field set to ``True`` + """ return cls(everyone=False, users=False, roles=True, replied_user=False) + # @@ INSTANCE + + # @overrides discord.AllowedMentions + @override def merge(self, other: discord.AllowedMentions) -> Self: return cast(Self, super().merge(other)) + def __eq__(self, other: object) -> bool: + if not isinstance(other, discord.AllowedMentions): + return False + + for field in self.__slots__: + self_value = getattr(self, field, default) + other_value = getattr(self, field, default) + if self_value != other_value: + return False + + return True + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + # @@ DE/SERIALIZATION + # @implements FromDataMixin + @override @classmethod - def try_from_data(cls, data): + def try_from_data(cls, data) -> Optional[Self]: if isinstance(data, str): if factory := getattr(cls, data, None): return factory() @@ -49,6 +105,7 @@ def try_from_data(cls, data): return cls(**data) # @implements JsonSerializable + @override def to_json(self) -> Any: data = {} for field in self.__slots__: @@ -56,17 +113,74 @@ def to_json(self) -> Any: data[field] = value return data - def __eq__(self, other: object) -> bool: - if not isinstance(other, discord.AllowedMentions): - return False - - for field in self.__slots__: - self_value = getattr(self, field, default) - other_value = getattr(other, field, default) - if self_value != other_value: - return False - - return True - - def __ne__(self, other: object) -> bool: - return not self.__eq__(other) + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + def from_factory(factory_name: str) -> Self: + if factory := cls._FACTORIES.get(factory_name): + try: + return factory() + except Exception as ex: + raise InstantiationError(cls, ex) + raise NoSuchFactory(cls, factory_name) + + def from_dict(raw_allowed_mentions: dict[str, bool]): + try: + return cls(**raw_allowed_mentions) + except Exception as ex: + raise InstantiationError(cls, ex) + + def to_dict(instance: Self) -> dict: + data = {} + for field in cls.__slots__: + if (value := getattr(instance, field, default)) is not default: + data[field] = value + + return data + + from_factory_schema = core_schema.chain_schema( + [ + core_schema.str_schema(), + core_schema.no_info_plain_validator_function(from_factory), + ] + ) + + from_dict_schema = core_schema.chain_schema( + [ + core_schema.dict_schema( + keys_schema=core_schema.literal_schema([*cls.__slots__]), + values_schema=core_schema.bool_schema(), + ), + core_schema.no_info_plain_validator_function(from_dict), + ] + ) + + return core_schema.json_or_python_schema( + json_schema=core_schema.union_schema( + [from_factory_schema, from_dict_schema] + ), + python_schema=core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + from_factory_schema, + from_dict_schema, + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema(to_dict), + ) + + +# Cache allowed mentions factories. +for name, func in inspect.getmembers(AllowedMentions, inspect.ismethod): + # An allowed mentions factory takes no arguments and has a specific doc string. + signature = inspect.signature(func) + doc_str = inspect.getdoc(func) or "" + if len(signature.parameters) == 0 and "A factory method that returns a" in doc_str: + AllowedMentions._FACTORIES[name] = func + + +AllowedMentionsAdapter = TypeAdapter(AllowedMentions) +""" +A Pydantic type adapter that lets you de/serialize `AllowedMentions` outside of a Pydantic `BaseModel`. +""" diff --git a/commanderbot/lib/color.py b/commanderbot/lib/color.py index bd74f51..1243ede 100644 --- a/commanderbot/lib/color.py +++ b/commanderbot/lib/color.py @@ -1,21 +1,30 @@ import inspect -from typing import Optional, Self, cast +from typing import Any, Callable, ClassVar, Optional, Self, cast, override import discord +from pydantic import GetCoreSchemaHandler, TypeAdapter +from pydantic_core import core_schema +from commanderbot.lib.exceptions import InstantiationError, NoSuchFactory from commanderbot.lib.from_data_mixin import FromDataMixin +from commanderbot.lib.json_serializable import JsonSerializable -__all__ = ("Color",) +__all__ = ["Color", "ColorAdapter"] -class Color(discord.Color, FromDataMixin): - """Extends `discord.Color` to simplify deserialization.""" +class Color(discord.Colour, FromDataMixin, JsonSerializable): + """ + Extends `discord.Color` to implement de/serialization and add extra features. - # @overrides discord.Color - def __repr__(self) -> str: - return f"0x{self.value:X}" + Can be de/serialized using Pydantic or `FromDataMixin`/`JsonSerializeable`. + """ + + # @@ FACTORIES - # @overrides discord.Color + _FACTORIES: ClassVar[dict[str, Callable[[], Self]]] = {} + + # @overrides discord.Colour + @override @classmethod def from_str(cls, value: str) -> Self: # The classmethod, `discord.Color.from_str()`, always returns a @@ -23,48 +32,29 @@ def from_str(cls, value: str) -> Self: # to this class. return cast(Self, super().from_str(value)) - # @implements FromDataMixin - @classmethod - def try_from_data(cls, data): - if isinstance(data, str): - return cls.from_str(data) - elif isinstance(data, int): - return cls(data) - elif isinstance(data, dict): - return cls.from_field_optional(data, "color") - @classmethod def presets( cls, *, color_filter: Optional[str] = None, case_sensitive: bool = False ) -> dict[str, "Color"]: """ Returns a dictionary containing all color presets. - The `color_filter` parameter can be used to filter the color presets that are returned + The `color_filter` parameter can be used to filter the color presets that are returned. """ color_filter = color_filter and ( color_filter if case_sensitive else color_filter.lower() ) colors: dict[str, Color] = {} - for name, func in inspect.getmembers(Color, inspect.ismethod): - # If we're seaching for specific functions, skip the current - # function if it doesn't have the string we're searching for. + for name, func in cls._FACTORIES.items(): + # Skip factory function if its name doesn't contain the color filter. func_name: str = name if case_sensitive else name.lower() if color_filter and color_filter not in func_name: continue - # Check if the current function is one of the `@classmethod`s that - # take no arguments and return this class/`discord.Color`` - signature = inspect.signature(func) - doc_str = inspect.getdoc(func) or "" - if len(signature.parameters) == 0 and "value of ``0" in doc_str: - colors[name] = func() + colors[name] = func() return colors - def to_hex(self) -> str: - return str(self) - @classmethod def white(cls) -> Self: """A factory method that returns a :class:`Color` with a value of ``0xFFFFFF``. @@ -89,6 +79,30 @@ def mojang_red(cls) -> Self: """ return cls(0xF0313B) + @classmethod + def minecoin_gold(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xDDD605``. + + .. color:: #DDD605 + """ + return cls(0xDDD605) + + @classmethod + def minecraft_black(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x000000``. + + .. color:: #000000 + """ + return cls(0x000000) + + @classmethod + def minecraft_dark_blue(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x0000AA``. + + .. color:: #0000AA + """ + return cls(0x0000AA) + @classmethod def minecraft_dark_green(cls) -> Self: """A factory method that returns a :class:`Color` with a value of ``0x00AA00``. @@ -97,6 +111,30 @@ def minecraft_dark_green(cls) -> Self: """ return cls(0x00AA00) + @classmethod + def minecraft_dark_aqua(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x00AAAA``. + + .. color:: #00AAAA + """ + return cls(0x00AAAA) + + @classmethod + def minecraft_dark_red(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xAA0000``. + + .. color:: #AA0000 + """ + return cls(0xAA0000) + + @classmethod + def minecraft_dark_purple(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xAA00AA``. + + .. color:: #AA00AA + """ + return cls(0xAA00AA) + @classmethod def minecraft_gold(cls) -> Self: """A factory method that returns a :class:`Color` with a value of ``0xFFAA00``. @@ -105,14 +143,126 @@ def minecraft_gold(cls) -> Self: """ return cls(0xFFAA00) + @classmethod + def minecraft_gray(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xAAAAAA``. + + .. color:: #AAAAAA + """ + return cls(0xAAAAAA) + + @classmethod + def minecraft_dark_gray(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x555555``. + + .. color:: #555555 + """ + return cls(0x555555) + + @classmethod + def minecraft_blue(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x5555FF``. + + .. color:: #5555FF + """ + return cls(0x5555FF) + + @classmethod + def minecraft_green(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x55FF55``. + + .. color:: #55FF55 + """ + return cls(0x55FF55) + + @classmethod + def minecraft_aqua(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x55FFFF``. + + .. color:: #55FFFF + """ + return cls(0x55FFFF) + + @classmethod + def minecraft_red(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xFF5555``. + + .. color:: #FF5555 + """ + return cls(0xFF5555) + + @classmethod + def minecraft_light_purple(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xFF55FF``. + + .. color:: #FF55FF + """ + return cls(0xFF55FF) + + @classmethod + def minecraft_yellow(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xFFFF55``. + + .. color:: #FFFF55 + """ + return cls(0xFFFF55) + + @classmethod + def minecraft_white(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xFFFFFF``. + + .. color:: #FFFFFF + """ + return cls(0xFFFFFF) + + @classmethod + def minecraft_material_quartz(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xE3D4D1``. + + .. color:: #E3D4D1 + """ + return cls(0xE3D4D1) + + @classmethod + def minecraft_material_iron(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xCECACA``. + + .. color:: #CECACA + """ + return cls(0xCECACA) + + @classmethod + def minecraft_material_netherite(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x443A3B``. + + .. color:: #443A3B + """ + return cls(0x443A3B) + + @classmethod + def minecraft_material_redstone(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x971607``. + + .. color:: #971607 + """ + return cls(0x971607) + @classmethod def minecraft_material_copper(cls) -> Self: - """A factory method that returns a :class:`Color` with a value of ``0x47A036``. + """A factory method that returns a :class:`Color` with a value of ``0xB4684D``. .. color:: #B4684D """ return cls(0xB4684D) + @classmethod + def minecraft_material_gold(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xDEB12D``. + + .. color:: #DEB12D + """ + return cls(0xDEB12D) + @classmethod def minecraft_material_emerald(cls) -> Self: """A factory method that returns a :class:`Color` with a value of ``0x47A036``. @@ -122,17 +272,160 @@ def minecraft_material_emerald(cls) -> Self: return cls(0x47A036) @classmethod - def minecraft_material_gold(cls) -> Self: - """A factory method that returns a :class:`Color` with a value of ``0xDEB12D``. + def minecraft_material_diamond(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x2CBAA8``. - .. color:: #DEB12D + .. color:: #2CBAA8 """ - return cls(0xDEB12D) + return cls(0x2CBAA8) @classmethod - def minecraft_material_netherite(cls) -> Self: - """A factory method that returns a :class:`Color` with a value of ``0x47A036``. + def minecraft_material_lapis(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x21497B``. - .. color:: #443A3B + .. color:: #21497B """ - return cls(0x443A3B) + return cls(0x21497B) + + @classmethod + def minecraft_material_amethyst(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0x9A5CC6``. + + .. color:: #9A5CC6 + """ + return cls(0x9A5CC6) + + @classmethod + def minecraft_material_resin(cls) -> Self: + """A factory method that returns a :class:`Color` with a value of ``0xEB7114``. + + .. color:: #EB7114 + """ + return cls(0xEB7114) + + # @@ INSTANCE + + # @overrides discord.Colour + @override + def __repr__(self) -> str: + return f"0x{self.value:X}" + + # @@ DE/SERIALIZATION + + # @implements FromDataMixin + @override + @classmethod + def try_from_data(cls, data) -> Optional[Self]: + if isinstance(data, str): + return cls.from_str(data) + elif isinstance(data, int): + return cls(data) + elif isinstance(data, dict): + return cls.from_field_optional(data, "color") + + # @implements JsonSerializable + @override + def to_json(self) -> Any: + return str(self) + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + def from_factory(factory_name: str) -> Self: + if factory := cls._FACTORIES.get(factory_name): + try: + return factory() + except Exception as ex: + raise InstantiationError(cls, ex) + raise NoSuchFactory(cls, factory_name) + + from_int_schema = core_schema.chain_schema( + [ + core_schema.int_schema(), + core_schema.no_info_plain_validator_function(lambda v: cls(v)), + ] + ) + + from_str_schema = core_schema.chain_schema( + [ + core_schema.str_schema(), + core_schema.no_info_plain_validator_function(lambda v: cls.from_str(v)), + ] + ) + + from_factory_schema = core_schema.chain_schema( + [ + core_schema.str_schema(), + core_schema.no_info_plain_validator_function(from_factory), + ] + ) + + from_rgb_dict_schema = core_schema.chain_schema( + [ + core_schema.dict_schema( + keys_schema=core_schema.literal_schema(["r", "g", "b"]), + values_schema=core_schema.int_schema(), + min_length=3, + max_length=3, + ), + core_schema.no_info_plain_validator_function( + lambda d: cls.from_rgb(**d) + ), + ] + ) + + from_hsv_dict_schema = core_schema.chain_schema( + [ + core_schema.dict_schema( + keys_schema=core_schema.literal_schema(["h", "s", "v"]), + values_schema=core_schema.union_schema( + [core_schema.float_schema(), core_schema.int_schema()] + ), + min_length=3, + max_length=3, + ), + core_schema.no_info_plain_validator_function( + lambda d: cls.from_hsv(**d) + ), + ] + ) + + return core_schema.json_or_python_schema( + json_schema=core_schema.union_schema( + [ + from_int_schema, + from_str_schema, + from_factory_schema, + from_rgb_dict_schema, + from_hsv_dict_schema, + ] + ), + python_schema=core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + from_int_schema, + from_str_schema, + from_factory_schema, + from_rgb_dict_schema, + from_hsv_dict_schema, + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema( + lambda c: str(c) + ), + ) + + +# Cache color factories. +for name, func in inspect.getmembers(Color, inspect.ismethod): + # A color factory takes no arguments and has a specific doc string. + signature = inspect.signature(func) + doc_str = inspect.getdoc(func) or "" + if len(signature.parameters) == 0 and "value of ``0x" in doc_str: + Color._FACTORIES[name] = func + +ColorAdapter = TypeAdapter(Color) +""" +A Pydantic type adapter that lets you de/serialize `Color` outside of a Pydantic `BaseModel`. +""" diff --git a/commanderbot/lib/exceptions.py b/commanderbot/lib/exceptions.py index 5ea4b57..a80b4df 100644 --- a/commanderbot/lib/exceptions.py +++ b/commanderbot/lib/exceptions.py @@ -1,19 +1,51 @@ -from typing import Any, Optional, Type +from typing import Any, Optional from discord import AllowedMentions, Interaction from discord.ext.commands import Context +from pydantic_core import PydanticCustomError __all__ = ( "MalformedData", + "NoSuchFactory", + "InstantiationError", "ResponsiveException", ) class MalformedData(Exception): - def __init__(self, cls: Type, data: Any): + def __init__(self, cls: type, data: Any): super().__init__(f"Cannot create {cls.__name__} from {type(data).__name__}") +class NoSuchFactory(PydanticCustomError): + """ + Raised by a Pydantic validator if a factory function wasn't found. + """ + + def __init__(self, cls: type, factory_name: str): + super().__init__( + "no_such_factory", + "'{class_name}' doesn't have a factory function called '{factory_name}'", + {"class_name": cls.__name__, "factory_name": factory_name}, + ) + + +class InstantiationError(PydanticCustomError): + """ + Raised by a Pydantic validator if there was an error while instantiating a class. + """ + + def __init__(self, cls: type, exception: Exception): + super().__init__( + "instantiation_error", + "An error occurred while instantiating a '{class_name}': {message}", + { + "class_name": cls.__name__, + "message": str(exception), + }, + ) + + class ResponsiveException(Exception): def __init__( self, diff --git a/commanderbot/lib/intents.py b/commanderbot/lib/intents.py index 2de8e36..67347a6 100644 --- a/commanderbot/lib/intents.py +++ b/commanderbot/lib/intents.py @@ -1,36 +1,70 @@ -from typing import Any, Optional, Self, cast +import inspect +from typing import Any, Callable, ClassVar, Optional, Self, cast, override import discord +from pydantic import GetCoreSchemaHandler, TypeAdapter +from pydantic_core import core_schema +from commanderbot.lib.exceptions import InstantiationError, NoSuchFactory from commanderbot.lib.from_data_mixin import FromDataMixin from commanderbot.lib.json_serializable import JsonSerializable -__all__ = ("Intents",) +__all__ = ["Intents", "IntentsAdapter"] -class Intents(discord.Intents, JsonSerializable, FromDataMixin): - """Extends `discord.Intents` to simplify de/serialization.""" +class Intents(discord.Intents, FromDataMixin, JsonSerializable): + """ + Extends `discord.Intents` to implement de/serialization and add extra features. + Can be de/serialized using Pydantic or `FromDataMixin`/`JsonSerializeable`. + """ + + # @@ FACTORIES + + _FACTORIES: ClassVar[dict[str, Callable[[], Self]]] = {} + + # @overrides discord.Intents + @override @classmethod def all(cls) -> Self: + """ + A factory method that creates a :class:`Intents` with everything enabled. + """ return cast(Self, super().all()) + # @overrides discord.Intents + @override @classmethod def none(cls) -> Self: + """ + A factory method that creates a :class:`Intents` with everything disabled. + """ return cast(Self, super().none()) + # @overrides discord.Intents + @override @classmethod def default(cls) -> Self: + """ + A factory method that creates a :class:`Intents` with everything enabled + except :attr:`presences`, :attr:`members`, and :attr:`message_content`. + """ return cast(Self, super().default()) @classmethod def privileged(cls) -> Self: - """A factory method that creates a :class:`Intents` with only :attr:`presences`, + """ + A factory method that creates a :class:`Intents` with only :attr:`presences`, :attr:`members`, and :attr:`message_content` enabled. """ return cls(message_content=True, members=True, presences=True) + # @@ INSTANCE + + # @@ DE/SERIALIZATION + # @implements FromDataMixin + @override @classmethod def try_from_data(cls, data) -> Optional[Self]: if isinstance(data, int): @@ -42,7 +76,89 @@ def try_from_data(cls, data) -> Optional[Self]: return cls(**data) # @implements JsonSerializable + @override def to_json(self) -> Any: # A very hacky way to turn this instance into a `dict``. # Have no idea why `dict(self)` doesn't work. return dict(discord.Intents(self.value)) + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + def from_factory(factory_name: str) -> Self: + if factory := cls._FACTORIES.get(factory_name): + try: + return factory() + except Exception as ex: + raise InstantiationError(cls, ex) + raise NoSuchFactory(cls, factory_name) + + def from_dict(raw_intents: dict[str, bool]) -> Self: + try: + return cls(**raw_intents) + except Exception as ex: + raise InstantiationError(cls, ex) + + def to_dict(instance: Self) -> dict: + temp = discord.Intents(instance.value) + return {k: v for k, v in temp if v} + + from_int_schema = core_schema.chain_schema( + [ + core_schema.int_schema(), + core_schema.no_info_plain_validator_function( + lambda v: cls._from_value(v) + ), + ] + ) + + from_factory_schema = core_schema.chain_schema( + [ + core_schema.str_schema(), + core_schema.no_info_plain_validator_function(from_factory), + ] + ) + + from_dict_schema = core_schema.chain_schema( + [ + core_schema.dict_schema( + keys_schema=core_schema.str_schema(), + values_schema=core_schema.bool_schema(), + ), + core_schema.no_info_plain_validator_function(from_dict), + ] + ) + + return core_schema.json_or_python_schema( + json_schema=core_schema.union_schema( + [ + from_int_schema, + from_factory_schema, + from_dict_schema, + ] + ), + python_schema=core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + from_int_schema, + from_factory_schema, + from_dict_schema, + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema(to_dict), + ) + + +# Cache intents factories. +for name, func in inspect.getmembers(Intents, inspect.ismethod): + # An intents factory takes no arguments and has a specific doc string. + signature = inspect.signature(func) + doc_str = inspect.getdoc(func) or "" + if len(signature.parameters) == 0 and "A factory method that creates a" in doc_str: + Intents._FACTORIES[name] = func + +IntentsAdapter = TypeAdapter(Intents) +""" +A Pydantic type adapter that lets you de/serialize `Intents` outside of a Pydantic `BaseModel`. +""" From 3388629090233a94d1f59dd3f9c4843abad63ba6 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 22 Apr 2026 04:04:27 -0700 Subject: [PATCH 003/158] Added `to_hex()` --- commanderbot/lib/color.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/commanderbot/lib/color.py b/commanderbot/lib/color.py index 1243ede..cbb2382 100644 --- a/commanderbot/lib/color.py +++ b/commanderbot/lib/color.py @@ -305,6 +305,9 @@ def minecraft_material_resin(cls) -> Self: # @@ INSTANCE + def to_hex(self) -> str: + return str(self) + # @overrides discord.Colour @override def __repr__(self) -> str: From e025896dabb3c4acbb0377d31efc1d81880ed3d4 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 22 Apr 2026 04:04:50 -0700 Subject: [PATCH 004/158] Made the config use Pydantic --- commanderbot/core/cli.py | 3 +- commanderbot/core/commander_bot.py | 3 +- commanderbot/core/config.py | 269 +++++++++++++--------- commanderbot/core/configured_extension.py | 53 ----- commanderbot/ext/sudo/sudo_cog.py | 6 +- 5 files changed, 161 insertions(+), 173 deletions(-) delete mode 100644 commanderbot/core/configured_extension.py diff --git a/commanderbot/core/cli.py b/commanderbot/core/cli.py index 137b7a9..cbeaa27 100644 --- a/commanderbot/core/cli.py +++ b/commanderbot/core/cli.py @@ -50,7 +50,8 @@ def run(): # Read config file log.info(f"Configuration file: {parsed_args.config}") log.debug("Parsing configuration file...") - config = Config.from_file(parsed_args.config) # type: ignore + config = Config.model_validate_json(parsed_args.config.read_text()) + config.log_info() log.debug("Successfully parsed configuration file!") # Get bot token diff --git a/commanderbot/core/commander_bot.py b/commanderbot/core/commander_bot.py index 1dd8391..c1dc9f7 100644 --- a/commanderbot/core/commander_bot.py +++ b/commanderbot/core/commander_bot.py @@ -11,8 +11,7 @@ from commanderbot.core.application_emoji_manager import ApplicationEmojiManager from commanderbot.core.command_tree import CachingCommandTree -from commanderbot.core.config import Config -from commanderbot.core.configured_extension import ConfiguredExtension +from commanderbot.core.config import Config, ConfiguredExtension from commanderbot.core.error_handling import ( AppCommandErrorHandler, CommandErrorHandler, diff --git a/commanderbot/core/config.py b/commanderbot/core/config.py index 391c92f..52cf658 100644 --- a/commanderbot/core/config.py +++ b/commanderbot/core/config.py @@ -1,130 +1,146 @@ -import json -from dataclasses import dataclass, field from logging import Logger, getLogger -from pathlib import Path -from typing import Any, Optional, Self +from typing import Any, Optional, Self, override + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ModelWrapValidatorHandler, + PrivateAttr, + SerializerFunctionWrapHandler, + model_serializer, + model_validator, +) -from commanderbot.core.configured_extension import ConfiguredExtension from commanderbot.core.exceptions import ExtensionIsRequired, ExtensionNotInConfig from commanderbot.lib import ( AllowedMentions, - FromDataMixin, + AllowedMentionsAdapter, Intents, - JsonSerializable, - utils, + JsonObject, ) -from commanderbot.lib.types import JsonObject - -@dataclass -class Config(JsonSerializable, FromDataMixin): - command_prefix: str - intents: Intents - allowed_mentions: AllowedMentions - extensions: dict[str, ConfiguredExtension] - enabled_extensions: list[ConfiguredExtension] = field( - init=False, default_factory=list - ) - disabled_extensions: list[ConfiguredExtension] = field( - init=False, default_factory=list - ) - - # @implements FromDataMixin +class ConfiguredExtension(BaseModel): + """ + Represents an extension with an optional config. + + Attributes + ---------- + name: :class:`str` + The name of this extension (Should be the same as its import path). + required: :class:`bool` + Whether this extension is required or not. It doesn't affect loading/unloading/reloading this extension, + but you're free to choose how to handle this attribute in your code. Defaults to `False`. + disabled: :class:`bool` + Whether this extension is disabled or not. Defaults to `False`. + options: :class:`Optional[JsonObject]` + Options for this extension. Defaults to `None`. + """ + + name: str + required: bool = False + disabled: bool = False + options: Optional[JsonObject] = None + + @model_validator(mode="wrap") @classmethod - def try_from_data(cls, data: Any) -> Optional[Self]: + def _validate_model( + cls, data: dict | str, handler: ModelWrapValidatorHandler + ) -> Self: if isinstance(data, dict): - log: Logger = getLogger(__name__) - log.info(f"Number of configuration keys: {len(data)}") - - # Get command prefix - command_prefix: str = data["command_prefix"] - log.info(f"Command prefix: {command_prefix}") - - # Process intents - intents = Intents.default() - if i := Intents.from_field_optional(data, "intents"): - intents = Intents.default() & i - if i := Intents.from_field_optional(data, "privileged_intents"): - intents |= Intents.privileged() & i - - log.info(f"Using intents flags: {intents.value}") + return handler(data) + # Extensions starting with a `$` are required. + elif data.startswith("$"): + return handler({"name": data[1:], "required": True}) + # Extensions starting with a `!` are disabled. + elif data.startswith("!"): + return handler({"name": data[1:], "disabled": True}) + else: + return handler({"name": data}) + + @model_serializer(mode="wrap") + def _serialize_model(self, handler: SerializerFunctionWrapHandler) -> dict | str: + if self.options: + return handler(self) + elif self.required: + return f"${self.name}" + elif self.disabled: + return f"!{self.name}" + else: + return self.name + + +class Config(BaseModel): + """ + Attributes + ---------- + command_prefix :class:`str` + The prefix to use for prefix commands. + gateway_intents :class:`Intents` + Non-privileged intents to use. Defaults to `Intents.default()`. + privileged_gateway_intents :class:`Intents` + Privileged intents to use. Defaults to `Intents.none()`. + allowed_mentions :class:`AllowedMentions` + The allowed mentions to use. Defaults to `AllowedMentions.not_everyone()`. + extensions :class:`list[ConfiguredExtension]` + The list of extensions to use. + """ + + model_config = ConfigDict(serialize_by_alias=True) - # Process allowed mentions - allowed_mentions = AllowedMentions.not_everyone() - if m := AllowedMentions.from_field_optional(data, "allowed_mentions"): - allowed_mentions = m - - log.info(f"Using allowed mentions: {allowed_mentions.to_json()}") - - # Process extensions - log.info("Processing extensions...") + command_prefix: str + gateway_intents: Intents = Field(alias="intents", default_factory=Intents.default) + privileged_gateway_intents: Intents = Field( + alias="privileged_intents", default_factory=Intents.none + ) + allowed_mentions: AllowedMentions = Field( + default_factory=AllowedMentions.not_everyone + ) + extensions: list[ConfiguredExtension] = Field(default_factory=list) - raw_extensions = data.get("extensions", []) - extensions: dict[str, ConfiguredExtension] = {} - for raw_entry in raw_extensions: - ext = ConfiguredExtension.from_data(raw_entry) - extensions[ext.name] = ext + _extensions_by_name: dict[str, ConfiguredExtension] = PrivateAttr( + default_factory=dict + ) + _enabled_extensions: list[ConfiguredExtension] = PrivateAttr(default_factory=list) + _disabled_extensions: list[ConfiguredExtension] = PrivateAttr(default_factory=list) - if extensions: - log.info(f"Processed {len(extensions)} extensions...") + @property + def intents(self) -> Intents: + """ + The intents that result from merging `gateway_intents` and `privileged_gateway_intents`. + """ + return self.gateway_intents | self.privileged_gateway_intents + + @property + def extensions_by_name(self) -> dict[str, ConfiguredExtension]: + return self._extensions_by_name + + @property + def enabled_extensions(self) -> list[ConfiguredExtension]: + return self._enabled_extensions + + @property + def disabled_extensions(self) -> list[ConfiguredExtension]: + return self._disabled_extensions + + # @overrides BaseModel + @override + def model_post_init(self, context: Any): + self._extensions_by_name.clear() + self._enabled_extensions.clear() + self._disabled_extensions.clear() + for ext in self.extensions: + self._extensions_by_name[ext.name] = ext + if ext.disabled: + self.disabled_extensions.append(ext) else: - log.warning("No extensions configured.") - - return cls( - command_prefix=command_prefix, - intents=intents, - allowed_mentions=allowed_mentions, - extensions=extensions, - ) - - # @implements JsonSerializable - def to_json(self) -> Any: - intents: Optional[Intents] = Intents.default() & self.intents - privileged_intents: Intents = Intents.privileged() & self.intents - - # `intents` is an optional field in the config and defaults to `Intents.default()`. - # So don't include it if it's value is the same as `Intents.default()`. - if intents == Intents.default(): - intents = None - - # `allowed_mentions` is an optional field in the config and defaults to `AllowedMentions.not_everyone()`. - # So don't include it if it's value is the same as `AllowedMentions.not_everyone()`. - allowed_mentions: Optional[AllowedMentions] = self.allowed_mentions - if allowed_mentions == AllowedMentions.not_everyone(): - allowed_mentions = None - - # Create the Json - return utils.dict_without_falsies( - command_prefix=self.command_prefix, - intents=( - utils.dict_without_falsies(intents.to_json()) if intents else None - ), - privileged_intents=utils.dict_without_falsies(privileged_intents.to_json()), - allowed_mentions=( - utils.dict_without_falsies(allowed_mentions.to_json()) - if allowed_mentions - else None - ), - extensions=[ext.to_json() for ext in self.extensions.values()], - ) - - @classmethod - def from_file(cls, path: Path) -> Self: - raw_config: JsonObject = {} - with open(path) as file: - raw_config = json.load(file) - - return cls.from_data(raw_config) - - def __post_init__(self): - self._rebuild_extension_states() + self.enabled_extensions.append(ext) def _rebuild_extension_states(self): - self.enabled_extensions.clear() - self.disabled_extensions.clear() - - for ext in self.extensions.values(): + self._enabled_extensions.clear() + self._disabled_extensions.clear() + for ext in self.extensions: if ext.disabled: self.disabled_extensions.append(ext) else: @@ -145,7 +161,7 @@ def get_extension(self, name: str) -> ConfiguredExtension: The extension was not in the config. """ - if ext := self.extensions.get(name): + if ext := self._extensions_by_name.get(name): return ext raise ExtensionNotInConfig(name) @@ -205,7 +221,7 @@ def enable_extension(self, name: str): """ ext: ConfiguredExtension = self.get_extension(name) - if ext in self.disabled_extensions: + if ext in self._disabled_extensions: ext.disabled = False self._rebuild_extension_states() @@ -225,6 +241,33 @@ def disable_extension(self, name: str): """ ext: ConfiguredExtension = self.get_extension(name) - if ext in self.enabled_extensions: + if ext in self._enabled_extensions: ext.disabled = True self._rebuild_extension_states() + + def log_info(self): + log: Logger = getLogger(__name__) + log.info(f"Command prefix: {self.command_prefix}") + log.info(f"Using intents flags: {self.intents.value}") + log.info( + f"Using allowed mentions: {AllowedMentionsAdapter.dump_python(self.allowed_mentions)}" + ) + + if self.extensions: + log.info( + f"Processed {len(self.extensions)} extensions (Enabled: {len(self._enabled_extensions)} | Disabled: {len(self._disabled_extensions)})" + ) + else: + log.warning("No extensions configured.") + + @model_validator(mode="after") + def _validate_model(self) -> Self: + self.gateway_intents &= Intents.default() + self.privileged_gateway_intents &= Intents.privileged() + return self + + @model_serializer(mode="wrap") + def _serialize_model(self, handler: SerializerFunctionWrapHandler) -> dict: + self.gateway_intents &= Intents.default() + self.privileged_gateway_intents &= Intents.privileged() + return handler(self) diff --git a/commanderbot/core/configured_extension.py b/commanderbot/core/configured_extension.py deleted file mode 100644 index 01dc6bf..0000000 --- a/commanderbot/core/configured_extension.py +++ /dev/null @@ -1,53 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Optional, Self - -from commanderbot.lib import FromDataMixin, JsonSerializable, utils -from commanderbot.lib.types import JsonObject - - -@dataclass -class ConfiguredExtension(JsonSerializable, FromDataMixin): - """ - Represents an extension with an optional config. - - Attributes - ---------- - name: :class:`str` - The name of this extension (Should be the same as its import path). - required: :class:`bool` - Whether this extension is required or not. It doesn't affect loading/unloading/reloading this extension, - but you're free to choose how to handle this attribute in your code. Defaults to `False`. - disabled: :class:`bool` - Whether this extension is disabled or not. Defaults to `False`. - options: :class:`Optional[JsonObject]` - Options for this extension. Defaults to `None`. - """ - - name: str - required: bool = False - disabled: bool = False - options: Optional[JsonObject] = None - - # @implements FromDataMixin - @classmethod - def try_from_data(cls, data: Any) -> Optional[Self]: - if isinstance(data, str): - # Extensions starting with a `$` are required. - if data.startswith("$"): - return cls(name=data[1:], required=True) - # Extensions starting with a `!` are disabled. - elif data.startswith("!"): - return cls(name=data[1:], disabled=True) - else: - return cls(name=data) - elif isinstance(data, dict): - return cls(**data) - - # @implements JsonSerializable - def to_json(self) -> Any: - return utils.dict_without_falsies( - name=self.name, - required=self.required, - disabled=self.disabled, - options=self.options, - ) diff --git a/commanderbot/ext/sudo/sudo_cog.py b/commanderbot/ext/sudo/sudo_cog.py index 1526f89..e1ebefa 100644 --- a/commanderbot/ext/sudo/sudo_cog.py +++ b/commanderbot/ext/sudo/sudo_cog.py @@ -16,7 +16,6 @@ ) from discord.app_commands import ( AppCommand, - AppCommandContext, AppInstallationType, Choice, Group, @@ -29,8 +28,7 @@ from discord.utils import format_dt from commanderbot.core.commander_bot import CommanderBot -from commanderbot.core.config import Config -from commanderbot.core.configured_extension import ConfiguredExtension +from commanderbot.core.config import Config, ConfiguredExtension from commanderbot.core.exceptions import ExtensionIsRequired, ExtensionNotInConfig from commanderbot.core.utils import is_commander_bot from commanderbot.ext.sudo.sudo_data import CogWithStore @@ -406,7 +404,7 @@ async def cmd_sudo_export_config(self, interaction: Interaction): # Turn the config into Json assert is_commander_bot(self.bot) - json_data: str = json_dumps(self.bot.config.to_json()) + json_data: str = self.bot.config.model_dump_json(exclude_defaults=True) file = utils.str_to_file(json_data, "config.json") # Respond with the config file From 2864305354fa86bb466d9b0a7d6d8b05b1578eda Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 22 Apr 2026 17:07:18 -0700 Subject: [PATCH 005/158] Updated dependencies --- pyproject.toml | 2 +- uv.lock | 90 ++++++++++++++++++++++++++------------------------ 2 files changed, 47 insertions(+), 45 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 38c3f70..ee61b32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ # Core dependencies. "discord-py>=2.6.0", "python-dotenv>=1.0.0", - "pydantic>=2.12.0", + "pydantic>=2.13.0", # These are used for logging. "colorama>=0.4.0", diff --git a/uv.lock b/uv.lock index e75c6a1..dcd2943 100644 --- a/uv.lock +++ b/uv.lock @@ -177,14 +177,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.2" +version = "8.3.3" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -247,7 +247,7 @@ requires-dist = [ { name = "jsonpath-ng", specifier = ">=1.7.0" }, { name = "mccq", specifier = ">=1.0.0" }, { name = "psutil", specifier = ">=7.0.0" }, - { name = "pydantic", specifier = ">=2.12.0" }, + { name = "pydantic", specifier = ">=2.13.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "sqlalchemy", specifier = ">=1.4.0,<2.0.0" }, @@ -347,11 +347,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.13" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] @@ -437,11 +437,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.1" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] [[package]] @@ -525,7 +525,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.3" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "annotated-types" }, @@ -533,48 +533,50 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.3" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, ] [[package]] From 5cb84651df56414746910755f39b7c20935518ab Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 22 Apr 2026 17:07:33 -0700 Subject: [PATCH 006/158] Moved some functions around --- commanderbot/core/cli.py | 6 ++-- commanderbot/core/config.py | 66 ++++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/commanderbot/core/cli.py b/commanderbot/core/cli.py index cbeaa27..fcee1e6 100644 --- a/commanderbot/core/cli.py +++ b/commanderbot/core/cli.py @@ -59,11 +59,11 @@ def run(): if not bot_token: log.warning( - "Bot token provided in a form other than the BOT_TOKEN environment variable." + "Bot token provided in a form other than the BOT_TOKEN environment variable" ) if parsed_args.token: - log.info("Using bot token provided as an argument.") + log.info("Using bot token provided as an argument") bot_token = parsed_args.token elif parsed_args.tokenfile: @@ -79,6 +79,6 @@ def run(): bot = CommanderBot(config, parsed_args.synctree) bot.run(bot_token) - log.warning("Bot has shut down.") + log.warning("Bot has shut down!") log.info("Goodbye!") diff --git a/commanderbot/core/config.py b/commanderbot/core/config.py index 52cf658..2226e45 100644 --- a/commanderbot/core/config.py +++ b/commanderbot/core/config.py @@ -114,16 +114,40 @@ def intents(self) -> Intents: @property def extensions_by_name(self) -> dict[str, ConfiguredExtension]: + """ + A dictionary of extensions where the key is the name and the value is a `ConfiguredExtension`. + """ return self._extensions_by_name @property def enabled_extensions(self) -> list[ConfiguredExtension]: + """ + A list of enabled extensions. + """ return self._enabled_extensions @property def disabled_extensions(self) -> list[ConfiguredExtension]: + """ + A list of disabled extensions. + """ return self._disabled_extensions + @model_validator(mode="after") + def _validate_model(self) -> Self: + self.gateway_intents &= Intents.default() + self.privileged_gateway_intents &= Intents.privileged() + return self + + @model_serializer(mode="wrap") + def _serialize_model(self, handler: SerializerFunctionWrapHandler) -> dict: + self.gateway_intents &= Intents.default() + self.privileged_gateway_intents &= Intents.privileged() + + # Serialize model and remove keys with falsy values + result: dict = handler(self) + return {k: v for k, v in result.items() if v} + # @overrides BaseModel @override def model_post_init(self, context: Any): @@ -146,6 +170,21 @@ def _rebuild_extension_states(self): else: self.enabled_extensions.append(ext) + def log_info(self): + log: Logger = getLogger(__name__) + log.info(f"Command prefix: {self.command_prefix}") + log.info(f"Using intents flags: {self.intents.value}") + log.info( + f"Using allowed mentions: {AllowedMentionsAdapter.dump_python(self.allowed_mentions)}" + ) + + if self.extensions: + log.info( + f"Processed {len(self.extensions)} extensions (Enabled: {len(self._enabled_extensions)} | Disabled: {len(self._disabled_extensions)})" + ) + else: + log.warning("No extensions configured.") + def get_extension(self, name: str) -> ConfiguredExtension: """ Get an extension from the config. @@ -244,30 +283,3 @@ def disable_extension(self, name: str): if ext in self._enabled_extensions: ext.disabled = True self._rebuild_extension_states() - - def log_info(self): - log: Logger = getLogger(__name__) - log.info(f"Command prefix: {self.command_prefix}") - log.info(f"Using intents flags: {self.intents.value}") - log.info( - f"Using allowed mentions: {AllowedMentionsAdapter.dump_python(self.allowed_mentions)}" - ) - - if self.extensions: - log.info( - f"Processed {len(self.extensions)} extensions (Enabled: {len(self._enabled_extensions)} | Disabled: {len(self._disabled_extensions)})" - ) - else: - log.warning("No extensions configured.") - - @model_validator(mode="after") - def _validate_model(self) -> Self: - self.gateway_intents &= Intents.default() - self.privileged_gateway_intents &= Intents.privileged() - return self - - @model_serializer(mode="wrap") - def _serialize_model(self, handler: SerializerFunctionWrapHandler) -> dict: - self.gateway_intents &= Intents.default() - self.privileged_gateway_intents &= Intents.privileged() - return handler(self) From 43a0502bd72cec8de40d6c8c746669563eedb5d1 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 12 Jun 2026 01:54:45 -0700 Subject: [PATCH 007/158] Changed when configured extensions are validated --- commanderbot/core/config.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/commanderbot/core/config.py b/commanderbot/core/config.py index 2226e45..bec47a6 100644 --- a/commanderbot/core/config.py +++ b/commanderbot/core/config.py @@ -43,21 +43,19 @@ class ConfiguredExtension(BaseModel): disabled: bool = False options: Optional[JsonObject] = None - @model_validator(mode="wrap") + @model_validator(mode="before") @classmethod - def _validate_model( - cls, data: dict | str, handler: ModelWrapValidatorHandler - ) -> Self: + def _validate_model(cls, data: dict | str) -> dict: if isinstance(data, dict): - return handler(data) + return data # Extensions starting with a `$` are required. elif data.startswith("$"): - return handler({"name": data[1:], "required": True}) + return {"name": data[1:], "required": True} # Extensions starting with a `!` are disabled. elif data.startswith("!"): - return handler({"name": data[1:], "disabled": True}) + return {"name": data[1:], "disabled": True} else: - return handler({"name": data}) + return {"name": data} @model_serializer(mode="wrap") def _serialize_model(self, handler: SerializerFunctionWrapHandler) -> dict | str: From b7303b0b503597866893c7cdc4d33f0b049744f7 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 12 Jun 2026 01:56:05 -0700 Subject: [PATCH 008/158] Removed the old `help_chat` extension since it hasn't been used in years --- commanderbot/ext/help_chat/__init__.py | 9 - commanderbot/ext/help_chat/constants.py | 6 - commanderbot/ext/help_chat/help_chat_cog.py | 163 -------- .../ext/help_chat/help_chat_guild_state.py | 178 --------- .../ext/help_chat/help_chat_options.py | 18 - .../ext/help_chat/help_chat_report.py | 356 ------------------ commanderbot/ext/help_chat/help_chat_state.py | 14 - commanderbot/ext/help_chat/help_chat_store.py | 37 -- .../ext/help_chat/sql_store/__init__.py | 1 - .../sql_store/help_chat_sql_store.py | 84 ----- .../ext/help_chat/sql_store/models.py | 60 --- 11 files changed, 926 deletions(-) delete mode 100644 commanderbot/ext/help_chat/__init__.py delete mode 100644 commanderbot/ext/help_chat/constants.py delete mode 100644 commanderbot/ext/help_chat/help_chat_cog.py delete mode 100644 commanderbot/ext/help_chat/help_chat_guild_state.py delete mode 100644 commanderbot/ext/help_chat/help_chat_options.py delete mode 100644 commanderbot/ext/help_chat/help_chat_report.py delete mode 100644 commanderbot/ext/help_chat/help_chat_state.py delete mode 100644 commanderbot/ext/help_chat/help_chat_store.py delete mode 100644 commanderbot/ext/help_chat/sql_store/__init__.py delete mode 100644 commanderbot/ext/help_chat/sql_store/help_chat_sql_store.py delete mode 100644 commanderbot/ext/help_chat/sql_store/models.py diff --git a/commanderbot/ext/help_chat/__init__.py b/commanderbot/ext/help_chat/__init__.py deleted file mode 100644 index ad66587..0000000 --- a/commanderbot/ext/help_chat/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from discord.ext.commands import Bot - -from commanderbot.core.utils import is_commander_bot -from commanderbot.ext.help_chat.help_chat_cog import HelpChatCog - - -async def setup(bot: Bot): - assert is_commander_bot(bot) - await bot.add_configured_cog(__name__, HelpChatCog) diff --git a/commanderbot/ext/help_chat/constants.py b/commanderbot/ext/help_chat/constants.py deleted file mode 100644 index fdd6319..0000000 --- a/commanderbot/ext/help_chat/constants.py +++ /dev/null @@ -1,6 +0,0 @@ -DATE_FMT_YYYY_MM_DD = "%Y-%m-%d" -DATE_FMT_YYYY_MM_DD_HH_MM_SS = "%Y-%m-%d %H:%M:%S" - -DEFAULT_REPORT_SPLIT_LENGTH = 1000 -DEFAULT_REPORT_MAX_ROWS = 50 -DEFAULT_REPORT_MIN_SCORE = 1 diff --git a/commanderbot/ext/help_chat/help_chat_cog.py b/commanderbot/ext/help_chat/help_chat_cog.py deleted file mode 100644 index 98513ab..0000000 --- a/commanderbot/ext/help_chat/help_chat_cog.py +++ /dev/null @@ -1,163 +0,0 @@ -from datetime import datetime -from typing import Optional - -from discord import CategoryChannel, TextChannel -from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context -from discord.utils import utcnow - -from commanderbot.ext.help_chat import constants -from commanderbot.ext.help_chat.help_chat_guild_state import HelpChatGuildState -from commanderbot.ext.help_chat.help_chat_options import HelpChatOptions -from commanderbot.ext.help_chat.help_chat_state import HelpChatState -from commanderbot.ext.help_chat.help_chat_store import HelpChatStore -from commanderbot.ext.help_chat.sql_store import HelpChatSQLStore -from commanderbot.lib import is_guild -from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( - InMemoryDatabaseOptions, - SQLiteDatabaseAdapter, - SQLiteDatabaseOptions, - UnsupportedDatabaseOptions, -) -from commanderbot.lib.commands import checks - - -def make_help_chat_store(bot: Bot, cog: Cog, options: HelpChatOptions) -> HelpChatStore: - db_options = options.database - if isinstance(db_options, InMemoryDatabaseOptions): - return HelpChatSQLStore( - bot=bot, - cog=cog, - db=SQLiteDatabaseAdapter(options=SQLiteDatabaseOptions.in_memory()), - ) - if isinstance(db_options, SQLiteDatabaseOptions): - return HelpChatSQLStore( - bot=bot, - cog=cog, - db=SQLiteDatabaseAdapter(options=db_options), - ) - raise UnsupportedDatabaseOptions(db_options) - - -class HelpChatCog(Cog, name="commanderbot.ext.help_chat"): - """ - Designate channels to be recycled for question-and-answer threads. - - Attributes - ---------- - bot - The bot/client instance this cog is attached to. - options - Immutable, pre-defined settings that define core cog behavior. - store - Abstracts the data storage and persistence of this cog. - state - Encapsulates the state and logic of this cog, for each guild. - """ - - def __init__(self, bot: Bot, **options): - self.bot: Bot = bot - self.bot = bot - self.options = HelpChatOptions.from_dict(options) - self.store: HelpChatStore = make_help_chat_store(bot, self, self.options) - self.state = HelpChatState( - bot=self.bot, - cog=self, - guilds=CogGuildStateManager( - bot=self.bot, - cog=self, - factory=lambda guild: HelpChatGuildState( - bot=bot, cog=self, guild=guild, store=self.store - ), - ), - store=self.store, - ) - - # @@ COMMANDS - - @commands.group(name="helpchat", aliases=["hc"]) - @checks.is_administrator() - @checks.guild_only() - async def cmd_helpchat(self, ctx: Context): - if not ctx.invoked_subcommand: - await ctx.send_help(self.cmd_helpchat) - - # @@ helpchat channels - - @cmd_helpchat.group(name="channels", aliases=["ch"]) - async def cmd_helpchat_channels(self, ctx: Context): - if not ctx.invoked_subcommand: - await ctx.send_help(self.cmd_helpchat_channels) - - @cmd_helpchat_channels.command(name="list") - async def cmd_helpchat_channels_list(self, ctx: Context): - assert is_guild(ctx.guild) - await self.state[ctx.guild].list_channels(ctx) - - @cmd_helpchat_channels.command(name="listc") - async def cmd_helpchat_channels_listc(self, ctx: Context): - assert is_guild(ctx.guild) - await self.state[ctx.guild].list_channels_by_creation_date(ctx) - - @cmd_helpchat_channels.command(name="add") - async def cmd_helpchat_channels_add( - self, ctx: Context, *channels: TextChannel | CategoryChannel - ): - if not channels: - await ctx.send_help(self.cmd_helpchat_channels_add) - else: - assert is_guild(ctx.guild) - await self.state[ctx.guild].add_channels(ctx, channels) - - @cmd_helpchat_channels.command(name="remove") - async def cmd_helpchat_channels_remove( - self, ctx: Context, *channels: TextChannel | CategoryChannel - ): - if not channels: - await ctx.send_help(self.cmd_helpchat_channels_remove) - else: - assert is_guild(ctx.guild) - await self.state[ctx.guild].remove_channels(ctx, channels) - - # @@ helpchat report - - @cmd_helpchat.group(name="report") - async def cmd_helpchat_report(self, ctx: Context): - if not ctx.invoked_subcommand: - await ctx.send_help(self.cmd_helpchat_report) - - # @@ helpchat report build - - @cmd_helpchat_report.command(name="build") - async def cmd_helpchat_report_build( - self, - ctx: Context, - after: str, - before: str = "now", - label: Optional[str] = None, - split_length: int = constants.DEFAULT_REPORT_SPLIT_LENGTH, - max_rows: int = constants.DEFAULT_REPORT_MAX_ROWS, - min_score: int = constants.DEFAULT_REPORT_MIN_SCORE, - ): - after_date = datetime.strptime(after, constants.DATE_FMT_YYYY_MM_DD) - before_date = ( - utcnow() - if before == "now" - else datetime.strptime(before, constants.DATE_FMT_YYYY_MM_DD) - ) - actual_label = ( - label - if label is not None - else utcnow().strftime(constants.DATE_FMT_YYYY_MM_DD_HH_MM_SS) - ) - assert is_guild(ctx.guild) - await self.state[ctx.guild].build_report( - ctx, - after_date, - before_date, - label=actual_label, - split_length=split_length, - max_rows=max_rows, - min_score=min_score, - ) diff --git a/commanderbot/ext/help_chat/help_chat_guild_state.py b/commanderbot/ext/help_chat/help_chat_guild_state.py deleted file mode 100644 index 0def43a..0000000 --- a/commanderbot/ext/help_chat/help_chat_guild_state.py +++ /dev/null @@ -1,178 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from typing import Iterable - -from discord import CategoryChannel, TextChannel -from discord.ext.commands import Context - -from commanderbot.ext.help_chat.help_chat_report import HelpChatReportBuildContext -from commanderbot.ext.help_chat.help_chat_store import HelpChannel, HelpChatStore -from commanderbot.lib import is_category_channel, is_text_channel -from commanderbot.lib.cogs import CogGuildState - - -@dataclass -class HelpChatGuildState(CogGuildState): - """ - Encapsulates the state and logic of the help-chat cog, at the guild level. - - Attributes - ----------- - store - The store used to interface with persistent data in a database-agnostic way. - """ - - store: HelpChatStore - - @staticmethod - def _flatten_text_channels( - channels: tuple[TextChannel | CategoryChannel, ...] - ) -> Iterable[TextChannel]: - for channel in channels: - if is_text_channel(channel): - yield channel - elif is_category_channel(channel): - yield from (ch for ch in channel.channels if is_text_channel(ch)) - - async def list_channels(self, ctx: Context): - if help_channels := await self.store.get_help_channels(self.guild): - pairs = [ - (help_channel, help_channel.channel(ctx)) - for help_channel in help_channels - ] - sorted_pairs = sorted(pairs, key=lambda pair: pair[1].position) - await ctx.send( - f"There are {len(sorted_pairs)} help channels:\n" - + "\n".join( - f"{ch.mention} (since {hc.registered_on})" - for hc, ch in sorted_pairs - ) - ) - else: - await ctx.send(f"No help channels") - - async def list_channels_by_creation_date(self, ctx: Context): - if help_channels := await self.store.get_help_channels(self.guild): - channels = [hc.channel(ctx) for hc in help_channels] - sorted_channels = sorted(channels, key=lambda ch: ch.created_at) - lines = [] - for ch in sorted_channels: - ts = int(ch.created_at.timestamp()) - line = f"{ch.mention} `{ch.id}`" - lines.append(line) - content = "\n".join(lines) - await ctx.message.reply(content) - else: - await ctx.send(f"No help channels") - - async def add_channels( - self, - ctx: Context, - channels: tuple[TextChannel | CategoryChannel, ...], - ): - added_help_channels: list[HelpChannel] = [] - already_help_channels: list[HelpChannel] = [] - failed_channels: list[TextChannel] = [] - - for channel in self._flatten_text_channels(channels): - try: - if already_help_channel := await self.store.get_help_channel( - self.guild, channel - ): - already_help_channels.append(already_help_channel) - else: - added_help_channel = await self.store.add_help_channel( - self.guild, channel - ) - added_help_channels.append(added_help_channel) - except: - self.log.exception("Failed to add help channel") - failed_channels.append(channel) - - if added_help_channels: - await ctx.send( - f"✅ These {len(added_help_channels)} channels are now help channels: " - + " ".join(ch.channel(ctx).mention for ch in added_help_channels) - ) - - if already_help_channels: - await ctx.send( - f"💡 These {len(already_help_channels)} channels were already help channels and " - + "haven't changed: " - + " ".join(ch.channel(ctx).mention for ch in already_help_channels) - ) - - if failed_channels: - await ctx.send( - f"⚠️ These {len(failed_channels)} channels caused errors:" - + " ".join(ch.mention for ch in failed_channels) - ) - - async def remove_channels( - self, - ctx: Context, - channels: tuple[TextChannel | CategoryChannel, ...], - ): - removed_help_channels: list[TextChannel] = [] - not_help_channels: list[TextChannel] = [] - failed_channels: list[TextChannel] = [] - - for channel in self._flatten_text_channels(channels): - try: - if await self.store.get_help_channel(self.guild, channel): - await self.store.remove_help_channel(self.guild, channel) - removed_help_channels.append(channel) - else: - not_help_channels.append(channel) - except: - self.log.exception("Failed to remove help channel") - failed_channels.append(channel) - - if removed_help_channels: - await ctx.send( - f"✅ These {len(removed_help_channels)} channels are **no longer** help channels: " - + " ".join(ch.mention for ch in removed_help_channels) - ) - - if not_help_channels: - await ctx.send( - f"💡 These {len(not_help_channels)} channels were **not** already help channels " - + "and haven't changed: " - + " ".join(ch.mention for ch in not_help_channels) - ) - - if failed_channels: - await ctx.send( - f"⚠️ These {len(failed_channels)} channels caused errors:" - + " ".join(ch.mention for ch in failed_channels) - ) - - async def build_report( - self, - ctx: Context, - after: datetime, - before: datetime, - label: str, - split_length: int, - max_rows: int, - min_score: int, - ): - # Build the report, which will send progress updates as each channel is scanned. - help_channels: list[HelpChannel] = await self.store.get_help_channels( - self.guild - ) - report_context = HelpChatReportBuildContext( - ctx, - help_channels=help_channels, - after=after, - before=before, - label=label, - ) - report = await report_context.build() - # Summarize the report in the current channel, using guild default options. - await report.summarize( - ctx, - split_length=split_length, - max_rows=max_rows, - min_score=min_score, - ) diff --git a/commanderbot/ext/help_chat/help_chat_options.py b/commanderbot/ext/help_chat/help_chat_options.py deleted file mode 100644 index 9849e54..0000000 --- a/commanderbot/ext/help_chat/help_chat_options.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass, field -from typing import Any - -from commanderbot.lib.cogs.database import ( - DatabaseOptions, - InMemoryDatabaseOptions, - make_database_options, -) - - -@dataclass -class HelpChatOptions: - database: DatabaseOptions = field(default_factory=InMemoryDatabaseOptions) - - @staticmethod - def from_dict(options: dict[str, Any]) -> "HelpChatOptions": - database_options = make_database_options(options.get("database")) - return HelpChatOptions(database=database_options) diff --git a/commanderbot/ext/help_chat/help_chat_report.py b/commanderbot/ext/help_chat/help_chat_report.py deleted file mode 100644 index 884f6f1..0000000 --- a/commanderbot/ext/help_chat/help_chat_report.py +++ /dev/null @@ -1,356 +0,0 @@ -import asyncio -import io -from collections import defaultdict -from dataclasses import dataclass, field -from datetime import datetime -from enum import Enum -from typing import Iterable, Optional, cast - -from discord import AllowedMentions, Embed, File, Message, TextChannel, User -from discord.ext.commands import Context -from discord.utils import utcnow - -from commanderbot.ext.help_chat import constants -from commanderbot.ext.help_chat.help_chat_store import HelpChannel -from commanderbot.lib import IDType - - -class ChannelStatus(Enum): - INCOMPLETE = 0 - IN_PROGRESS = 1 - COMPLETE = 2 - - -STATUS_EMOJI = { - ChannelStatus.INCOMPLETE: "⬜", - ChannelStatus.IN_PROGRESS: "🔄", - ChannelStatus.COMPLETE: "✅", -} - - -@dataclass -class UserRecord: - user_id: IDType - username: str - - # map YYYY-MM-DD to score (initialized at 0) - score_by_day: defaultdict[tuple[int, int, int], int] = field( - default_factory=lambda: defaultdict(lambda: 0) - ) - - @property - def total_score(self) -> int: - return sum(self.score_by_day.values()) - - @property - def days_active(self) -> int: - return len(self.score_by_day) - - -UserTable = dict[IDType, UserRecord] - - -@dataclass -class ChannelState: - help_channel: HelpChannel - channel: TextChannel - status: ChannelStatus = ChannelStatus.INCOMPLETE - total_messages: int = 0 - total_message_length: int = 0 - - @property - def status_text(self) -> str: - if self.total_messages > 0: - return f"{self.channel.mention} ({self.total_messages:,} messages)" - return self.channel.mention - - -@dataclass -class HelpChatSummaryOptions: - split_length: int - max_rows: int - min_score: int - - -@dataclass -class HelpChatReport: - after: datetime - before: datetime - label: str - built_at: datetime - channel_states: list[ChannelState] - user_table: UserTable - - @property - def title(self) -> str: - return f"Help-chat Report: {self.label}" - - def make_summary_batch_embed( - self, batch_no: int, count_batches: int, text: str - ) -> Embed: - # Create the base embed. - embed = Embed( - type="rich", - title=self.title, - description=text, - colour=0x77B255, - ) - # If we've got more than a single batch, include the batch number in the footer. - if count_batches > 1: - embed.set_footer(text=f"{batch_no} of {count_batches}") - # Return the final embed. - return embed - - def get_user_records(self) -> list[UserRecord]: - # Get user results, in arbitrary order. - return list(self.user_table.values()) - - def get_sorted_user_records(self) -> list[UserRecord]: - # Get user results, sorted by score in descending order. - return sorted( - self.get_user_records(), key=lambda user_record: -user_record.total_score - ) - - def build_results_file_line(self, user_record: UserRecord) -> str: - return ",".join( - str(elem) - for elem in ( - user_record.user_id, - user_record.username, - user_record.total_score, - user_record.days_active, - ) - ) - - def build_results_file(self) -> File: - sorted_user_records = self.get_sorted_user_records() - lines = [ - "User ID,Username,Score,Days Active", - *( - self.build_results_file_line(user_record) - for user_record in sorted_user_records - ), - ] - file_contents = "\n".join(lines) - filename = f"{self.title}.csv" - fp = io.StringIO(file_contents) - file = File(fp=fp, filename=filename) - return file - - def build_summary_lines(self, options: HelpChatSummaryOptions) -> Iterable[str]: - sorted_user_records = self.get_sorted_user_records() - # Print some initial information about the report. - count_results = len(sorted_user_records) - after_str = self.after.strftime(constants.DATE_FMT_YYYY_MM_DD) - before_str = self.before.strftime(constants.DATE_FMT_YYYY_MM_DD) - max_results = min(options.max_rows, count_results) - yield ( - f"Showing the top {max_results:,} of {count_results:,} results" - + f" with a score of at least {options.min_score:,}." - + " A user's score is determined by summing the length of all their messages" - + f" in help channels from {after_str} up to {before_str}." - + "\n" - ) - # Print a line for each user. - for i, user_record in enumerate(sorted_user_records): - if (i >= options.max_rows) or (user_record.total_score < options.min_score): - break - yield ( - f"<@{user_record.user_id}>: **{user_record.total_score:,}**" - + f" ({user_record.days_active:,} days active)" - ) - - def batch_summary_text(self, options: HelpChatSummaryOptions) -> Iterable[str]: - batch = "" - for line in self.build_summary_lines(options): - would_be_text = batch + "\n" + line - if len(would_be_text) <= options.split_length: - batch = would_be_text - else: - yield batch - batch = line - if batch: - yield batch - - async def summarize(self, ctx: Context, **kwargs): - options = HelpChatSummaryOptions(**kwargs) - # Create a simple CSV file with the full results. - results_file = self.build_results_file() - # Split the response into individual batches, to avoid hitting the message cap. - batches = list(self.batch_summary_text(options)) - # Count the number of batches so we can include this information in the response. - count_batches = len(batches) - # Send the first (and possibly only) batch as an embed with some initial response text, - # with the results file attached. - first_batch = batches[0] - await ctx.message.reply( - embed=self.make_summary_batch_embed(1, count_batches, first_batch), - file=results_file, - ) - # Send an additional embed for each remaining batch (if any). - for i, batch in enumerate(batches[1:]): - # Wait a few seconds before posting each additional embed, just in case. - await asyncio.sleep(5) - await ctx.send( - content=None, - embed=self.make_summary_batch_embed(i + 2, count_batches, batch), - ) - - -class HelpChatReportBuildContext: - def __init__( - self, - ctx: Context, - help_channels: list[HelpChannel], - after: datetime, - before: datetime, - label: str, - ): - self.ctx: Context = ctx - self.help_channels: list[HelpChannel] = help_channels - self.after: datetime = after - self.before: datetime = before - self.label: str = label - - self._progress_message: Optional[Message] = field(init=False, default=None) - self._built_at: datetime = field(init=False, default_factory=lambda: utcnow()) - self._channel_states: list[ChannelState] = field( - init=False, default_factory=lambda: [] - ) - self._user_table: UserTable = field(init=False, default_factory=lambda: {}) - - @property - def channel(self) -> TextChannel: - return cast(TextChannel, self.ctx.channel) - - def reset(self): - self._built_at = utcnow() - self._progress_message = None - self._channel_states = [ - ChannelState(help_channel, help_channel.channel(self.ctx)) - for help_channel in self.help_channels - ] - self._user_table = {} - - def get_states_with_status(self, status: ChannelStatus) -> list[ChannelState]: - return [state for state in self._channel_states if state.status == status] - - def get_states_incomplete(self) -> list[ChannelState]: - return self.get_states_with_status(ChannelStatus.INCOMPLETE) - - def get_states_in_progress(self) -> list[ChannelState]: - return self.get_states_with_status(ChannelStatus.IN_PROGRESS) - - def get_states_complete(self) -> list[ChannelState]: - return self.get_states_with_status(ChannelStatus.COMPLETE) - - def is_finished(self) -> bool: - for state in self._channel_states: - if state.status != ChannelStatus.COMPLETE: - return False - return True - - def build_progress_text(self) -> str: - after_str = self.after.strftime(constants.DATE_FMT_YYYY_MM_DD) - before_str = self.before.strftime(constants.DATE_FMT_YYYY_MM_DD) - - progress_emoji = " ".join( - STATUS_EMOJI[state.status] for state in self._channel_states - ) - - status_text = "" - if states_in_progress := self.get_states_in_progress(): - status_text = "Scanning: " + " ".join( - state.status_text for state in states_in_progress - ) - elif self.is_finished(): - status_text = "Done!" - - text = ( - f"\nScanning message history from {after_str} up to {before_str}, across" - + f" {len(self.help_channels)} help channels..." - + f"\n> {progress_emoji}" - + f"\n{status_text}" - ) - - return text - - async def update(self): - # Update the progress message, or send a new one if it doesn't already exist. - progress_text = self.build_progress_text() - if self._progress_message is not None: - await self._progress_message.edit( - content=progress_text, - allowed_mentions=AllowedMentions(replied_user=False), - ) - else: - self._progress_message = await self.ctx.message.reply( - content=progress_text, - mention_author=False, - ) - - async def build(self) -> HelpChatReport: - # Reset temporary state variables. - self.reset() - # Invoke an update, which will send the initial progress message. - await self.update() - # Since scanning message history is a long/expensive operation, we'll make it look like - # we're typing until we're finished. - async with self.channel.typing(): - # Iterate over one channel at a time, which will help us convey progress. - for channel_state in self._channel_states: - # Immediately mark the channel as in-progress and send an update, which will make an - # edit to the progress message. - channel_state.status = ChannelStatus.IN_PROGRESS - await self.update() - # Iterate over the history of the channel within the given timeframe. - history = channel_state.channel.history( - after=self.after, before=self.before, limit=None - ) - channel_state.total_messages = 0 - channel_state.total_message_length = 0 - async for message in history: - message: Message - content = message.content - # Skip messages that don't have any content. - if not isinstance(content, str): - continue - # Update the channel state. - message_length = len(content) - channel_state.total_messages += 1 - channel_state.total_message_length += message_length - # Update the author's record. - author: User = cast(User, message.author) - user_record = self._user_table.get(author.id) - if user_record is None: - # Create a new record for this user, if one does not already exist. - user_record = UserRecord( - user_id=author.id, username=str(author) - ) - self._user_table[author.id] = user_record - # Build the daily record key from YYYY-MM-DD. - daily_key = ( - message.created_at.year, - message.created_at.month, - message.created_at.day, - ) - # Increment the user's daily score by message count. - user_record.score_by_day[daily_key] += message_length - # Update progress every X messages. - if channel_state.total_messages % 1000 == 0: - await self.update() - # Mark the channel as complete. Don't bother updating here, because we're going to - # update anyway as soon as we either (1) finish and run off the end of the loop or - # (2) enter the next iteration of the loop and mark the next channel to in-progress. - channel_state.status = ChannelStatus.COMPLETE - # Invoking one final update, which will make the final edit to the progress message. - await self.update() - # Return the final result, encapsulated in an object. - return HelpChatReport( - after=self.after, - before=self.before, - label=self.label, - built_at=self._built_at, - channel_states=self._channel_states, - user_table=self._user_table, - ) diff --git a/commanderbot/ext/help_chat/help_chat_state.py b/commanderbot/ext/help_chat/help_chat_state.py deleted file mode 100644 index e0515bb..0000000 --- a/commanderbot/ext/help_chat/help_chat_state.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass - -from commanderbot.ext.help_chat.help_chat_guild_state import HelpChatGuildState -from commanderbot.ext.help_chat.help_chat_store import HelpChatStore -from commanderbot.lib.cogs import GuildPartitionedCogState - - -@dataclass -class HelpChatState(GuildPartitionedCogState[HelpChatGuildState]): - """ - Encapsulates the state and logic of the help-chat cog, for each guild. - """ - - store: HelpChatStore diff --git a/commanderbot/ext/help_chat/help_chat_store.py b/commanderbot/ext/help_chat/help_chat_store.py deleted file mode 100644 index c7332b1..0000000 --- a/commanderbot/ext/help_chat/help_chat_store.py +++ /dev/null @@ -1,37 +0,0 @@ -from datetime import datetime -from typing import Optional, Protocol - -from discord import Guild, TextChannel -from discord.ext.commands import Context - -from commanderbot.lib import ChannelID - - -class HelpChannel(Protocol): - channel_id: ChannelID - registered_on: datetime - - def channel(self, ctx: Context) -> TextChannel: - ... - - -class HelpChatStore(Protocol): - """ - Abstracts the data storage and persistence of the help-chat cog. - """ - - async def get_help_channels(self, guild: Guild) -> list[HelpChannel]: - ... - - async def get_help_channel( - self, guild: Guild, channel: TextChannel - ) -> Optional[HelpChannel]: - ... - - async def add_help_channel(self, guild: Guild, channel: TextChannel) -> HelpChannel: - ... - - async def remove_help_channel( - self, guild: Guild, channel: TextChannel - ) -> HelpChannel: - ... diff --git a/commanderbot/ext/help_chat/sql_store/__init__.py b/commanderbot/ext/help_chat/sql_store/__init__.py deleted file mode 100644 index 3fa2fed..0000000 --- a/commanderbot/ext/help_chat/sql_store/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .help_chat_sql_store import * diff --git a/commanderbot/ext/help_chat/sql_store/help_chat_sql_store.py b/commanderbot/ext/help_chat/sql_store/help_chat_sql_store.py deleted file mode 100644 index 1b6a941..0000000 --- a/commanderbot/ext/help_chat/sql_store/help_chat_sql_store.py +++ /dev/null @@ -1,84 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, cast - -from discord import Guild, TextChannel -from sqlalchemy.future import select -from sqlalchemy.sql.expression import insert - -from commanderbot.ext.help_chat.help_chat_store import HelpChannel -from commanderbot.ext.help_chat.sql_store import models -from commanderbot.lib.cogs import CogStore -from commanderbot.lib.cogs.database import SQLDatabaseAdapter - -__all__ = ("HelpChatSQLStore",) - - -# @implements HelpChatStore -@dataclass -class HelpChatSQLStore(CogStore): - """ - Implementation of `HelpChatStore` that uses a SQL database to persist state. - """ - - db: SQLDatabaseAdapter - - async def _ensure_init(self): - async with self.db.connect() as conn: - await conn.run_sync(models.metadata.create_all) - - async def get_help_channels(self, guild: Guild) -> list[HelpChannel]: - await self._ensure_init() - async with self.db.session() as session: - result = await session.execute( - select(models.HelpChannel).where( - models.HelpChannel.guild_id == guild.id - ) - ) - rows = result.all() - help_channels = [row[0] for row in rows] - return cast(list[models.HelpChannel], help_channels) - - async def get_help_channel( - self, guild: Guild, channel: TextChannel - ) -> Optional[HelpChannel]: - await self._ensure_init() - async with self.db.session() as session: - help_channel = await session.get( - models.HelpChannel, {"guild_id": guild.id, "channel_id": channel.id} - ) - return cast(Optional[models.HelpChannel], help_channel) - - async def add_help_channel(self, guild: Guild, channel: TextChannel) -> HelpChannel: - await self._ensure_init() - async with self.db.session() as session: - # insert row - await session.execute( - insert( - models.HelpChannel, - [{"guild_id": guild.id, "channel_id": channel.id}], - ) - ) - # commit - await session.commit() - # get row back - help_channel = await session.get( - models.HelpChannel, {"guild_id": guild.id, "channel_id": channel.id} - ) - assert isinstance(help_channel, models.HelpChannel) - return help_channel - - async def remove_help_channel( - self, guild: Guild, channel: TextChannel - ) -> HelpChannel: - await self._ensure_init() - async with self.db.session() as session: - # get row - help_channel = await session.get( - models.HelpChannel, {"guild_id": guild.id, "channel_id": channel.id} - ) - # delete row - await session.delete(help_channel) - # commit - await session.commit() - assert isinstance(help_channel, models.HelpChannel) - return help_channel diff --git a/commanderbot/ext/help_chat/sql_store/models.py b/commanderbot/ext/help_chat/sql_store/models.py deleted file mode 100644 index a0caf32..0000000 --- a/commanderbot/ext/help_chat/sql_store/models.py +++ /dev/null @@ -1,60 +0,0 @@ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any - -from discord import TextChannel -from discord.ext.commands import Context -from sqlalchemy import Column, DateTime, Integer, func -from sqlalchemy.orm.decl_api import registry - -from commanderbot.lib import is_text_channel - -mapper_registry = registry() -metadata = mapper_registry.metadata -sqmetakey = "sqmeta" - - -def sqmeta(meta: Any) -> dict[str, Any]: - return {sqmetakey: meta} - - -class Base: - __sa_dataclass_metadata_key__ = sqmetakey - - -@mapper_registry.mapped -@dataclass -class HelpChannel(Base): - __tablename__ = "help_channel" - - # The ID of the corresponding Discord guild. - guild_id: int = field( - init=False, - metadata=sqmeta(Column(Integer, primary_key=True, autoincrement=False)), - ) - - # The ID of the corresponding Discord channel. - channel_id: int = field( - init=False, - metadata=sqmeta(Column(Integer, primary_key=True, autoincrement=False)), - ) - - # The date the corresponding Discord channel was registered as a help channel. - registered_on: datetime = field( - init=False, metadata=sqmeta(Column(DateTime, server_default=func.now())) - ) - - # (Required for `server_default` attributes.) - __mapper_args__ = {"eager_defaults": True} - - def channel(self, ctx: Context) -> TextChannel: - channel = ctx.bot.get_channel(self.channel_id) - if channel is None: - raise ValueError( - f"Failed to resolve help channel from channel ID: {self.channel}" - ) - if not is_text_channel(channel): - raise ValueError( - f"Help channel resolved into non-text type channel: {channel}" - ) - return channel From 64372b7ca68e681007d1e5363ad3fd3fc055f427 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 12 Jun 2026 01:57:59 -0700 Subject: [PATCH 009/158] Reworked the Json database to use Pydantic --- .../lib/databases/json_db/__init__.py | 2 + commanderbot/lib/databases/json_db/config.py | 44 ++++++++ commanderbot/lib/databases/json_db/db.py | 105 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 commanderbot/lib/databases/json_db/__init__.py create mode 100644 commanderbot/lib/databases/json_db/config.py create mode 100644 commanderbot/lib/databases/json_db/db.py diff --git a/commanderbot/lib/databases/json_db/__init__.py b/commanderbot/lib/databases/json_db/__init__.py new file mode 100644 index 0000000..eea0ce0 --- /dev/null +++ b/commanderbot/lib/databases/json_db/__init__.py @@ -0,0 +1,2 @@ +from .config import * +from .db import * diff --git a/commanderbot/lib/databases/json_db/config.py b/commanderbot/lib/databases/json_db/config.py new file mode 100644 index 0000000..743440f --- /dev/null +++ b/commanderbot/lib/databases/json_db/config.py @@ -0,0 +1,44 @@ +from pathlib import Path +from typing import Annotated, Literal, Optional, TypeIs + +from pydantic import BaseModel, BeforeValidator, Field + +__all__ = ( + "InMemoryDatabaseOptions", + "JsonFileDatabaseOptions", + "JsonDBOptions", +) + + +class InMemoryDatabaseOptions(BaseModel): + type: Literal["in_memory"] = "in_memory" + + +class JsonFileDatabaseOptions(BaseModel): + type: Literal["json_file"] = "json_file" + path: Path + no_init: bool = False + indent: Optional[int] = None + + +def validate_options(data: Optional[str | dict]) -> dict: + if data is None: + return {"type": "in_memory"} + elif isinstance(data, str): + return {"type": "json_file", "path": data} + return data + + +JsonDBOptions = Annotated[ + InMemoryDatabaseOptions | JsonFileDatabaseOptions, + Field(default_factory=InMemoryDatabaseOptions, discriminator="type"), + BeforeValidator(validate_options), +] + + +def is_in_memory_options(obj: object) -> TypeIs[InMemoryDatabaseOptions]: + return isinstance(obj, InMemoryDatabaseOptions) + + +def is_json_file_options(obj: object) -> TypeIs[JsonFileDatabaseOptions]: + return isinstance(obj, JsonFileDatabaseOptions) diff --git a/commanderbot/lib/databases/json_db/db.py b/commanderbot/lib/databases/json_db/db.py new file mode 100644 index 0000000..b7b4dbb --- /dev/null +++ b/commanderbot/lib/databases/json_db/db.py @@ -0,0 +1,105 @@ +import asyncio +from dataclasses import dataclass, field +from logging import Logger, getLogger +from pathlib import Path +from typing import Optional + +from pydantic import BaseModel + +from .config import JsonDBOptions, is_json_file_options + + +@dataclass +class JsonDB[CacheType: BaseModel]: + """ + Wraps common operations for persistent data backed by a simple JSON file. + + Attributes + ---------- + options + Immutable, pre-defined settings that define core database behavior. + cache_cls + A type that inherits from `pydantic.BaseModel` which will be used for + initializing the cache. + log + A logger named in a uniquely identifiable way. + """ + + options: JsonDBOptions + cache_cls: type[CacheType] + log: Logger = field(init=False) + + __cache: Optional[CacheType] = field(init=False, default=None) + """ + Lazily-initialized in-memory representation of state. The reason this is lazy is + because it needs to be asynchronously initialized from within an async method. + **Do not use this member; use `get_cache()` instead.** + """ + + __cache_lock = asyncio.Lock() + """ + Lock used to avoid potential race conditions where multiple concurrent asyncio + tasks initialize the cache or write it to the database file. + """ + + def __post_init__(self): + if is_json_file_options(self.options): + self.log = getLogger( + f"{self.options.path.name} ({self.__class__.__name__}#{id(self)})" + ) + else: + self.log = getLogger(f":memory: ({self.__class__.__name__}#{id(self)})") + + def _read(self) -> str: + """ + Read and return the data from the database file. + """ + assert is_json_file_options(self.options) + path: Path = self.options.path + try: + # Attempt read the file. + return self.options.path.read_text() + except FileNotFoundError as ex: + if self.options.no_init: + # If the file doesn't exist, and we've been specifically told not to + # automatically create it, then let the error fall through. + raise ex + else: + # Otherwise, we can go ahead and automatically initialize the file. + self.log.warning( + f"Initializing database file because it doesn't already exist: {path}" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{}") + return "{}" + + def _write(self, data: str): + """ + Write the given data to the database file. + """ + assert is_json_file_options(self.options) + self.options.path.write_text(data) + + async def get_cache(self) -> CacheType: + """ + Create the cache if it doesn't already exist, and then return it. + """ + async with self.__cache_lock: + if self.__cache is None: + self.log.info("Lazily-initializing new cache...") + if is_json_file_options(self.options): + data: str = await asyncio.to_thread(self._read) + self.__cache = self.cache_cls.model_validate_json(data) + else: + self.__cache = self.cache_cls() + return self.__cache + + async def commit(self): + """ + Commit any changes to the cache, forcing a write to the database. + """ + cache: CacheType = await self.get_cache() + async with self.__cache_lock: + if is_json_file_options(self.options): + data: str = cache.model_dump_json(indent=self.options.indent) + await asyncio.to_thread(self._write, data) From 07bd1738b5b571bde9b9fc893821799209daeedb Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 12 Jun 2026 02:00:40 -0700 Subject: [PATCH 010/158] Moved the old Json database to a "v1" folder so we can incrementally update our extensions --- .../json_db/v1}/__init__.py | 0 .../json_db/v1}/adapters.py | 73 +------------------ .../json_db/v1}/options.py | 22 ------ 3 files changed, 3 insertions(+), 92 deletions(-) rename commanderbot/lib/{cogs/database => databases/json_db/v1}/__init__.py (100%) rename commanderbot/lib/{cogs/database => databases/json_db/v1}/adapters.py (62%) rename commanderbot/lib/{cogs/database => databases/json_db/v1}/options.py (82%) diff --git a/commanderbot/lib/cogs/database/__init__.py b/commanderbot/lib/databases/json_db/v1/__init__.py similarity index 100% rename from commanderbot/lib/cogs/database/__init__.py rename to commanderbot/lib/databases/json_db/v1/__init__.py diff --git a/commanderbot/lib/cogs/database/adapters.py b/commanderbot/lib/databases/json_db/v1/adapters.py similarity index 62% rename from commanderbot/lib/cogs/database/adapters.py rename to commanderbot/lib/databases/json_db/v1/adapters.py index db16be1..16f07fb 100644 --- a/commanderbot/lib/cogs/database/adapters.py +++ b/commanderbot/lib/databases/json_db/v1/adapters.py @@ -1,36 +1,14 @@ import asyncio from dataclasses import dataclass, field from logging import Logger, getLogger -from pathlib import Path -from typing import Callable, Optional, Protocol +from typing import Callable, Optional -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine -from sqlalchemy.ext.asyncio.engine import AsyncConnection - -from commanderbot.lib.cogs.database.options import ( - JsonFileDatabaseOptions, - SQLiteDatabaseOptions, -) from commanderbot.lib.json import json_dump_async, json_load_async from commanderbot.lib.types import JsonObject -__all__ = ( - "SQLDatabaseAdapter", - "JsonFileDatabaseAdapter", - "SQLiteDatabaseAdapter", -) - - -class SQLDatabaseAdapter(Protocol): - """ - A protocol class for SQL databases - """ - - def connect(self) -> AsyncConnection: ... - - def begin(self) -> AsyncConnection: ... +from .options import JsonFileDatabaseOptions - def session(self) -> AsyncSession: ... +__all__ = ("JsonFileDatabaseAdapter",) @dataclass @@ -112,48 +90,3 @@ async def read(self) -> JsonObject: async def write(self, data: JsonObject): """Write the given data to the database file.""" await json_dump_async(data, self.options.path, indent=self.options.indent) # type: ignore - - -@dataclass -class SQLiteDatabaseAdapter: - """ - Wraps common operations for persistent data backed by a SQLite database. - - Attributes - ---------- - options - Immutable, pre-defined settings that define core database behavior. - log - A logger named in a uniquely identifiable way. - engine - The underlying SQLAlchemy engine object. - """ - - options: SQLiteDatabaseOptions - - log: Logger = field(init=False) - engine: AsyncEngine = field(init=False) - - @property - def path_or_in_memory(self) -> Optional[Path]: - return self.options.path - - def __post_init__(self): - if path := self.path_or_in_memory: - self.log = getLogger(f"{path.name} ({self.__class__.__name__}#{id(self)})") - self.log.info(f"Creating SQLite engine at: {path}") - path_str = f"sqlite+aiosqlite:///{path}" - else: - self.log = getLogger(f"IN-MEMORY ({self.__class__.__name__}#{id(self)})") - self.log.info("Creating IN-MEMORY SQLite engine") - path_str = "sqlite://" - self.engine = create_async_engine(path_str) - - def connect(self) -> AsyncConnection: - return self.engine.connect() - - def begin(self) -> AsyncConnection: - return self.engine.begin() - - def session(self) -> AsyncSession: - return AsyncSession(self.engine) diff --git a/commanderbot/lib/cogs/database/options.py b/commanderbot/lib/databases/json_db/v1/options.py similarity index 82% rename from commanderbot/lib/cogs/database/options.py rename to commanderbot/lib/databases/json_db/v1/options.py index 6e5f2e5..2c99f84 100644 --- a/commanderbot/lib/cogs/database/options.py +++ b/commanderbot/lib/databases/json_db/v1/options.py @@ -8,7 +8,6 @@ "DatabaseOptions", "InMemoryDatabaseOptions", "JsonFileDatabaseOptions", - "SQLiteDatabaseOptions", "InvalidDatabaseOptions", "UnknownDatabaseType", "MissingDatabaseType", @@ -42,23 +41,6 @@ def from_dict(cls, options: JsonObject) -> Self: ) -@dataclass -class SQLiteDatabaseOptions(DatabaseOptions): - path: Optional[Path] - no_init: Optional[bool] = None - - @classmethod - def from_dict(cls, options: JsonObject) -> Self: - return cls( - path=Path(options["path"]), - no_init=options.get("no_init"), - ) - - @classmethod - def in_memory(cls) -> Self: - return cls(path=None) - - class InvalidDatabaseOptions(Exception): def __init__(self, raw_options: Any): self.raw_options = raw_options @@ -102,8 +84,6 @@ def make_database_options(obj: Any) -> DatabaseOptions: # type of database to use. if path.suffix == ".json": return JsonFileDatabaseOptions(path=path) - if path.suffix == ".sqlite": - return SQLiteDatabaseOptions(path=path) if isinstance(obj, dict): # If it's a dict, expect a `type` to be explicitly defined. db_type = obj.get("type") @@ -113,8 +93,6 @@ def make_database_options(obj: Any) -> DatabaseOptions: return InMemoryDatabaseOptions() if db_type == "json": return JsonFileDatabaseOptions.from_dict(obj) - if db_type == "sqlite": - return SQLiteDatabaseOptions.from_dict(obj) raise UnknownDatabaseType(obj, db_type) except Exception as ex: raise InvalidDatabaseOptions(obj) from ex From 53c046884a1b93cfdbc701ecaf8cb9d4e412e9d5 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 12 Jun 2026 02:02:00 -0700 Subject: [PATCH 011/158] Updated imports --- commanderbot/ext/automod/automod_cog.py | 2 +- commanderbot/ext/automod/automod_json_store.py | 2 +- commanderbot/ext/automod/automod_options.py | 2 +- commanderbot/ext/faq/faq_cog.py | 2 +- commanderbot/ext/faq/faq_json_store.py | 10 +++++----- commanderbot/ext/faq/faq_options.py | 2 +- commanderbot/ext/feeds/feeds_cog.py | 2 +- commanderbot/ext/feeds/feeds_json_store.py | 2 +- commanderbot/ext/feeds/feeds_options.py | 2 +- commanderbot/ext/friday/friday_cog.py | 2 +- commanderbot/ext/friday/friday_json_store.py | 2 +- commanderbot/ext/friday/friday_options.py | 2 +- commanderbot/ext/help_forum/help_forum_cog.py | 2 +- commanderbot/ext/help_forum/help_forum_json_store.py | 2 +- commanderbot/ext/help_forum/help_forum_options.py | 2 +- commanderbot/ext/invite/invite_cog.py | 2 +- commanderbot/ext/invite/invite_json_store.py | 2 +- commanderbot/ext/invite/invite_options.py | 2 +- commanderbot/ext/roles/roles_cog.py | 2 +- commanderbot/ext/roles/roles_json_store.py | 2 +- commanderbot/ext/roles/roles_options.py | 2 +- commanderbot/ext/stacktracer/stacktracer_cog.py | 2 +- commanderbot/ext/stacktracer/stacktracer_json_store.py | 2 +- commanderbot/ext/stacktracer/stacktracer_options.py | 2 +- commanderbot/ext/sudo/sudo_cog.py | 2 +- commanderbot/ext/sudo/sudo_data.py | 4 ++-- 26 files changed, 31 insertions(+), 31 deletions(-) diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index 383156b..91b3b0d 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -37,7 +37,7 @@ utils, ) from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, diff --git a/commanderbot/ext/automod/automod_json_store.py b/commanderbot/ext/automod/automod_json_store.py index ab80fdb..08ec7b3 100644 --- a/commanderbot/ext/automod/automod_json_store.py +++ b/commanderbot/ext/automod/automod_json_store.py @@ -8,7 +8,7 @@ from commanderbot.ext.automod.automod_store import AutomodRule from commanderbot.lib import JsonObject, LogOptions, RoleSet, utils from commanderbot.lib.cogs import CogStore -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter # @implements AutomodStore diff --git a/commanderbot/ext/automod/automod_options.py b/commanderbot/ext/automod/automod_options.py index 0defbeb..828bca7 100644 --- a/commanderbot/ext/automod/automod_options.py +++ b/commanderbot/ext/automod/automod_options.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from typing import Any, Self -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( DatabaseOptions, InMemoryDatabaseOptions, make_database_options, diff --git a/commanderbot/ext/faq/faq_cog.py b/commanderbot/ext/faq/faq_cog.py index e3f3d83..7a4dbdb 100644 --- a/commanderbot/ext/faq/faq_cog.py +++ b/commanderbot/ext/faq/faq_cog.py @@ -23,7 +23,7 @@ utils, ) from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, diff --git a/commanderbot/ext/faq/faq_json_store.py b/commanderbot/ext/faq/faq_json_store.py index a574f65..e56849c 100644 --- a/commanderbot/ext/faq/faq_json_store.py +++ b/commanderbot/ext/faq/faq_json_store.py @@ -8,7 +8,7 @@ from commanderbot.ext.faq.faq_store import CategoryEntry, FaqEntry from commanderbot.lib import UserID from commanderbot.lib.cogs import CogStore -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter # @implements FaqStore @@ -105,7 +105,7 @@ async def query_faqs_by_match( content: str, *, sort: bool = False, - cap: Optional[int] = None + cap: Optional[int] = None, ) -> AsyncIterable[FaqEntry]: cache = await self.db.get_cache() async for entry in cache.query_faqs_by_match( @@ -129,7 +129,7 @@ async def get_faqs( faq_filter: Optional[str] = None, case_sensitive: bool = False, sort: bool = False, - cap: Optional[int] = None + cap: Optional[int] = None, ) -> AsyncIterable[FaqEntry]: cache = await self.db.get_cache() async for entry in cache.get_faqs( @@ -149,7 +149,7 @@ async def get_faqs_and_aliases( item_filter: Optional[str] = None, case_sensitive: bool = False, sort: bool = False, - cap: Optional[int] = None + cap: Optional[int] = None, ) -> AsyncIterable[FaqEntry | tuple[str, FaqEntry]]: cache = await self.db.get_cache() async for item in cache.get_faqs_and_aliases( @@ -208,7 +208,7 @@ async def get_categories( category_filter: Optional[str] = None, case_sensitive: bool = False, sort: bool = False, - cap: Optional[int] = None + cap: Optional[int] = None, ) -> AsyncIterable[CategoryEntry]: cache = await self.db.get_cache() async for category in cache.get_categories( diff --git a/commanderbot/ext/faq/faq_options.py b/commanderbot/ext/faq/faq_options.py index 8965b5b..921bffa 100644 --- a/commanderbot/ext/faq/faq_options.py +++ b/commanderbot/ext/faq/faq_options.py @@ -2,7 +2,7 @@ from typing import Any, Optional, Self from commanderbot.lib import FromDataMixin -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( DatabaseOptions, InMemoryDatabaseOptions, make_database_options, diff --git a/commanderbot/ext/feeds/feeds_cog.py b/commanderbot/ext/feeds/feeds_cog.py index 678e2ae..7e92fa2 100644 --- a/commanderbot/ext/feeds/feeds_cog.py +++ b/commanderbot/ext/feeds/feeds_cog.py @@ -21,7 +21,7 @@ from commanderbot.lib import MessageableGuildChannel, is_guild from commanderbot.lib.app_commands import checks from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, diff --git a/commanderbot/ext/feeds/feeds_json_store.py b/commanderbot/ext/feeds/feeds_json_store.py index 397b938..e9121e6 100644 --- a/commanderbot/ext/feeds/feeds_json_store.py +++ b/commanderbot/ext/feeds/feeds_json_store.py @@ -8,7 +8,7 @@ from commanderbot.ext.feeds.providers import FeedType from commanderbot.lib import ChannelID, MessageID, RoleID, UserID from commanderbot.lib.cogs import CogStore -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter # @implements FeedsStore diff --git a/commanderbot/ext/feeds/feeds_options.py b/commanderbot/ext/feeds/feeds_options.py index d9fb2c2..adf9bd6 100644 --- a/commanderbot/ext/feeds/feeds_options.py +++ b/commanderbot/ext/feeds/feeds_options.py @@ -7,7 +7,7 @@ MinecraftJavaUpdatesOptions, ) from commanderbot.lib import FromDataMixin -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( DatabaseOptions, InMemoryDatabaseOptions, make_database_options, diff --git a/commanderbot/ext/friday/friday_cog.py b/commanderbot/ext/friday/friday_cog.py index 568746d..3ed9899 100644 --- a/commanderbot/ext/friday/friday_cog.py +++ b/commanderbot/ext/friday/friday_cog.py @@ -23,7 +23,7 @@ is_messagable_guild_channel, ) from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, diff --git a/commanderbot/ext/friday/friday_json_store.py b/commanderbot/ext/friday/friday_json_store.py index 09c63c8..b6f06f8 100644 --- a/commanderbot/ext/friday/friday_json_store.py +++ b/commanderbot/ext/friday/friday_json_store.py @@ -7,7 +7,7 @@ from commanderbot.ext.friday.friday_store import FridayRule from commanderbot.lib import ChannelID, UserID from commanderbot.lib.cogs import CogStore -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter # @implements FridayStore diff --git a/commanderbot/ext/friday/friday_options.py b/commanderbot/ext/friday/friday_options.py index feed372..913bcd8 100644 --- a/commanderbot/ext/friday/friday_options.py +++ b/commanderbot/ext/friday/friday_options.py @@ -2,7 +2,7 @@ from typing import Any, Optional, Self from commanderbot.lib import FromDataMixin -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( DatabaseOptions, InMemoryDatabaseOptions, make_database_options, diff --git a/commanderbot/ext/help_forum/help_forum_cog.py b/commanderbot/ext/help_forum/help_forum_cog.py index 081d7fb..0b03452 100644 --- a/commanderbot/ext/help_forum/help_forum_cog.py +++ b/commanderbot/ext/help_forum/help_forum_cog.py @@ -33,7 +33,7 @@ from commanderbot.lib import is_bot, is_forum_channel, is_guild, is_thread from commanderbot.lib.app_commands import EmojiTransformer from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, diff --git a/commanderbot/ext/help_forum/help_forum_json_store.py b/commanderbot/ext/help_forum/help_forum_json_store.py index 58cb1c9..ead5ecc 100644 --- a/commanderbot/ext/help_forum/help_forum_json_store.py +++ b/commanderbot/ext/help_forum/help_forum_json_store.py @@ -6,7 +6,7 @@ from commanderbot.ext.help_forum.help_forum_data import HelpForumData from commanderbot.ext.help_forum.help_forum_store import HelpForum from commanderbot.lib.cogs import CogStore -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter # @implements HelpForumStore diff --git a/commanderbot/ext/help_forum/help_forum_options.py b/commanderbot/ext/help_forum/help_forum_options.py index 9f90f46..cc3a341 100644 --- a/commanderbot/ext/help_forum/help_forum_options.py +++ b/commanderbot/ext/help_forum/help_forum_options.py @@ -2,7 +2,7 @@ from typing import Any, Optional, Self from commanderbot.lib import FromDataMixin -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( DatabaseOptions, InMemoryDatabaseOptions, make_database_options, diff --git a/commanderbot/ext/invite/invite_cog.py b/commanderbot/ext/invite/invite_cog.py index d8845b8..9698c6e 100644 --- a/commanderbot/ext/invite/invite_cog.py +++ b/commanderbot/ext/invite/invite_cog.py @@ -17,7 +17,7 @@ from commanderbot.ext.invite.invite_store import InviteEntry, InviteStore from commanderbot.lib import constants, is_guild, utils from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, diff --git a/commanderbot/ext/invite/invite_json_store.py b/commanderbot/ext/invite/invite_json_store.py index 5d92327..028762c 100644 --- a/commanderbot/ext/invite/invite_json_store.py +++ b/commanderbot/ext/invite/invite_json_store.py @@ -7,7 +7,7 @@ from commanderbot.ext.invite.invite_store import InviteEntry from commanderbot.lib import UserID from commanderbot.lib.cogs import CogStore -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter # @implements InviteStore diff --git a/commanderbot/ext/invite/invite_options.py b/commanderbot/ext/invite/invite_options.py index 86e2e54..4471352 100644 --- a/commanderbot/ext/invite/invite_options.py +++ b/commanderbot/ext/invite/invite_options.py @@ -2,7 +2,7 @@ from typing import Any, Optional, Self from commanderbot.lib import FromDataMixin -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( DatabaseOptions, InMemoryDatabaseOptions, make_database_options, diff --git a/commanderbot/ext/roles/roles_cog.py b/commanderbot/ext/roles/roles_cog.py index b8b7c33..1d76102 100644 --- a/commanderbot/ext/roles/roles_cog.py +++ b/commanderbot/ext/roles/roles_cog.py @@ -12,7 +12,7 @@ from commanderbot.ext.roles.roles_store import RolesStore from commanderbot.lib import is_guild, is_member from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, diff --git a/commanderbot/ext/roles/roles_json_store.py b/commanderbot/ext/roles/roles_json_store.py index 90d3f48..340f0bd 100644 --- a/commanderbot/ext/roles/roles_json_store.py +++ b/commanderbot/ext/roles/roles_json_store.py @@ -7,7 +7,7 @@ from commanderbot.ext.roles.roles_store import RoleEntry from commanderbot.lib import GuildID, RoleID, RoleSet from commanderbot.lib.cogs import CogStore -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter # @implements RolesStore diff --git a/commanderbot/ext/roles/roles_options.py b/commanderbot/ext/roles/roles_options.py index 0d2bd02..a167042 100644 --- a/commanderbot/ext/roles/roles_options.py +++ b/commanderbot/ext/roles/roles_options.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from typing import Any -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( DatabaseOptions, InMemoryDatabaseOptions, make_database_options, diff --git a/commanderbot/ext/stacktracer/stacktracer_cog.py b/commanderbot/ext/stacktracer/stacktracer_cog.py index 8fdf2f8..2fa246e 100644 --- a/commanderbot/ext/stacktracer/stacktracer_cog.py +++ b/commanderbot/ext/stacktracer/stacktracer_cog.py @@ -28,7 +28,7 @@ from commanderbot.lib import Color, EventData, is_guild from commanderbot.lib.app_commands import ColorTransformer, EmojiTransformer, checks from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, diff --git a/commanderbot/ext/stacktracer/stacktracer_json_store.py b/commanderbot/ext/stacktracer/stacktracer_json_store.py index 42f4883..c868b5d 100644 --- a/commanderbot/ext/stacktracer/stacktracer_json_store.py +++ b/commanderbot/ext/stacktracer/stacktracer_json_store.py @@ -6,7 +6,7 @@ from commanderbot.ext.stacktracer.stacktracer_data import StacktracerData from commanderbot.lib import LogOptions from commanderbot.lib.cogs import CogStore -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter # @implements StacktracerStore diff --git a/commanderbot/ext/stacktracer/stacktracer_options.py b/commanderbot/ext/stacktracer/stacktracer_options.py index cc137db..864e201 100644 --- a/commanderbot/ext/stacktracer/stacktracer_options.py +++ b/commanderbot/ext/stacktracer/stacktracer_options.py @@ -2,7 +2,7 @@ from typing import Any, Optional, Self from commanderbot.lib import FromDataMixin -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( DatabaseOptions, InMemoryDatabaseOptions, make_database_options, diff --git a/commanderbot/ext/sudo/sudo_cog.py b/commanderbot/ext/sudo/sudo_cog.py index e1ebefa..fd8a7c3 100644 --- a/commanderbot/ext/sudo/sudo_cog.py +++ b/commanderbot/ext/sudo/sudo_cog.py @@ -63,7 +63,7 @@ utils, ) from commanderbot.lib.app_commands import checks -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter class SyncTypeChoices(Enum): diff --git a/commanderbot/ext/sudo/sudo_data.py b/commanderbot/ext/sudo/sudo_data.py index eb5821c..a44555b 100644 --- a/commanderbot/ext/sudo/sudo_data.py +++ b/commanderbot/ext/sudo/sudo_data.py @@ -1,11 +1,11 @@ from typing import Protocol, runtime_checkable -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter, SQLDatabaseAdapter +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter @runtime_checkable class DatabaseAdapter(Protocol): - db: JsonFileDatabaseAdapter | SQLDatabaseAdapter + db: JsonFileDatabaseAdapter @runtime_checkable From 390ea931637411361aa2226a562248af5fd24102 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 12 Jun 2026 02:02:12 -0700 Subject: [PATCH 012/158] Updated dependencies --- pyproject.toml | 8 ++------ uv.lock | 45 --------------------------------------------- 2 files changed, 2 insertions(+), 51 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee61b32..b9011eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,21 +21,17 @@ dependencies = [ "discord-py>=2.6.0", "python-dotenv>=1.0.0", "pydantic>=2.13.0", - + "aiohttp>=3.13.0", + # These are used for logging. "colorama>=0.4.0", "colorlog>=6.10.0", - # These are used for managing SQLite databases. - "aiosqlite>=0.21.0", - "sqlalchemy>=1.4.0,<2.0.0", - # These are used for handling text-based data (json, yaml, etc). "jsonpath-ng>=1.7.0", "pyyaml>=6.0.0", # The rest are for various extensions. - "aiohttp>=3.13.0", "emoji>=2.0.0", "allay>=1.3.0", "mccq>=1.0.0", diff --git a/uv.lock b/uv.lock index dcd2943..15c4d6f 100644 --- a/uv.lock +++ b/uv.lock @@ -74,15 +74,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] -[[package]] -name = "aiosqlite" -version = "0.22.1" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, -] - [[package]] name = "allay" version = "1.3.0" @@ -214,7 +205,6 @@ version = "0.23.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, - { name = "aiosqlite" }, { name = "allay" }, { name = "colorama" }, { name = "colorlog" }, @@ -226,7 +216,6 @@ dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "pyyaml" }, - { name = "sqlalchemy" }, ] [package.dev-dependencies] @@ -238,7 +227,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.13.0" }, - { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "allay", specifier = ">=1.3.0" }, { name = "colorama", specifier = ">=0.4.0" }, { name = "colorlog", specifier = ">=6.10.0" }, @@ -250,7 +238,6 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.13.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "sqlalchemy", specifier = ">=1.4.0,<2.0.0" }, ] [package.metadata.requires-dev] @@ -322,29 +309,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] -[[package]] -name = "greenlet" -version = "3.4.0" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, - { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, - { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, - { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, - { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, - { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, - { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, - { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, - { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, - { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, - { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, -] - [[package]] name = "idna" version = "3.13" @@ -633,15 +597,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "sqlalchemy" -version = "1.4.54" -source = { registry = "https://pypi.org/simple/" } -dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/af/20290b55d469e873cba9d41c0206ab5461ff49d759989b3fe65010f9d265/sqlalchemy-1.4.54.tar.gz", hash = "sha256:4470fbed088c35dc20b78a39aaf4ae54fe81790c783b3264872a0224f437c31a", size = 8470350, upload-time = "2024-09-05T15:54:10.398Z" } - [[package]] name = "tokenstream" version = "1.7.0" From 5e03d01894540cbf6ea9d735ba9be417d7bfe6db Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 12 Jun 2026 02:18:43 -0700 Subject: [PATCH 013/158] `/sudo export database` now works with `JsonDB` --- commanderbot/ext/sudo/sudo_cog.py | 20 +++++++++++++++----- commanderbot/ext/sudo/sudo_data.py | 7 ++++--- commanderbot/ext/sudo/sudo_exceptions.py | 5 ----- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/commanderbot/ext/sudo/sudo_cog.py b/commanderbot/ext/sudo/sudo_cog.py index fd8a7c3..cc1ed70 100644 --- a/commanderbot/ext/sudo/sudo_cog.py +++ b/commanderbot/ext/sudo/sudo_cog.py @@ -26,6 +26,7 @@ ) from discord.ext.commands import Bot, Cog from discord.utils import format_dt +from pydantic import BaseModel from commanderbot.core.commander_bot import CommanderBot from commanderbot.core.config import Config, ConfiguredExtension @@ -53,7 +54,6 @@ GuildIDNotFound, GuildSyncError, UnknownCog, - UnsupportedStoreExport, ) from commanderbot.lib import ( ConfirmationResult, @@ -63,6 +63,7 @@ utils, ) from commanderbot.lib.app_commands import checks +from commanderbot.lib.databases.json_db import JsonDB from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter @@ -430,17 +431,26 @@ async def cmd_sudo_export_database(self, interaction: Interaction, cog: str): # Export the store and respond with a followup match found_cog.store.db: + case JsonDB() as db: + cache: BaseModel = await db.get_cache() + data = cache.model_dump_json() + + file = utils.str_to_file(data, f"{found_cog.qualified_name}.json") + await interaction.followup.send( + f"📦 Exported JsonDB for `{found_cog.qualified_name}`:", + file=file, + ephemeral=True, + ) case JsonFileDatabaseAdapter() as db: cache = await db.get_cache() - json_data = json_dumps(db.serializer(cache)) - file = utils.str_to_file(json_data, f"{found_cog.qualified_name}.json") + data = json_dumps(db.serializer(cache)) + + file = utils.str_to_file(data, f"{found_cog.qualified_name}.json") await interaction.followup.send( f"📦 Exported Json store for `{found_cog.qualified_name}`:", file=file, ephemeral=True, ) - case _ as db: - raise UnsupportedStoreExport(db) # @@ sudo avatar diff --git a/commanderbot/ext/sudo/sudo_data.py b/commanderbot/ext/sudo/sudo_data.py index a44555b..612d072 100644 --- a/commanderbot/ext/sudo/sudo_data.py +++ b/commanderbot/ext/sudo/sudo_data.py @@ -1,13 +1,14 @@ from typing import Protocol, runtime_checkable +from commanderbot.lib.databases.json_db import JsonDB from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter @runtime_checkable -class DatabaseAdapter(Protocol): - db: JsonFileDatabaseAdapter +class CogStore(Protocol): + db: JsonDB | JsonFileDatabaseAdapter @runtime_checkable class CogWithStore(Protocol): - store: DatabaseAdapter + store: CogStore diff --git a/commanderbot/ext/sudo/sudo_exceptions.py b/commanderbot/ext/sudo/sudo_exceptions.py index d4e5b20..abe640d 100644 --- a/commanderbot/ext/sudo/sudo_exceptions.py +++ b/commanderbot/ext/sudo/sudo_exceptions.py @@ -109,11 +109,6 @@ def __init__(self, cog_name: str): super().__init__(f"😬 The cog `{self.cog_name}` does not use a store") -class UnsupportedStoreExport(SudoException): - def __init__(self, store: Any): - super().__init__(f"😬 Unsupported store export: `{type(store)}`") - - class BotHasNoAvatar(SudoException): def __init__(self): super().__init__("😵 The bot doesn't have an avatar set") From beb873d4a2084e75eadc3d09e0e347ff841445e6 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 12 Jun 2026 23:58:54 -0700 Subject: [PATCH 014/158] Updated the help forums to use JsonDB --- commanderbot/ext/help_forum/help_forum_cog.py | 31 +--- .../ext/help_forum/help_forum_data.py | 146 +++--------------- .../ext/help_forum/help_forum_json_store.py | 104 ------------- .../ext/help_forum/help_forum_options.py | 22 +-- .../ext/help_forum/help_forum_store.py | 100 ++++++------ 5 files changed, 90 insertions(+), 313 deletions(-) delete mode 100644 commanderbot/ext/help_forum/help_forum_json_store.py diff --git a/commanderbot/ext/help_forum/help_forum_cog.py b/commanderbot/ext/help_forum/help_forum_cog.py index 0b03452..e566f92 100644 --- a/commanderbot/ext/help_forum/help_forum_cog.py +++ b/commanderbot/ext/help_forum/help_forum_cog.py @@ -26,43 +26,22 @@ UnableToResolvePinned, ) from commanderbot.ext.help_forum.help_forum_guild_state import HelpForumGuildState -from commanderbot.ext.help_forum.help_forum_json_store import HelpForumJsonStore from commanderbot.ext.help_forum.help_forum_options import HelpForumOptions from commanderbot.ext.help_forum.help_forum_state import HelpForumState from commanderbot.ext.help_forum.help_forum_store import HelpForumStore from commanderbot.lib import is_bot, is_forum_channel, is_guild, is_thread from commanderbot.lib.app_commands import EmojiTransformer from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.databases.json_db.v1 import ( - InMemoryDatabaseOptions, - JsonFileDatabaseAdapter, - JsonFileDatabaseOptions, - UnsupportedDatabaseOptions, -) - - -def _make_store(bot: Bot, cog: Cog, options: HelpForumOptions) -> HelpForumStore: - db_options = options.database - if isinstance(db_options, InMemoryDatabaseOptions): - return HelpForumData() - if isinstance(db_options, JsonFileDatabaseOptions): - return HelpForumJsonStore( - bot=bot, - cog=cog, - db=JsonFileDatabaseAdapter( - options=db_options, - serializer=lambda cache: cache.to_json(), - deserializer=HelpForumData.from_data, - ), - ) - raise UnsupportedDatabaseOptions(db_options) +from commanderbot.lib.databases.json_db import JsonDB class HelpForumCog(Cog, name="commanderbot.ext.help_forum"): def __init__(self, bot: Bot, **options): self.bot: Bot = bot - self.options = HelpForumOptions.from_data(options) - self.store: HelpForumStore = _make_store(self.bot, self, self.options) + self.options = HelpForumOptions.model_validate(options) + self.store = HelpForumStore( + self.bot, self, JsonDB(self.options.database, HelpForumData) + ) self.state = HelpForumState( bot=self.bot, cog=self, diff --git a/commanderbot/ext/help_forum/help_forum_data.py b/commanderbot/ext/help_forum/help_forum_data.py index 58e133c..40abc0a 100644 --- a/commanderbot/ext/help_forum/help_forum_data.py +++ b/commanderbot/ext/help_forum/help_forum_data.py @@ -1,28 +1,19 @@ import math from collections import defaultdict -from dataclasses import dataclass, field -from typing import Any, Optional, Self +from typing import Annotated, Optional from discord import ForumChannel, ForumTag, Guild, PartialEmoji +from pydantic import BaseModel, Field from commanderbot.ext.help_forum.help_forum_exceptions import ( ForumChannelAlreadyRegistered, ForumChannelNotRegistered, HelpForumInvalidTag, ) -from commanderbot.ext.help_forum.help_forum_store import HelpForum -from commanderbot.lib import ( - ChannelID, - ForumTagID, - FromDataMixin, - GuildID, - JsonSerializable, - utils, -) +from commanderbot.lib import ChannelID, ForumTagID, GuildID, utils -@dataclass -class HelpForumForumData(JsonSerializable, FromDataMixin): +class HelpForum(BaseModel): channel_id: ChannelID unresolved_emoji: str resolved_emoji: str @@ -31,32 +22,6 @@ class HelpForumForumData(JsonSerializable, FromDataMixin): threads_created: int resolutions: int - # @implements FromDataMixin - @classmethod - def try_from_data(cls, data: Any) -> Optional[Self]: - if isinstance(data, dict): - return cls( - channel_id=data["channel_id"], - unresolved_emoji=data["unresolved_emoji"], - resolved_emoji=data["resolved_emoji"], - unresolved_tag_id=data["unresolved_tag_id"], - resolved_tag_id=data["resolved_tag_id"], - threads_created=data["threads_created"], - resolutions=data["resolutions"], - ) - - # @implements JsonSerializable - def to_json(self) -> Any: - return { - "channel_id": self.channel_id, - "unresolved_emoji": self.unresolved_emoji, - "resolved_emoji": self.resolved_emoji, - "unresolved_tag_id": self.unresolved_tag_id, - "resolved_tag_id": self.resolved_tag_id, - "threads_created": self.threads_created, - "resolutions": self.resolutions, - } - @property def partial_unresolved_emoji(self) -> PartialEmoji: return PartialEmoji.from_str(self.unresolved_emoji) @@ -78,32 +43,10 @@ def ratio(self) -> tuple[int, int]: return (self.threads_created // gcd, self.resolutions // gcd) -@dataclass -class HelpForumGuildData(JsonSerializable, FromDataMixin): - help_forums: dict[ChannelID, HelpForumForumData] = field(default_factory=dict) - - # @implements FromDataMixin - @classmethod - def try_from_data(cls, data: Any) -> Optional[Self]: - if isinstance(data, dict): - help_forums = {} - for raw_channel_id, raw_forum_data in data.get("help_forums", {}).items(): - channel_id = int(raw_channel_id) - help_forums[channel_id] = HelpForumForumData.from_data(raw_forum_data) - - return cls(help_forums=help_forums) - - # @implements JsonSerializable - def to_json(self) -> Any: - # Omit empty help forums - return utils.dict_without_falsies( - help_forums=utils.dict_without_falsies( - { - str(channel_id): forum_data.to_json() - for channel_id, forum_data in self.help_forums.items() - } - ) - ) +class HelpForumGuildData(BaseModel): + help_forums: dict[ChannelID, HelpForum] = Field( + default_factory=dict, exclude_if=lambda v: not v + ) def _is_forum_registered(self, forum: ForumChannel): return forum.id in self.help_forums.keys() @@ -116,14 +59,14 @@ def _require_tag(self, forum: ForumChannel, tag_str: str) -> ForumTag: except: raise HelpForumInvalidTag(forum.id, tag_str) - def require_help_forum(self, forum: ForumChannel) -> HelpForumForumData: + def require_help_forum(self, forum: ForumChannel) -> HelpForum: # Returns the help forum data if it exists if forum_data := self.help_forums.get(forum.id): return forum_data # Otherwise, raise raise ForumChannelNotRegistered(forum.id) - def get_help_forum(self, forum: ForumChannel) -> Optional[HelpForumForumData]: + def get_help_forum(self, forum: ForumChannel) -> Optional[HelpForum]: return self.help_forums.get(forum.id) def register_forum_channel( @@ -133,7 +76,7 @@ def register_forum_channel( resolved_emoji: str, unresolved_tag: str, resolved_tag: str, - ) -> HelpForumForumData: + ) -> HelpForum: # Check if the forum channel was already registered if self._is_forum_registered(forum): raise ForumChannelAlreadyRegistered(forum.id) @@ -143,7 +86,7 @@ def register_forum_channel( valid_resolved_tag: ForumTag = self._require_tag(forum, resolved_tag) # Create and add a new help forum - forum_data = HelpForumForumData( + forum_data = HelpForum( channel_id=forum.id, unresolved_emoji=unresolved_emoji, resolved_emoji=resolved_emoji, @@ -158,7 +101,7 @@ def register_forum_channel( # Return the newly created help forum return forum_data - def deregister_forum_channel(self, forum: ForumChannel) -> HelpForumForumData: + def deregister_forum_channel(self, forum: ForumChannel) -> HelpForum: # The help forum channel should exist forum_data = self.require_help_forum(forum) # Remove it @@ -166,17 +109,13 @@ def deregister_forum_channel(self, forum: ForumChannel) -> HelpForumForumData: # Return it return forum_data - def modify_unresolved_emoji( - self, forum: ForumChannel, emoji: str - ) -> HelpForumForumData: + def modify_unresolved_emoji(self, forum: ForumChannel, emoji: str) -> HelpForum: # Modify unresolved emoji for a help forum forum_data = self.require_help_forum(forum) forum_data.unresolved_emoji = emoji return forum_data - def modify_resolved_emoji( - self, forum: ForumChannel, emoji: str - ) -> HelpForumForumData: + def modify_resolved_emoji(self, forum: ForumChannel, emoji: str) -> HelpForum: # Modify resolved emoji for a help forum forum_data = self.require_help_forum(forum) forum_data.resolved_emoji = emoji @@ -184,7 +123,7 @@ def modify_resolved_emoji( def modify_unresolved_tag( self, forum: ForumChannel, tag: str - ) -> tuple[HelpForumForumData, ForumTag]: + ) -> tuple[HelpForum, ForumTag]: # Modify unresolved tag ID for a help forum forum_data = self.require_help_forum(forum) valid_tag = self._require_tag(forum, tag) @@ -193,7 +132,7 @@ def modify_unresolved_tag( def modify_resolved_tag( self, forum: ForumChannel, tag: str - ) -> tuple[HelpForumForumData, ForumTag]: + ) -> tuple[HelpForum, ForumTag]: # Modify resolved tag ID for a help forum forum_data = self.require_help_forum(forum) valid_tag = self._require_tag(forum, tag) @@ -201,52 +140,23 @@ def modify_resolved_tag( return (forum_data, valid_tag) -def _guilds_defaultdict_factory() -> defaultdict[GuildID, HelpForumGuildData]: - return defaultdict(lambda: HelpForumGuildData()) - - -# @implements HelpForumStore -@dataclass -class HelpForumData(JsonSerializable, FromDataMixin): - guilds: defaultdict[GuildID, HelpForumGuildData] = field( - default_factory=_guilds_defaultdict_factory +class HelpForumData(BaseModel): + guilds: defaultdict[ + GuildID, + Annotated[HelpForumGuildData, Field(default_factory=HelpForumGuildData)], + ] = Field( + default_factory=lambda: defaultdict(HelpForumGuildData), + exclude_if=lambda v: not v, ) - # @implements FromDataMixin - @classmethod - def try_from_data(cls, data: Any) -> Optional[Self]: - if isinstance(data, dict): - # Construct guild data - guilds = _guilds_defaultdict_factory() - for raw_guild_id, raw_guild_data in data.get("guilds", {}).items(): - guild_id = int(raw_guild_id) - guilds[guild_id] = HelpForumGuildData.from_data(raw_guild_data) - - return cls(guilds=guilds) - - # @implements JsonSerializable - def to_json(self) -> Any: - # Omit empty guilds, as well as an empty list of guilds - return utils.dict_without_falsies( - guilds=utils.dict_without_falsies( - { - str(guild_id): guild_data.to_json() - for guild_id, guild_data in self.guilds.items() - } - ) - ) - - # @implements HelpForumStore async def require_help_forum(self, guild: Guild, forum: ForumChannel) -> HelpForum: return self.guilds[guild.id].require_help_forum(forum) - # @implements HelpForumStore async def get_help_forum( self, guild: Guild, forum: ForumChannel ) -> Optional[HelpForum]: return self.guilds[guild.id].get_help_forum(forum) - # @implements HelpForumStore async def register_forum_channel( self, guild: Guild, @@ -260,40 +170,32 @@ async def register_forum_channel( forum, unresolved_emoji, resolved_emoji, unresolved_tag, resolved_tag ) - # @implements HelpForumStore async def deregister_forum_channel( self, guild: Guild, forum: ForumChannel ) -> HelpForum: return self.guilds[guild.id].deregister_forum_channel(forum) - # @implements HelpForumStore async def increment_threads_created(self, help_forum: HelpForum): help_forum.threads_created += 1 - # @implements HelpForumStore async def increment_resolutions(self, help_forum: HelpForum): help_forum.resolutions += 1 - # @implements HelpForumStore async def modify_unresolved_emoji( self, guild: Guild, forum: ForumChannel, emoji: str ) -> HelpForum: return self.guilds[guild.id].modify_unresolved_emoji(forum, emoji) - - # @implements HelpForumStore async def modify_resolved_emoji( self, guild: Guild, forum: ForumChannel, emoji: str ) -> HelpForum: return self.guilds[guild.id].modify_resolved_emoji(forum, emoji) - # @implements HelpForumStore async def modify_unresolved_tag( self, guild: Guild, forum: ForumChannel, tag: str ) -> tuple[HelpForum, ForumTag]: return self.guilds[guild.id].modify_unresolved_tag(forum, tag) - # @implements HelpForumStore async def modify_resolved_tag( self, guild: Guild, forum: ForumChannel, tag: str ) -> tuple[HelpForum, ForumTag]: diff --git a/commanderbot/ext/help_forum/help_forum_json_store.py b/commanderbot/ext/help_forum/help_forum_json_store.py deleted file mode 100644 index ead5ecc..0000000 --- a/commanderbot/ext/help_forum/help_forum_json_store.py +++ /dev/null @@ -1,104 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from discord import ForumChannel, ForumTag, Guild - -from commanderbot.ext.help_forum.help_forum_data import HelpForumData -from commanderbot.ext.help_forum.help_forum_store import HelpForum -from commanderbot.lib.cogs import CogStore -from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter - - -# @implements HelpForumStore -@dataclass -class HelpForumJsonStore(CogStore): - """ - Implementation of `HelpForumStore` that uses a simple JSON file to persist state. - """ - - db: JsonFileDatabaseAdapter[HelpForumData] - - # @implements HelpForumStore - async def require_help_forum(self, guild: Guild, forum: ForumChannel) -> HelpForum: - cache = await self.db.get_cache() - return await cache.require_help_forum(guild, forum) - - async def get_help_forum( - self, guild: Guild, forum: ForumChannel - ) -> Optional[HelpForum]: - cache = await self.db.get_cache() - return await cache.get_help_forum(guild, forum) - - # @implements HelpForumStore - async def register_forum_channel( - self, - guild: Guild, - forum: ForumChannel, - unresolved_emoji: str, - resolved_emoji: str, - unresolved_tag: str, - resolved_tag: str, - ) -> HelpForum: - cache = await self.db.get_cache() - help_forum = await cache.register_forum_channel( - guild, forum, unresolved_emoji, resolved_emoji, unresolved_tag, resolved_tag - ) - await self.db.dirty() - return help_forum - - # @implements HelpForumStore - async def deregister_forum_channel( - self, guild: Guild, forum: ForumChannel - ) -> HelpForum: - cache = await self.db.get_cache() - help_forum = await cache.deregister_forum_channel(guild, forum) - await self.db.dirty() - return help_forum - - # @implements HelpForumStore - async def increment_threads_created(self, help_forum: HelpForum): - cache = await self.db.get_cache() - await cache.increment_threads_created(help_forum) - await self.db.dirty() - - # @implements HelpForumStore - async def increment_resolutions(self, help_forum: HelpForum): - cache = await self.db.get_cache() - await cache.increment_resolutions(help_forum) - await self.db.dirty() - - # @implements HelpForumStore - async def modify_unresolved_emoji( - self, guild: Guild, forum: ForumChannel, emoji: str - ) -> HelpForum: - cache = await self.db.get_cache() - help_forum = await cache.modify_unresolved_emoji(guild, forum, emoji) - await self.db.dirty() - return help_forum - - # @implements HelpForumStore - async def modify_resolved_emoji( - self, guild: Guild, forum: ForumChannel, emoji: str - ) -> HelpForum: - cache = await self.db.get_cache() - help_forum = await cache.modify_resolved_emoji(guild, forum, emoji) - await self.db.dirty() - return help_forum - - # @implements HelpForumStore - async def modify_unresolved_tag( - self, guild: Guild, forum: ForumChannel, tag: str - ) -> tuple[HelpForum, ForumTag]: - cache = await self.db.get_cache() - result = await cache.modify_unresolved_tag(guild, forum, tag) - await self.db.dirty() - return result - - # @implements HelpForumStore - async def modify_resolved_tag( - self, guild: Guild, forum: ForumChannel, tag: str - ) -> tuple[HelpForum, ForumTag]: - cache = await self.db.get_cache() - result = await cache.modify_resolved_tag(guild, forum, tag) - await self.db.dirty() - return result diff --git a/commanderbot/ext/help_forum/help_forum_options.py b/commanderbot/ext/help_forum/help_forum_options.py index cc3a341..b4ff313 100644 --- a/commanderbot/ext/help_forum/help_forum_options.py +++ b/commanderbot/ext/help_forum/help_forum_options.py @@ -1,21 +1,7 @@ -from dataclasses import dataclass, field -from typing import Any, Optional, Self +from pydantic import BaseModel -from commanderbot.lib import FromDataMixin -from commanderbot.lib.databases.json_db.v1 import ( - DatabaseOptions, - InMemoryDatabaseOptions, - make_database_options, -) +from commanderbot.lib.databases.json_db import JsonDBOptions -@dataclass -class HelpForumOptions(FromDataMixin): - database: DatabaseOptions = field(default_factory=InMemoryDatabaseOptions) - - # @implements FromDataMixin - @classmethod - def try_from_data(cls, data: Any) -> Optional[Self]: - if isinstance(data, dict): - database_options = make_database_options(data.get("database")) - return cls(database=database_options) +class HelpForumOptions(BaseModel): + database: JsonDBOptions diff --git a/commanderbot/ext/help_forum/help_forum_store.py b/commanderbot/ext/help_forum/help_forum_store.py index bb09fb9..04850ea 100644 --- a/commanderbot/ext/help_forum/help_forum_store.py +++ b/commanderbot/ext/help_forum/help_forum_store.py @@ -1,44 +1,26 @@ -from typing import Optional, Protocol +from dataclasses import dataclass +from typing import Optional -from discord import ForumChannel, ForumTag, Guild, PartialEmoji +from discord import ForumChannel, ForumTag, Guild -from commanderbot.lib import ChannelID, ForumTagID +from commanderbot.ext.help_forum.help_forum_data import HelpForum, HelpForumData +from commanderbot.lib.cogs import CogStore +from commanderbot.lib.databases.json_db import JsonDB -class HelpForum(Protocol): - channel_id: ChannelID - unresolved_emoji: str - resolved_emoji: str - unresolved_tag_id: ForumTagID - resolved_tag_id: ForumTagID - threads_created: int - resolutions: int +@dataclass +class HelpForumStore(CogStore): + db: JsonDB[HelpForumData] - @property - def partial_unresolved_emoji(self) -> PartialEmoji: ... - - @property - def partial_resolved_emoji(self) -> PartialEmoji: ... - - @property - def thread_state_tags(self) -> tuple[ForumTagID, ForumTagID]: ... - - @property - def ratio(self) -> tuple[int, int]: ... - - -class HelpForumStore(Protocol): - """ - Abstracts the data storage and persistence of the help forum cog - """ - - async def require_help_forum( - self, guild: Guild, forum: ForumChannel - ) -> HelpForum: ... + async def require_help_forum(self, guild: Guild, forum: ForumChannel) -> HelpForum: + cache = await self.db.get_cache() + return await cache.require_help_forum(guild, forum) async def get_help_forum( self, guild: Guild, forum: ForumChannel - ) -> Optional[HelpForum]: ... + ) -> Optional[HelpForum]: + cache = await self.db.get_cache() + return await cache.get_help_forum(guild, forum) async def register_forum_channel( self, @@ -48,28 +30,60 @@ async def register_forum_channel( resolved_emoji: str, unresolved_tag: str, resolved_tag: str, - ) -> HelpForum: ... + ) -> HelpForum: + cache = await self.db.get_cache() + help_forum = await cache.register_forum_channel( + guild, forum, unresolved_emoji, resolved_emoji, unresolved_tag, resolved_tag + ) + await self.db.commit() + return help_forum async def deregister_forum_channel( self, guild: Guild, forum: ForumChannel - ) -> HelpForum: ... - - async def increment_threads_created(self, help_forum: HelpForum): ... - - async def increment_resolutions(self, help_forum: HelpForum): ... + ) -> HelpForum: + cache = await self.db.get_cache() + help_forum = await cache.deregister_forum_channel(guild, forum) + await self.db.commit() + return help_forum + + async def increment_threads_created(self, help_forum: HelpForum): + cache = await self.db.get_cache() + await cache.increment_threads_created(help_forum) + await self.db.commit() + + async def increment_resolutions(self, help_forum: HelpForum): + cache = await self.db.get_cache() + await cache.increment_resolutions(help_forum) + await self.db.commit() async def modify_unresolved_emoji( self, guild: Guild, forum: ForumChannel, emoji: str - ) -> HelpForum: ... + ) -> HelpForum: + cache = await self.db.get_cache() + help_forum = await cache.modify_unresolved_emoji(guild, forum, emoji) + await self.db.commit() + return help_forum async def modify_resolved_emoji( self, guild: Guild, forum: ForumChannel, emoji: str - ) -> HelpForum: ... + ) -> HelpForum: + cache = await self.db.get_cache() + help_forum = await cache.modify_resolved_emoji(guild, forum, emoji) + await self.db.commit() + return help_forum async def modify_unresolved_tag( self, guild: Guild, forum: ForumChannel, tag: str - ) -> tuple[HelpForum, ForumTag]: ... + ) -> tuple[HelpForum, ForumTag]: + cache = await self.db.get_cache() + result = await cache.modify_unresolved_tag(guild, forum, tag) + await self.db.commit() + return result async def modify_resolved_tag( self, guild: Guild, forum: ForumChannel, tag: str - ) -> tuple[HelpForum, ForumTag]: ... + ) -> tuple[HelpForum, ForumTag]: + cache = await self.db.get_cache() + result = await cache.modify_resolved_tag(guild, forum, tag) + await self.db.commit() + return result From 300c403ea815f80d99a40ee1dd285a5d1886ef40 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sun, 14 Jun 2026 01:31:40 -0700 Subject: [PATCH 015/158] Updated predicates and types --- commanderbot/ext/faq/faq_cog.py | 10 ++++++-- commanderbot/ext/feeds/feeds_cog.py | 10 ++++---- commanderbot/ext/feeds/feeds_guild_state.py | 10 ++++---- commanderbot/ext/friday/friday_cog.py | 18 ++++++++++---- commanderbot/ext/friday/friday_guild_state.py | 8 ++++--- .../ext/poster_board/poster_board_cog.py | 14 +++++------ commanderbot/ext/quote/quote_cog.py | 13 ++++++---- .../ext/stacktracer/stacktracer_cog.py | 1 - commanderbot/lib/predicates.py | 24 ++++++++++++++----- commanderbot/lib/types.py | 8 ++++--- commanderbot/lib/utils/utils.py | 5 ++-- 11 files changed, 79 insertions(+), 42 deletions(-) diff --git a/commanderbot/ext/faq/faq_cog.py b/commanderbot/ext/faq/faq_cog.py index 7a4dbdb..93ef6c4 100644 --- a/commanderbot/ext/faq/faq_cog.py +++ b/commanderbot/ext/faq/faq_cog.py @@ -20,6 +20,7 @@ is_bot, is_guild, is_messagable_guild_channel, + is_thread, utils, ) from commanderbot.lib.cogs import CogGuildStateManager @@ -71,7 +72,7 @@ def __init__(self, bot: Bot, **options): ) # @@ AUTOCOMPLETE - + async def faq_autocomplete( self, interaction: Interaction, value: str ) -> list[Choice[str]]: @@ -159,7 +160,12 @@ async def on_message(self, message: Message): return # Make sure the message was sent in a messageable channel in a guild - if not (message.guild and is_messagable_guild_channel(message.channel)): + if not message.guild: + return + + if not ( + is_messagable_guild_channel(message.channel) or is_thread(message.channel) + ): return await self.state[message.guild].on_message(message) diff --git a/commanderbot/ext/feeds/feeds_cog.py b/commanderbot/ext/feeds/feeds_cog.py index 7e92fa2..1c3a8ba 100644 --- a/commanderbot/ext/feeds/feeds_cog.py +++ b/commanderbot/ext/feeds/feeds_cog.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Optional -from discord import Interaction, Permissions, Role +from discord import Interaction, Permissions, Role, Thread from discord.app_commands import AppCommandContext, AppInstallationType, Group, describe from discord.ext.commands import Bot, Cog @@ -122,7 +122,7 @@ async def cog_unload(self): async def cmd_feed_subscribe( self, interaction: Interaction, - channel: MessageableGuildChannel, + channel: MessageableGuildChannel | Thread, feed: FeedChoices, notification_role: Optional[Role], auto_pin: Optional[bool], @@ -143,7 +143,7 @@ async def cmd_feed_subscribe( async def cmd_feed_modify( self, interaction: Interaction, - channel: MessageableGuildChannel, + channel: MessageableGuildChannel | Thread, feed: FeedChoices, notification_role: Optional[Role], auto_pin: Optional[bool], @@ -164,7 +164,7 @@ async def cmd_feed_modify( async def cmd_feed_unsubscribe( self, interaction: Interaction, - channel: MessageableGuildChannel, + channel: MessageableGuildChannel | Thread, feed: FeedChoices, ): assert is_guild(interaction.guild) @@ -178,7 +178,7 @@ async def cmd_feed_unsubscribe( ) @describe(channel="The channel to show subscription details about") async def cmd_feed_details( - self, interaction: Interaction, channel: MessageableGuildChannel + self, interaction: Interaction, channel: MessageableGuildChannel | Thread ): assert is_guild(interaction.guild) await self.state[interaction.guild].show_subscription_details( diff --git a/commanderbot/ext/feeds/feeds_guild_state.py b/commanderbot/ext/feeds/feeds_guild_state.py index da4b192..ad3a38a 100644 --- a/commanderbot/ext/feeds/feeds_guild_state.py +++ b/commanderbot/ext/feeds/feeds_guild_state.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional -from discord import Embed, Interaction, Role +from discord import Embed, Interaction, Role, Thread from discord.utils import format_dt from commanderbot.ext.feeds.feeds_exceptions import ChannelHasNoSubscriptions @@ -36,7 +36,7 @@ class FeedsGuildState(CogGuildState): async def subscribe_to_feed( self, interaction: Interaction, - channel: MessageableGuildChannel, + channel: MessageableGuildChannel | Thread, feed: FeedType, notification_role: Optional[Role], auto_pin: Optional[bool], @@ -56,7 +56,7 @@ async def subscribe_to_feed( async def modify_subscription( self, interaction: Interaction, - channel: MessageableGuildChannel, + channel: MessageableGuildChannel | Thread, feed: FeedType, notification_role: Optional[Role], auto_pin: Optional[bool], @@ -72,7 +72,7 @@ async def modify_subscription( ) async def unsubscribe_from_feed( - self, interaction: Interaction, channel: MessageableGuildChannel, feed: FeedType + self, interaction: Interaction, channel: MessageableGuildChannel | Thread, feed: FeedType ): # Try to get the subscription subscription = await self.store.require_subscription(channel.id, feed) @@ -102,7 +102,7 @@ async def unsubscribe_from_feed( ) async def show_subscription_details( - self, interaction: Interaction, channel: MessageableGuildChannel + self, interaction: Interaction, channel: MessageableGuildChannel | Thread ): subscriptions = await utils.async_expand( self.store.get_subscriptions(channel.id) diff --git a/commanderbot/ext/friday/friday_cog.py b/commanderbot/ext/friday/friday_cog.py index 3ed9899..fbe1fe7 100644 --- a/commanderbot/ext/friday/friday_cog.py +++ b/commanderbot/ext/friday/friday_cog.py @@ -1,4 +1,4 @@ -from discord import ForumChannel, Interaction, Message, Permissions +from discord import ForumChannel, Interaction, Message, Permissions, Thread from discord.app_commands import ( AppCommandContext, AppInstallationType, @@ -21,6 +21,7 @@ is_bot, is_guild, is_messagable_guild_channel, + is_thread, ) from commanderbot.lib.cogs import CogGuildStateManager from commanderbot.lib.databases.json_db.v1 import ( @@ -95,7 +96,12 @@ async def on_message(self, message: Message): return # Make sure the message was sent in a messageable channel in a guild - if not (message.guild and is_messagable_guild_channel(message.channel)): + if not message.guild: + return + + if not ( + is_messagable_guild_channel(message.channel) or is_thread(message.channel) + ): return await self.state[message.guild].on_message(message) @@ -123,7 +129,9 @@ async def on_message(self, message: Message): ) @describe(channel="The channel to register") async def cmd_friday_channels_register( - self, interaction: Interaction, channel: MessageableGuildChannel | ForumChannel + self, + interaction: Interaction, + channel: MessageableGuildChannel | ForumChannel | Thread, ): assert is_guild(interaction.guild) await self.state[interaction.guild].register_channel(interaction, channel.id) @@ -135,7 +143,9 @@ async def cmd_friday_channels_register( ) @describe(channel="The channel to unregister") async def cmd_friday_channels_unregister( - self, interaction: Interaction, channel: MessageableGuildChannel | ForumChannel + self, + interaction: Interaction, + channel: MessageableGuildChannel | ForumChannel | Thread, ): assert is_guild(interaction.guild) await self.state[interaction.guild].unregister_channel(interaction, channel.id) diff --git a/commanderbot/ext/friday/friday_guild_state.py b/commanderbot/ext/friday/friday_guild_state.py index 1199ac2..7ef87bf 100644 --- a/commanderbot/ext/friday/friday_guild_state.py +++ b/commanderbot/ext/friday/friday_guild_state.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing import Optional -from discord import Embed, Interaction, Message, TextStyle +from discord import Embed, Interaction, Message, TextStyle, Thread from discord.ui import TextInput from discord.utils import format_dt @@ -39,14 +39,16 @@ class FridayGuildState(CogGuildState): store: FridayStore - async def _is_channel_registered(self, channel: MessageableGuildChannel): + async def _is_channel_registered(self, channel: MessageableGuildChannel | Thread): if is_thread(channel) and channel.parent: return await self.store.is_channel_registered(self.guild, channel.parent.id) return await self.store.is_channel_registered(self.guild, channel.id) async def on_message(self, message: Message): # Check if the channel the message was sent in was registered - assert is_messagable_guild_channel(message.channel) + assert is_messagable_guild_channel(message.channel) or is_thread( + message.channel + ) if not await self._is_channel_registered(message.channel): return diff --git a/commanderbot/ext/poster_board/poster_board_cog.py b/commanderbot/ext/poster_board/poster_board_cog.py index 01cb2df..57add04 100644 --- a/commanderbot/ext/poster_board/poster_board_cog.py +++ b/commanderbot/ext/poster_board/poster_board_cog.py @@ -1,11 +1,11 @@ from datetime import datetime, timedelta from typing import Optional -from discord import AllowedMentions, Colour, Embed, Message +from discord import AllowedMentions, Colour, Embed, Message, Thread from discord.ext.commands import Bot, Cog, Context, group from discord.ext.commands.converter import ColourConverter -from commanderbot.lib import MessageableChannel, is_messagable_channel +from commanderbot.lib import MessageableChannel, is_messagable_channel, is_thread from commanderbot.lib.commands import checks HEADING_PREFIX = "👉" @@ -17,7 +17,7 @@ def __init__(self, bot: Bot): async def send_embed( self, - destination: MessageableChannel, + destination: MessageableChannel | Thread, title: Optional[str] = None, content: Optional[str] = None, colour: Optional[Colour] = None, @@ -38,12 +38,12 @@ async def cmd_posterboard_embed( ctx: Context, colour: Optional[Colour] = None, title: Optional[str] = None, - destination: Optional[MessageableChannel] = None, + destination: Optional[MessageableChannel | Thread] = None, *, content: Optional[str] = None, ): actual_dest = destination if destination is not None else ctx.channel - assert is_messagable_channel(actual_dest) + assert is_messagable_channel(actual_dest) or is_thread(actual_dest) await self.send_embed( destination=actual_dest, title=title, content=content, colour=colour ) @@ -55,11 +55,11 @@ async def cmd_posterboard_copy( ctx: Context, from_message: Message, to_message: Optional[Message] = None, - destination: Optional[MessageableChannel] = None, + destination: Optional[MessageableChannel | Thread] = None, limit: Optional[int] = 100, ): actual_dest = destination if destination is not None else ctx.channel - assert is_messagable_channel(actual_dest) + assert is_messagable_channel(actual_dest) or is_thread(actual_dest) to_message = to_message if to_message is not None else from_message after_ts: datetime = from_message.created_at - timedelta(milliseconds=1) diff --git a/commanderbot/ext/quote/quote_cog.py b/commanderbot/ext/quote/quote_cog.py index b61ac2f..eb379de 100644 --- a/commanderbot/ext/quote/quote_cog.py +++ b/commanderbot/ext/quote/quote_cog.py @@ -1,7 +1,7 @@ import re from itertools import chain -from discord import Embed, Interaction, Member, Message, User +from discord import Embed, Interaction, Member, Message, Thread, User from discord.app_commands import ( Transform, allowed_contexts, @@ -22,6 +22,7 @@ MessageableGuildChannel, is_member, is_messagable_guild_channel, + is_thread, ) from commanderbot.lib.app_commands import MessageTransformer @@ -33,7 +34,7 @@ def __init__(self, bot: Bot): self.bot: Bot = bot def _user_can_quote( - self, user: MemberOrUser, channel: MessageableGuildChannel + self, user: MemberOrUser, channel: MessageableGuildChannel | Thread ) -> bool: # Return early if `user` is not a guild member. if not is_member(user): @@ -66,7 +67,9 @@ async def _do_quote( ): # Make sure the channel can be quoted from. message_channel = message.channel - if not is_messagable_guild_channel(message_channel): + if not ( + is_messagable_guild_channel(message_channel) or is_thread(message_channel) + ): raise ChannelNotMessageable # Make sure the quoter has read permissions in the channel. @@ -107,7 +110,9 @@ async def _do_quote( attachment_urls_gen = (att.url for att in message.attachments) embed_urls_gen = (embed.url for embed in message.embeds if embed.url) - assert is_messagable_guild_channel(interaction.channel) + assert is_messagable_guild_channel(interaction.channel) or is_thread( + interaction.channel + ) for url in chain(attachment_urls_gen, embed_urls_gen): await interaction.channel.send(url) diff --git a/commanderbot/ext/stacktracer/stacktracer_cog.py b/commanderbot/ext/stacktracer/stacktracer_cog.py index 2fa246e..b0a1c0b 100644 --- a/commanderbot/ext/stacktracer/stacktracer_cog.py +++ b/commanderbot/ext/stacktracer/stacktracer_cog.py @@ -80,7 +80,6 @@ class StacktracerCog( def __init__(self, bot: Bot, **options): self.bot: Bot = bot - self.bot = bot self.options = StacktracerOptions.from_data(options) self.store: StacktracerStore = _make_store(bot, self, self.options) self.state = StacktracerState( diff --git a/commanderbot/lib/predicates.py b/commanderbot/lib/predicates.py index 7cbd676..aaa5f61 100644 --- a/commanderbot/lib/predicates.py +++ b/commanderbot/lib/predicates.py @@ -21,8 +21,10 @@ from commanderbot.lib.types import ( ConnectableChannel, + GuildChannel, MessageableChannel, MessageableGuildChannel, + ThreadableChannel, UserID, ) @@ -44,8 +46,10 @@ "is_dm_channel", "is_group_dm_channel", "is_category_channel", + "is_guild_channel", "is_messagable_channel", "is_messagable_guild_channel", + "is_threadable_channel", "is_connectable_channel", "is_partial_messagable", ) @@ -146,26 +150,34 @@ def is_category_channel(obj: object) -> TypeIs[CategoryChannel]: return isinstance(obj, CategoryChannel) -def is_messagable_channel(obj: object) -> TypeIs[MessageableChannel]: +def is_guild_channel(obj: object) -> TypeIs[GuildChannel]: return ( is_text_channel(obj) - or is_thread(obj) + or is_forum_channel(obj) or is_voice_channel(obj) or is_stage_channel(obj) - or is_dm_channel(obj) - or is_group_dm_channel(obj) + or is_category_channel(obj) ) -def is_messagable_guild_channel(obj: object) -> TypeIs[MessageableGuildChannel]: +def is_messagable_channel(obj: object) -> TypeIs[MessageableChannel]: return ( is_text_channel(obj) - or is_thread(obj) or is_voice_channel(obj) or is_stage_channel(obj) + or is_dm_channel(obj) + or is_group_dm_channel(obj) ) +def is_messagable_guild_channel(obj: object) -> TypeIs[MessageableGuildChannel]: + return is_text_channel(obj) or is_voice_channel(obj) or is_stage_channel(obj) + + +def is_threadable_channel(obj: object) -> TypeIs[ThreadableChannel]: + return is_text_channel(obj) or is_forum_channel(obj) + + def is_connectable_channel(obj: object) -> TypeIs[ConnectableChannel]: return is_voice_channel(obj) or is_stage_channel(obj) diff --git a/commanderbot/lib/types.py b/commanderbot/lib/types.py index 6ce1424..eeae806 100644 --- a/commanderbot/lib/types.py +++ b/commanderbot/lib/types.py @@ -34,6 +34,7 @@ "GuildChannel", "MessageableChannel", "MessageableGuildChannel", + "ThreadableChannel", "ConnectableChannel", "TextMessage", "TextReaction", @@ -68,12 +69,13 @@ | CategoryChannel ) GuildChannel: TypeAlias = ( - TextChannel | ForumChannel | Thread | VoiceChannel | StageChannel | CategoryChannel + TextChannel | ForumChannel | VoiceChannel | StageChannel | CategoryChannel ) MessageableChannel: TypeAlias = ( - TextChannel | Thread | VoiceChannel | StageChannel | DMChannel | GroupChannel + TextChannel | VoiceChannel | StageChannel | DMChannel | GroupChannel ) -MessageableGuildChannel: TypeAlias = TextChannel | Thread | VoiceChannel | StageChannel +MessageableGuildChannel: TypeAlias = TextChannel | VoiceChannel | StageChannel +ThreadableChannel: TypeAlias = TextChannel | ForumChannel ConnectableChannel: TypeAlias = VoiceChannel | StageChannel diff --git a/commanderbot/lib/utils/utils.py b/commanderbot/lib/utils/utils.py index ea51972..fa89101 100644 --- a/commanderbot/lib/utils/utils.py +++ b/commanderbot/lib/utils/utils.py @@ -14,12 +14,13 @@ Member, Message, PartialMessageable, + Thread, User, ) from discord.ext.commands import Context from commanderbot.lib.predicates import is_text_channel, is_thread, is_user -from commanderbot.lib.types import MessageableChannel, RoleID, UserID +from commanderbot.lib.types import MessageableChannel, RoleID CHARACTER_CAP = 1900 @@ -130,7 +131,7 @@ def str_to_file(contents: str, file_name: str) -> File: async def send_message_or_file( - destination: MessageableChannel | PartialMessageable, + destination: MessageableChannel | Thread | PartialMessageable, content: str, *, file_callback: Callable[[], tuple[str, str, str]], From fa3d8968ad7edb0b94351369ec32d436fa4b53a8 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Mon, 15 Jun 2026 01:34:38 -0700 Subject: [PATCH 016/158] Updated discord.py to 2.7 --- README.md | 2 +- pyproject.toml | 2 +- uv.lock | 359 +++++++++++++++++++++++++------------------------ 3 files changed, 186 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index 8ca220f..67cae7a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A collection of utilities and extensions for discord.py bots. ## Requirements - Python 3.14+ -- discord.py 2.6+ +- discord.py 2.7+ ## Running your bot diff --git a/pyproject.toml b/pyproject.toml index b9011eb..e9dd748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ dependencies = [ # Core dependencies. - "discord-py>=2.6.0", + "discord-py>=2.7.0", "python-dotenv>=1.0.0", "pydantic>=2.13.0", "aiohttp>=3.13.0", diff --git a/uv.lock b/uv.lock index 15c4d6f..ec28d6a 100644 --- a/uv.lock +++ b/uv.lock @@ -4,16 +4,16 @@ requires-python = "==3.14.*" [[package]] name = "aiohappyeyeballs" -version = "2.6.1" +version = "2.6.2" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, ] [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -24,42 +24,49 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] [[package]] @@ -146,7 +153,7 @@ wheels = [ [[package]] name = "black" -version = "26.3.1" +version = "26.5.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "click" }, @@ -156,26 +163,26 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, - { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, - { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] [[package]] name = "click" -version = "8.3.3" +version = "8.4.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -230,7 +237,7 @@ requires-dist = [ { name = "allay", specifier = ">=1.3.0" }, { name = "colorama", specifier = ">=0.4.0" }, { name = "colorlog", specifier = ">=6.10.0" }, - { name = "discord-py", specifier = ">=2.6.0" }, + { name = "discord-py", specifier = ">=2.7.0" }, { name = "emoji", specifier = ">=2.0.0" }, { name = "jsonpath-ng", specifier = ">=1.7.0" }, { name = "mccq", specifier = ">=1.0.0" }, @@ -311,11 +318,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.18" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -401,68 +408,72 @@ wheels = [ [[package]] name = "packaging" -version = "26.1" +version = "26.2" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +version = "0.5.2" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] @@ -489,7 +500,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "annotated-types" }, @@ -497,50 +508,50 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.3" +version = "2.46.4" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] [[package]] @@ -629,50 +640,48 @@ wheels = [ [[package]] name = "yarl" -version = "1.23.0" +version = "1.24.2" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, - { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, - { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, - { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, - { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, - { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, - { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, - { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, - { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, - { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, - { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, - { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] From 9a588d3f6a110a90d35a5ea4706e344a5bda6531 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 30 Jun 2026 01:06:53 -0700 Subject: [PATCH 017/158] Reworked model validator --- commanderbot/core/config.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/commanderbot/core/config.py b/commanderbot/core/config.py index bec47a6..d0db731 100644 --- a/commanderbot/core/config.py +++ b/commanderbot/core/config.py @@ -5,7 +5,6 @@ BaseModel, ConfigDict, Field, - ModelWrapValidatorHandler, PrivateAttr, SerializerFunctionWrapHandler, model_serializer, @@ -45,17 +44,18 @@ class ConfiguredExtension(BaseModel): @model_validator(mode="before") @classmethod - def _validate_model(cls, data: dict | str) -> dict: - if isinstance(data, dict): - return data - # Extensions starting with a `$` are required. - elif data.startswith("$"): - return {"name": data[1:], "required": True} - # Extensions starting with a `!` are disabled. - elif data.startswith("!"): - return {"name": data[1:], "disabled": True} + def _validate_model(cls, data: Any) -> Any: + if isinstance(data, str): + # Extensions starting with a `$` are required. + if data.startswith("$"): + return {"name": data[1:], "required": True} + # Extensions starting with a `!` are disabled. + elif data.startswith("!"): + return {"name": data[1:], "disabled": True} + else: + return {"name": data} else: - return {"name": data} + return data @model_serializer(mode="wrap") def _serialize_model(self, handler: SerializerFunctionWrapHandler) -> dict | str: From 083ac330a55c9804d2ea766ca0dcb6026a09b05a Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 30 Jun 2026 01:08:03 -0700 Subject: [PATCH 018/158] Made custom Pydantic exceptions inherit from `ValueError` --- commanderbot/lib/exceptions.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/commanderbot/lib/exceptions.py b/commanderbot/lib/exceptions.py index a80b4df..c1c3abb 100644 --- a/commanderbot/lib/exceptions.py +++ b/commanderbot/lib/exceptions.py @@ -2,7 +2,6 @@ from discord import AllowedMentions, Interaction from discord.ext.commands import Context -from pydantic_core import PydanticCustomError __all__ = ( "MalformedData", @@ -14,35 +13,28 @@ class MalformedData(Exception): def __init__(self, cls: type, data: Any): - super().__init__(f"Cannot create {cls.__name__} from {type(data).__name__}") + super().__init__(f"Cannot create '{cls.__name__}' from '{type(data).__name__}'") -class NoSuchFactory(PydanticCustomError): +class NoSuchFactory(ValueError): """ Raised by a Pydantic validator if a factory function wasn't found. """ def __init__(self, cls: type, factory_name: str): super().__init__( - "no_such_factory", - "'{class_name}' doesn't have a factory function called '{factory_name}'", - {"class_name": cls.__name__, "factory_name": factory_name}, + f"'{cls.__name__}' doesn't have a factory function called '{factory_name}'", ) -class InstantiationError(PydanticCustomError): +class InstantiationError(ValueError): """ Raised by a Pydantic validator if there was an error while instantiating a class. """ def __init__(self, cls: type, exception: Exception): super().__init__( - "instantiation_error", - "An error occurred while instantiating a '{class_name}': {message}", - { - "class_name": cls.__name__, - "message": str(exception), - }, + f"An error occurred while instantiating a '{cls.__name__}': {str(exception)}", ) From a210c7c53aa510ce5df11444c63edb78c3c33947 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 30 Jun 2026 01:09:06 -0700 Subject: [PATCH 019/158] Added more types and enums --- commanderbot/lib/__init__.py | 3 +- commanderbot/lib/enums.py | 28 ++++++++++++++ commanderbot/lib/types.py | 72 +++++++++++++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 commanderbot/lib/enums.py diff --git a/commanderbot/lib/__init__.py b/commanderbot/lib/__init__.py index 3be3e53..49b1737 100644 --- a/commanderbot/lib/__init__.py +++ b/commanderbot/lib/__init__.py @@ -2,6 +2,7 @@ from .color import * from .constants import * from .dialogs import * +from .enums import * from .event_data import * from .exceptions import * from .from_data_mixin import * @@ -11,8 +12,8 @@ from .json_serializable import * from .log_options import * from .pattern_wrapper import * +from .predicates import * from .role_set import * from .shallow_formatter import * -from .predicates import * from .types import * from .value_formatter import * diff --git a/commanderbot/lib/enums.py b/commanderbot/lib/enums.py new file mode 100644 index 0000000..c4c4171 --- /dev/null +++ b/commanderbot/lib/enums.py @@ -0,0 +1,28 @@ +from typing import Annotated, Any + +import discord +from pydantic import PlainSerializer, PlainValidator + +from commanderbot.lib.types import ChannelTypeNames + +__all__ = ("ChannelType",) + + +def _validate_channel_type(data: Any) -> discord.ChannelType: + if isinstance(data, discord.ChannelType): + return data + + try: + return discord.ChannelType[data] + except KeyError as ex: + raise ValueError(f"'{data}' is not a valid 'ChannelType'") from ex + + +ChannelType = Annotated[ + discord.ChannelType, + PlainValidator(_validate_channel_type, json_schema_input_type=ChannelTypeNames), + PlainSerializer(lambda value: value.name, return_type=ChannelTypeNames), +] +""" +This is the `discord.ChannelType` enum, but with Pydantic support. +""" diff --git a/commanderbot/lib/types.py b/commanderbot/lib/types.py index eeae806..b659396 100644 --- a/commanderbot/lib/types.py +++ b/commanderbot/lib/types.py @@ -1,4 +1,4 @@ -from typing import Any, TypeAlias +from typing import Any, Literal, TypeAlias from discord import ( CategoryChannel, @@ -20,13 +20,16 @@ __all__ = ( "IDType", "GuildID", + "CategoryID", "ChannelID", + "ThreadID", "MessageID", "RoleID", "UserID", "ForumTagID", "AppCommandID", "EmojiID", + "DiscordAutomodRuleID", "RawOptions", "JsonObject", "MemberOrUser", @@ -36,6 +39,7 @@ "MessageableGuildChannel", "ThreadableChannel", "ConnectableChannel", + "ChannelTypeNames", "TextMessage", "TextReaction", ) @@ -44,13 +48,16 @@ IDType: TypeAlias = int GuildID: TypeAlias = IDType +CategoryID: TypeAlias = IDType ChannelID: TypeAlias = IDType +ThreadID: TypeAlias = IDType MessageID: TypeAlias = IDType RoleID: TypeAlias = IDType UserID: TypeAlias = IDType ForumTagID: TypeAlias = IDType AppCommandID: TypeAlias = IDType EmojiID: TypeAlias = IDType +DiscordAutomodRuleID: TypeAlias = IDType RawOptions: TypeAlias = Any @@ -78,6 +85,69 @@ ThreadableChannel: TypeAlias = TextChannel | ForumChannel ConnectableChannel: TypeAlias = VoiceChannel | StageChannel +ChannelTypeNames: TypeAlias = Literal[ + "text", + "news", + "forum", + "media", + "news_thread", + "public_thread", + "private_thread", + "voice", + "stage_voice", + "private", + "group", + "category", +] +""" +Contains all enumerator names from `discord.ChannelType`. + +https://discordpy.readthedocs.io/en/latest/api.html#discord.ChannelType +""" + +MemberFlagsNames: TypeAlias = Literal[ + "automod_quarantined_guild_tag", + "automod_quarantined_username", + "bypasses_verification", + "completed_home_actions", + "completed_onboarding", + "did_rejoin", + "dm_settings_upsell_acknowledged", + "guest", + "started_home_actions", + "started_onboarding", +] +""" +Contains all member flag names from `discord.MemberFlags` + +https://discordpy.readthedocs.io/en/latest/api.html#memberflags +""" + +PublicUserFlagsNames: TypeAlias = Literal[ + "active_developer", + "bot_http_interactions", + "bug_hunter", + "bug_hunter_level_2", + "discord_certified_moderator", + "early_supporter", + "hypesquad", + "hypesquad_balance", + "hypesquad_bravery", + "hypesquad_brilliance", + "partner", + "spammer", + "staff", + "system", + "team_user", + "verified_bot", + "verified_bot_developer", +] +""" +Contains all public user flag names from `discord.PublicUserFlags` + +https://discordpy.readthedocs.io/en/latest/api.html#publicuserflags +""" + class TextMessage(Message): """ From 4ecad0e60f5b9362a4fba822222709d6c9b39b42 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 30 Jun 2026 01:17:00 -0700 Subject: [PATCH 020/158] Moved guards to automod extension --- commanderbot/ext/automod/guards/__init__.py | 8 ++ commanderbot/ext/automod/guards/categories.py | 39 +++++++ .../ext/automod/guards/channel_types.py | 43 +++++++ commanderbot/ext/automod/guards/channels.py | 58 ++++++++++ .../automod/guards/discord_automod_rules.py | 42 +++++++ commanderbot/ext/automod/guards/flags.py | 109 ++++++++++++++++++ .../ext/automod/guards/integer_range.py | 79 +++++++++++++ commanderbot/ext/automod/guards/reactions.py | 51 ++++++++ commanderbot/ext/automod/guards/roles.py | 81 +++++++++++++ commanderbot/lib/guards/__init__.py | 4 - .../lib/guards/channel_types_guard.py | 60 ---------- commanderbot/lib/guards/channels_guard.py | 83 ------------- commanderbot/lib/guards/reactions_guard.py | 69 ----------- commanderbot/lib/guards/roles_guard.py | 87 -------------- 14 files changed, 510 insertions(+), 303 deletions(-) create mode 100644 commanderbot/ext/automod/guards/__init__.py create mode 100644 commanderbot/ext/automod/guards/categories.py create mode 100644 commanderbot/ext/automod/guards/channel_types.py create mode 100644 commanderbot/ext/automod/guards/channels.py create mode 100644 commanderbot/ext/automod/guards/discord_automod_rules.py create mode 100644 commanderbot/ext/automod/guards/flags.py create mode 100644 commanderbot/ext/automod/guards/integer_range.py create mode 100644 commanderbot/ext/automod/guards/reactions.py create mode 100644 commanderbot/ext/automod/guards/roles.py delete mode 100644 commanderbot/lib/guards/__init__.py delete mode 100644 commanderbot/lib/guards/channel_types_guard.py delete mode 100644 commanderbot/lib/guards/channels_guard.py delete mode 100644 commanderbot/lib/guards/reactions_guard.py delete mode 100644 commanderbot/lib/guards/roles_guard.py diff --git a/commanderbot/ext/automod/guards/__init__.py b/commanderbot/ext/automod/guards/__init__.py new file mode 100644 index 0000000..6f62abc --- /dev/null +++ b/commanderbot/ext/automod/guards/__init__.py @@ -0,0 +1,8 @@ +from .categories import * +from .channel_types import * +from .channels import * +from .discord_automod_rules import * +from .flags import * +from .integer_range import * +from .reactions import * +from .roles import * diff --git a/commanderbot/ext/automod/guards/categories.py b/commanderbot/ext/automod/guards/categories.py new file mode 100644 index 0000000..066cf89 --- /dev/null +++ b/commanderbot/ext/automod/guards/categories.py @@ -0,0 +1,39 @@ +from typing import Optional + +from discord import CategoryChannel +from pydantic import BaseModel, ConfigDict, Field + +from commanderbot.lib.types import CategoryID + +__all__ = ("CategoriesGuard",) + + +class CategoriesGuard(BaseModel): + """ + Checks whether a category matches a set of categories. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + include: set[CategoryID] = Field(default_factory=set) + """The categories to include. A category will match if it's in this set.""" + + exclude: set[CategoryID] = Field(default_factory=set) + """The categories to exclude. A category will match if it's not in this set.""" + + def _ignore_by_includes(self, category: CategoryChannel) -> bool: + if self.include: + return category.id not in self.include + return False + + def _ignore_by_excludes(self, category: CategoryChannel) -> bool: + if self.exclude: + return category.id in self.exclude + return False + + def ignore(self, category: Optional[CategoryChannel]) -> bool: + """Determine whether to ignore the category.""" + if not category: + return False + + return self._ignore_by_includes(category) or self._ignore_by_excludes(category) diff --git a/commanderbot/ext/automod/guards/channel_types.py b/commanderbot/ext/automod/guards/channel_types.py new file mode 100644 index 0000000..82d95c1 --- /dev/null +++ b/commanderbot/ext/automod/guards/channel_types.py @@ -0,0 +1,43 @@ +from typing import Optional + +import discord +from discord import Thread +from pydantic import BaseModel, ConfigDict, Field + +from commanderbot.lib.enums import ChannelType +from commanderbot.lib.types import GuildChannel + +__all__ = ("ChannelTypesGuard",) + + +class ChannelTypesGuard(BaseModel): + """ + Checks whether a channel has a certain type. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + include: set[ChannelType] = Field(default_factory=set) + """The channel types to include. A channel will match if it's one of these types.""" + + exclude: set[ChannelType] = Field(default_factory=set) + """The channel types to exclude. A channel will match if it's not one of these types.""" + + def _ignore_by_includes(self, channel_type: discord.ChannelType) -> bool: + if self.include: + return channel_type not in self.include + return False + + def _ignore_by_excludes(self, channel_type: discord.ChannelType) -> bool: + if self.exclude: + return channel_type in self.exclude + return False + + def ignore(self, channel: Optional[GuildChannel | Thread]) -> bool: + """Determine whether to ignore the channel based on its type.""" + if not channel: + return False + + return self._ignore_by_includes(channel.type) or self._ignore_by_excludes( + channel.type + ) diff --git a/commanderbot/ext/automod/guards/channels.py b/commanderbot/ext/automod/guards/channels.py new file mode 100644 index 0000000..99232a4 --- /dev/null +++ b/commanderbot/ext/automod/guards/channels.py @@ -0,0 +1,58 @@ +from typing import Optional + +from discord import Thread +from pydantic import BaseModel, ConfigDict, Field + +from commanderbot.lib.predicates import is_category_channel, is_thread +from commanderbot.lib.types import ChannelID, GuildChannel, ThreadID + +__all__ = ("ChannelsGuard",) + + +class ChannelsGuard(BaseModel): + """ + Checks whether a channel matches a set of channels. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + include: set[ChannelID | ThreadID] = Field(default_factory=set) + """The channels to include. A channel will match if it's in this set.""" + + exclude: set[ChannelID | ThreadID] = Field(default_factory=set) + """The channels to exclude. A channel will match if it's not in this set.""" + + def _ignore_by_includes(self, channel: GuildChannel | Thread) -> bool: + if not self.include: + return False + + # If the channel is a thread, check if its parent is included + if is_thread(channel) and (parent := channel.parent): + if parent.id in self.include: + return False + + # Otherwise, ignore the channel if and only if it's not included + return channel.id not in self.include + + def _ignore_by_excludes(self, channel: GuildChannel | Thread) -> bool: + if not self.exclude: + return False + + # If the channel is a thread, check if its parent is excluded + if is_thread(channel) and (parent := channel.parent): + if parent.id in self.exclude: + return False + + # Otherwise, ignore the channel if and only if it's excluded + return channel.id in self.exclude + + def ignore(self, channel: Optional[GuildChannel | Thread]) -> bool: + """Determine whether to ignore the channel.""" + if not channel: + return False + + # Skip checking category channels + if is_category_channel(channel): + return False + + return self._ignore_by_includes(channel) or self._ignore_by_excludes(channel) diff --git a/commanderbot/ext/automod/guards/discord_automod_rules.py b/commanderbot/ext/automod/guards/discord_automod_rules.py new file mode 100644 index 0000000..ef13383 --- /dev/null +++ b/commanderbot/ext/automod/guards/discord_automod_rules.py @@ -0,0 +1,42 @@ +from typing import Optional + +import discord +from pydantic import BaseModel, ConfigDict, Field + +from commanderbot.lib.types import DiscordAutomodRuleID + +__all__ = ("DiscordAutomodRulesGuard",) + + +class DiscordAutomodRulesGuard(BaseModel): + """ + Checks whether a Discord automod rule matches a set of Discord automod rules. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + include: set[DiscordAutomodRuleID] = Field(default_factory=set) + """The Discord automod rules to include. A rule will match if it's in this set.""" + + exclude: set[DiscordAutomodRuleID] = Field(default_factory=set) + """The Discord automod rules to exclude. A rule will match if it's not in this set.""" + + def _ignore_by_includes(self, rule_id: DiscordAutomodRuleID) -> bool: + if self.include: + return rule_id not in self.include + return False + + def _ignore_by_excludes(self, rule_id: DiscordAutomodRuleID) -> bool: + if self.exclude: + return rule_id in self.exclude + return False + + def ignore(self, rule: Optional[discord.AutoModRule | DiscordAutomodRuleID]): + """Determine whether to ignore the Discord automod rule.""" + if not rule: + return False + + # Get the ID of the rule + rule_id = rule.id if isinstance(rule, discord.AutoModRule) else rule + + return self._ignore_by_includes(rule_id) or self._ignore_by_excludes(rule_id) diff --git a/commanderbot/ext/automod/guards/flags.py b/commanderbot/ext/automod/guards/flags.py new file mode 100644 index 0000000..4b603ba --- /dev/null +++ b/commanderbot/ext/automod/guards/flags.py @@ -0,0 +1,109 @@ +from typing import Any, Generator, get_args + +from discord import Member, User +from pydantic import BaseModel, Field, GetCoreSchemaHandler +from pydantic_core import core_schema + +from commanderbot.lib.predicates import is_member +from commanderbot.lib.types import MemberFlagsNames, PublicUserFlagsNames + +type FlagNames = PublicUserFlagsNames | MemberFlagsNames +type Actor = User | Member + +__all__ = ("FlagsGuard",) + + +class FlagsGuard(BaseModel): + """ + Checks whether a `discord.User` or `discord.Member` has certain flags. + """ + + flags: set[FlagNames] = Field(default_factory=set) + + def _get_flags(self, actor: Actor) -> Generator[tuple[str, bool]]: + if is_member(actor): + yield from ((k, v) for k, v in actor.flags) + yield from ((k, v) for k, v in actor.public_flags) + + def has_any(self, actor: Actor) -> bool: + for flag_name, value in self._get_flags(actor): + if flag_name in self.flags and value: + return True + return False + + def has_all(self, actor: Actor) -> bool: + set_flags: int = 0 + for flag_name, value in self._get_flags(actor): + if flag_name in self.flags and value: + set_flags += 0 + return set_flags == len(self.flags) + + def gained_any(self, before: Actor, after: Actor) -> bool: + before_flags = dict(self._get_flags(before)) + after_flags = dict(self._get_flags(after)) + for flag_name in self.flags: + before_value: bool = before_flags[flag_name] + after_value: bool = after_flags[flag_name] + if before_value < after_value: + return True + return False + + def gained_all(self, before: Actor, after: Actor) -> bool: + before_flags = dict(self._get_flags(before)) + after_flags = dict(self._get_flags(after)) + gained_flags: int = 0 + for flag_name in self.flags: + before_value: bool = before_flags[flag_name] + after_value: bool = after_flags[flag_name] + if before_value < after_value: + gained_flags += 1 + return gained_flags == len(self.flags) + + def lost_any(self, before: Actor, after: Actor) -> bool: + before_flags = dict(self._get_flags(before)) + after_flags = dict(self._get_flags(after)) + for flag_name in self.flags: + before_value: bool = before_flags[flag_name] + after_value: bool = after_flags[flag_name] + if before_value > after_value: + return True + return False + + def lost_all(self, before: Actor, after: Actor) -> bool: + before_flags = dict(self._get_flags(before)) + after_flags = dict(self._get_flags(after)) + lost_flags: int = 0 + for flag_name in self.flags: + before_value: bool = before_flags[flag_name] + after_value: bool = after_flags[flag_name] + if before_value < after_value: + lost_flags += 0 + return lost_flags == len(self.flags) + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + from_list_schema = core_schema.chain_schema( + [ + core_schema.set_schema( + core_schema.literal_schema( + [*get_args(PublicUserFlagsNames), *get_args(MemberFlagsNames)] + ), + min_length=1, + ), + core_schema.no_info_plain_validator_function( + lambda data: cls(flags=set(data)) + ), + ] + ) + + return core_schema.json_or_python_schema( + json_schema=from_list_schema, + python_schema=core_schema.union_schema( + [core_schema.is_instance_schema(cls), from_list_schema] + ), + serialization=core_schema.plain_serializer_function_ser_schema( + lambda guard: list(guard.flags) + ), + ) diff --git a/commanderbot/ext/automod/guards/integer_range.py b/commanderbot/ext/automod/guards/integer_range.py new file mode 100644 index 0000000..358105b --- /dev/null +++ b/commanderbot/ext/automod/guards/integer_range.py @@ -0,0 +1,79 @@ +from typing import Any, Optional + +from pydantic import BaseModel, GetCoreSchemaHandler +from pydantic_core import core_schema + +__all__ = ("IntegerRangeGuard",) + + +class IntegerRangeGuard(BaseModel): + """ + An integer range with optional upper and lower bounds. + """ + + min: Optional[int] = None + """The lower bound of the range (inclusive).""" + + max: Optional[int] = None + """The upper bound of the range (inclusive).""" + + def includes(self, value: int) -> bool: + if self.min is not None and value < self.min: + return False + + if self.max is not None and value > self.max: + return False + + return True + + def excludes(self, value: int) -> bool: + return not self.includes(value) + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + from_int_schema = core_schema.chain_schema( + steps=[ + core_schema.int_schema(), + core_schema.no_info_plain_validator_function( + lambda data: cls(min=data, max=data) + ), + ], + serialization=core_schema.plain_serializer_function_ser_schema( + lambda guard: guard.min + ), + ) + + from_dict_schema = core_schema.chain_schema( + [ + core_schema.dict_schema( + keys_schema=core_schema.literal_schema(["min", "max"]), + values_schema=core_schema.union_schema( + [core_schema.int_schema(), core_schema.none_schema()] + ), + ), + core_schema.no_info_plain_validator_function( + lambda data: cls(min=data["min"], max=data["max"]) + ), + ] + ) + + return core_schema.json_or_python_schema( + json_schema=core_schema.union_schema( + [ + from_int_schema, + from_dict_schema, + ] + ), + python_schema=core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + from_int_schema, + from_dict_schema, + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema( + lambda guard: {"min": guard.min, "max": guard.max} + ), + ) diff --git a/commanderbot/ext/automod/guards/reactions.py b/commanderbot/ext/automod/guards/reactions.py new file mode 100644 index 0000000..6230db1 --- /dev/null +++ b/commanderbot/ext/automod/guards/reactions.py @@ -0,0 +1,51 @@ +from typing import Optional + +from discord import Reaction +from pydantic import BaseModel, ConfigDict, Field + +from commanderbot.ext.automod.guards.integer_range import IntegerRangeGuard + +__all__ = ("ReactionsGuard",) + + +class ReactionsGuard(BaseModel): + """ + Checks whether a reaction matches a set of reactions. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + include: set[str] = Field(default_factory=set) + """The reactions to include. A reaction will match if it's in this set.""" + + exclude: set[str] = Field(default_factory=set) + """The reactions to exclude. A reaction will match if it's not in this set.""" + + count: Optional[IntegerRangeGuard] = None + """The number of reactions to check for.""" + + def _ignore_by_includes(self, reaction: Reaction) -> bool: + if self.include: + return str(reaction.emoji) not in self.include + return False + + def _ignore_by_excludes(self, reaction: Reaction) -> bool: + if self.exclude: + return str(reaction.emoji) in self.exclude + return False + + def _ignore_by_count(self, reaction: Reaction) -> bool: + if self.count: + return self.count.excludes(reaction.count) + return False + + def ignore(self, reaction: Optional[Reaction]) -> bool: + """Determine whether to ignore the reaction based on the emoji and count.""" + if not reaction: + return False + + return ( + self._ignore_by_includes(reaction) + or self._ignore_by_excludes(reaction) + or self._ignore_by_count(reaction) + ) diff --git a/commanderbot/ext/automod/guards/roles.py b/commanderbot/ext/automod/guards/roles.py new file mode 100644 index 0000000..379ec42 --- /dev/null +++ b/commanderbot/ext/automod/guards/roles.py @@ -0,0 +1,81 @@ +from typing import Iterable, Optional + +from discord import Member, Role, User +from pydantic import BaseModel, ConfigDict, Field + +from commanderbot.lib.predicates import is_user +from commanderbot.lib.types import RoleID + +__all__ = ("RolesGuard",) + + +class RolesGuard(BaseModel): + """ + Checks whether a member matches a set of roles. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + include: set[RoleID] = Field(default_factory=set) + """The roles to include. A member will match if they have at least one of these.""" + + exclude: set[RoleID] = Field(default_factory=set) + """The roles to exclude. A member will match if they have none of these.""" + + def _ignore_by_includes(self, roles: set[RoleID]) -> bool: + if self.include: + matching_role_ids = self.include.intersection(roles) + return len(matching_role_ids) == 0 + return False + + def _ignore_by_excludes(self, roles: set[RoleID]) -> bool: + if self.exclude: + matching_role_ids = self.exclude.intersection(roles) + return len(matching_role_ids) > 0 + return False + + def ignore(self, member: Optional[Member | User]) -> bool: + """Determine whether to ignore the member based on their roles.""" + if not member: + return False + + # Ignore users since they don't have roles + if is_user(member): + return True + + roles = {role.id for role in member.roles} + return self._ignore_by_includes(roles) or self._ignore_by_excludes(roles) + + def member_matches(self, member: User | Member) -> bool: + if is_user(member): + return False + + # If `include` roles are defined, check if the member has *any* of them + # Note that `include` takes precedence over `exclude` + roles = {role.id for role in member.roles} + if self.include: + matching_role_ids = self.include.intersection(roles) + return len(matching_role_ids) > 0 + + # If `exclude` roles are defined, check if the member has *none* of them + if self.exclude: + matching_role_ids = self.exclude.intersection(roles) + return len(matching_role_ids) == 0 + + # If neither `include` nor `exclude` roles are defined, it's always a match + return True + + def role_matches(self, role: Role) -> bool: + if self.include: + return role.id in self.include + if self.exclude: + return role.id not in self.exclude + return True + + def filter_members(self, members: Iterable[User | Member]) -> list[User | Member]: + """Filter a sequence of members based on the guard.""" + return [member for member in members if self.member_matches(member)] + + def filter_roles(self, roles: Iterable[Role]) -> list[Role]: + """Filter a sequence of roles based on the guard.""" + return [role for role in roles if self.role_matches(role)] diff --git a/commanderbot/lib/guards/__init__.py b/commanderbot/lib/guards/__init__.py deleted file mode 100644 index 549b38b..0000000 --- a/commanderbot/lib/guards/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .channel_types_guard import * -from .channels_guard import * -from .reactions_guard import * -from .roles_guard import * diff --git a/commanderbot/lib/guards/channel_types_guard.py b/commanderbot/lib/guards/channel_types_guard.py deleted file mode 100644 index b2c2858..0000000 --- a/commanderbot/lib/guards/channel_types_guard.py +++ /dev/null @@ -1,60 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional - -from discord import TextChannel, Thread - -from commanderbot.lib.from_data_mixin import FromDataMixin - -__all__ = ("ChannelTypesGuard",) - - -THREAD_TYPES = { - TextChannel: "text", - Thread: "thread", -} - - -@dataclass -class ChannelTypesGuard(FromDataMixin): - """ - Check whether a channel is of a certain type. - - Attributes - ---------- - include - The channel types to include. A channel will match if it's one of these types. - exclude - The channel types to exclude. A channel will match if it's not one of these - types. - """ - - include: set[str] = field(default_factory=set) - exclude: set[str] = field(default_factory=set) - - @classmethod - def try_from_data(cls, data): - if isinstance(data, dict): - return cls( - include=set(data.get("include", [])), - exclude=set(data.get("exclude", [])), - ) - elif isinstance(data, list): - return cls(include=set(data)) - - def ignore_by_includes(self, type_name: str) -> bool: - if not self.include: - return False - return type_name not in self.include - - def ignore_by_excludes(self, type_name: str) -> bool: - if not self.exclude: - return False - return type_name in self.exclude - - def ignore(self, channel: Optional[TextChannel | Thread]) -> bool: - """Determine whether to ignore the channel based on its type.""" - if channel is None: - return False - channel_type = type(channel) - type_name = THREAD_TYPES.get(channel_type, "other") - return self.ignore_by_includes(type_name) or self.ignore_by_excludes(type_name) diff --git a/commanderbot/lib/guards/channels_guard.py b/commanderbot/lib/guards/channels_guard.py deleted file mode 100644 index 156ae69..0000000 --- a/commanderbot/lib/guards/channels_guard.py +++ /dev/null @@ -1,83 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional - -from discord import TextChannel, Thread - -from commanderbot.lib.from_data_mixin import FromDataMixin -from commanderbot.lib.predicates import is_thread -from commanderbot.lib.types import ChannelID - -__all__ = ("ChannelsGuard",) - - -@dataclass -class ChannelsGuard(FromDataMixin): - """ - Check whether a channel matches a set of channels. - - Attributes - ---------- - include - The channels to include. A channel will match if it is in this set. - exclude - The channels to exclude. A channel will match if it is not in this set. - """ - - include: set[ChannelID] = field(default_factory=set) - exclude: set[ChannelID] = field(default_factory=set) - - @classmethod - def try_from_data(cls, data): - if isinstance(data, dict): - return cls( - include=set(data.get("include", [])), - exclude=set(data.get("exclude", [])), - ) - elif isinstance(data, list): - return cls(include=set(data)) - - def ignore_by_includes(self, channel: TextChannel | Thread) -> bool: - # If includes are not defined, nothing is ignored. - if not self.include: - return False - - # TODO Should we add extra logic for categories too? #enhance - - # If the channel is a thread, apply some additional containment logic. - if is_thread(channel): - # If the thread has a parent channel (it should), check that first. - if parent_channel := channel.parent: - # If the parent channel is included, ignore the thread only if it's - # explicitly excluded. - if parent_channel.id in self.include: - return channel.id in self.exclude - - # Otherwise, ignore the channel if and only if it's not included. - return channel.id not in self.include - - def ignore_by_excludes(self, channel: TextChannel | Thread) -> bool: - # Ignore channels that are excluded, if any. - if not self.exclude: - return False - - # TODO Should we add extra logic for categories too? #enhance - - # If the channel is a thread, apply some additional containment logic. - if is_thread(channel): - # If the thread has a parent channel (it should), check that first. - if parent_channel := channel.parent: - # If the parent channel is excluded, ignore the thread only if it's - # not explicitly included. - if parent_channel.id in self.exclude: - return channel.id not in self.include - - return channel.id in self.exclude - - def ignore(self, channel: Optional[TextChannel | Thread]) -> bool: - """Determine whether to ignore the channel.""" - # If there's no channel, there's nothing to ignore. - if channel is None: - return False - - # Otherwise, check based on includes and excludes. - return self.ignore_by_includes(channel) or self.ignore_by_excludes(channel) diff --git a/commanderbot/lib/guards/reactions_guard.py b/commanderbot/lib/guards/reactions_guard.py deleted file mode 100644 index 8bf876b..0000000 --- a/commanderbot/lib/guards/reactions_guard.py +++ /dev/null @@ -1,69 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional - -from discord import Reaction - -from commanderbot.lib.from_data_mixin import FromDataMixin -from commanderbot.lib.integer_range import IntegerRange - -__all__ = ("ReactionsGuard",) - - -@dataclass -class ReactionsGuard(FromDataMixin): - """ - Check whether a reaction matches a set of reactions. - - Attributes - ---------- - include - The reactions to include. A reaction will match if it is in this set. - exclude - The reactions to exclude. A reaction will match if it is not in this set. - count - The number of reactions to check for. - """ - - include: set[str] = field(default_factory=set) - exclude: set[str] = field(default_factory=set) - - count: Optional[IntegerRange] = None - - @classmethod - def try_from_data(cls, data): - if isinstance(data, dict): - return cls( - include=set(data.get("include", [])), - exclude=set(data.get("exclude", [])), - count=IntegerRange.from_field_optional(data, "count"), - ) - elif isinstance(data, list): - return cls(include=set(data)) - - def ignore_by_includes(self, reaction: Reaction) -> bool: - # Ignore reactions with emoji that are not included, if any. - if not self.include: - return False - return str(reaction.emoji) not in self.include - - def ignore_by_excludes(self, reaction: Reaction) -> bool: - # Ignore reactions with emoji that are excluded, if any. - if not self.exclude: - return False - return (self.exclude is not None) and (str(reaction.emoji) in self.exclude) - - def ignore_by_count(self, reaction: Reaction) -> bool: - # Ignore reactions with a count that is not within the defined range, if any. - if self.count is None: - return False - return self.count.excludes(reaction.count) - - def ignore(self, reaction: Optional[Reaction]) -> bool: - """Determine whether to ignore the reaction based on the emoji and count.""" - if reaction is None: - return False - return ( - self.ignore_by_includes(reaction) - or self.ignore_by_excludes(reaction) - or self.ignore_by_count(reaction) - ) diff --git a/commanderbot/lib/guards/roles_guard.py b/commanderbot/lib/guards/roles_guard.py deleted file mode 100644 index 027e21a..0000000 --- a/commanderbot/lib/guards/roles_guard.py +++ /dev/null @@ -1,87 +0,0 @@ -from dataclasses import dataclass, field -from typing import Iterable, Optional - -from discord import Member, Role, User - -from commanderbot.lib.from_data_mixin import FromDataMixin -from commanderbot.lib.types import RoleID -from commanderbot.lib.utils import member_roles_from - -__all__ = ("RolesGuard",) - - -@dataclass -class RolesGuard(FromDataMixin): - """ - Check whether a member matches a set of roles. - - Attributes - ---------- - include - The roles to include. A member will match if they have at least one of these. - exclude - The roles to exclude. A member will match if they have none of these. - """ - - include: set[RoleID] = field(default_factory=set) - exclude: set[RoleID] = field(default_factory=set) - - @classmethod - def try_from_data(cls, data): - if isinstance(data, dict): - return cls( - include=set(data.get("include", [])), - exclude=set(data.get("exclude", [])), - ) - elif isinstance(data, list): - return cls(include=set(data)) - - def ignore_by_includes(self, member: Member) -> bool: - # Ignore roles that are not included, if any. - if not self.include: - return False - matching_roles = member_roles_from(member, self.include) - return len(matching_roles) == 0 - - def ignore_by_excludes(self, member: Member) -> bool: - # Ignore roles that are excluded, if any. - if not self.exclude: - return False - matching_roles = member_roles_from(member, self.exclude) - return len(matching_roles) > 0 - - def ignore(self, member: Optional[Member]) -> bool: - """Determine whether to ignore the member based on their roles.""" - if member is None: - return False - return self.ignore_by_includes(member) or self.ignore_by_excludes(member) - - def member_matches(self, member: User | Member) -> bool: - # If `include` roles are defined, check if the member has *any* of them. - # Note that `include` takes precedence over `exclude`. - if self.include: - included_roles = member_roles_from(member, self.include) - return len(included_roles) > 0 - - # If `exclude` roles are defined, check if the member has *none* of them. - if self.exclude: - excluded_roles = member_roles_from(member, self.exclude) - return len(excluded_roles) == 0 - - # If neither `include` nor `exclude` roles are defined, it's always a match. - return True - - def role_matches(self, role: Role) -> bool: - if self.include: - return role.id in self.include - if self.exclude: - return role.id not in self.exclude - return True - - def filter_members(self, members: Iterable[User | Member]) -> list[User | Member]: - """Filter a sequence of members based on the guard.""" - return [member for member in members if self.member_matches(member)] - - def filter_roles(self, roles: Iterable[Role]) -> list[Role]: - """Filter a sequence of roles based on the guard.""" - return [role for role in roles if self.role_matches(role)] From 6a16e6233951955bbc3c774521ada0fb1d0652b5 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 30 Jun 2026 01:17:30 -0700 Subject: [PATCH 021/158] Reworked automod events --- commanderbot/ext/automod/event/__init__.py | 1 + commanderbot/ext/automod/event/_types.py | 8 ++ commanderbot/ext/automod/event/event_base.py | 84 +++++++++++++++++++ commanderbot/ext/automod/events/__init__.py | 8 +- .../blocked_member_interactions.py | 51 +++++++++++ .../events/discord_automod/blocked_message.py | 75 +++++++++++++++++ .../events/discord_automod/timed_out.py | 65 ++++++++++++++ .../automod/events/guild_channel_created.py | 13 +-- .../automod/events/guild_channel_deleted.py | 13 +-- .../events/guild_channel_pins_updated.py | 21 +++++ .../automod/events/guild_channel_updated.py | 20 +++-- .../ext/automod/events/member_joined.py | 20 ++++- .../ext/automod/events/member_left.py | 20 ++++- .../ext/automod/events/member_typing.py | 24 ++++-- .../ext/automod/events/member_updated.py | 24 ++++-- .../ext/automod/events/message_deleted.py | 44 +++++++--- .../ext/automod/events/message_edited.py | 53 +++++++++--- .../ext/automod/events/message_sent.py | 38 +++++++-- .../ext/automod/events/raw_message_deleted.py | 12 --- .../ext/automod/events/raw_message_edited.py | 12 --- .../ext/automod/events/raw_reaction_added.py | 12 --- .../automod/events/raw_reaction_removed.py | 12 --- .../ext/automod/events/reaction_added.py | 41 ++++++--- .../ext/automod/events/reaction_removed.py | 41 ++++++--- .../ext/automod/events/thread_created.py | 9 +- .../ext/automod/events/thread_deleted.py | 9 +- .../ext/automod/events/thread_joined.py | 9 +- .../automod/events/thread_member_joined.py | 28 +++++-- .../ext/automod/events/thread_member_left.py | 28 +++++-- .../ext/automod/events/thread_removed.py | 9 +- .../ext/automod/events/thread_updated.py | 18 ++-- .../ext/automod/events/user_banned.py | 11 ++- .../ext/automod/events/user_unbanned.py | 11 ++- .../ext/automod/events/user_updated.py | 25 ++++-- 34 files changed, 681 insertions(+), 188 deletions(-) create mode 100644 commanderbot/ext/automod/event/__init__.py create mode 100644 commanderbot/ext/automod/event/_types.py create mode 100644 commanderbot/ext/automod/event/event_base.py create mode 100644 commanderbot/ext/automod/events/discord_automod/blocked_member_interactions.py create mode 100644 commanderbot/ext/automod/events/discord_automod/blocked_message.py create mode 100644 commanderbot/ext/automod/events/discord_automod/timed_out.py create mode 100644 commanderbot/ext/automod/events/guild_channel_pins_updated.py delete mode 100644 commanderbot/ext/automod/events/raw_message_deleted.py delete mode 100644 commanderbot/ext/automod/events/raw_message_edited.py delete mode 100644 commanderbot/ext/automod/events/raw_reaction_added.py delete mode 100644 commanderbot/ext/automod/events/raw_reaction_removed.py diff --git a/commanderbot/ext/automod/event/__init__.py b/commanderbot/ext/automod/event/__init__.py new file mode 100644 index 0000000..b72fc64 --- /dev/null +++ b/commanderbot/ext/automod/event/__init__.py @@ -0,0 +1 @@ +from .event_base import * diff --git a/commanderbot/ext/automod/event/_types.py b/commanderbot/ext/automod/event/_types.py new file mode 100644 index 0000000..7e924c6 --- /dev/null +++ b/commanderbot/ext/automod/event/_types.py @@ -0,0 +1,8 @@ +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + + type AutomodGuildStateRef = AutomodGuildState +else: + type AutomodGuildStateRef = Any diff --git a/commanderbot/ext/automod/event/event_base.py b/commanderbot/ext/automod/event/event_base.py new file mode 100644 index 0000000..fb88bef --- /dev/null +++ b/commanderbot/ext/automod/event/event_base.py @@ -0,0 +1,84 @@ +from abc import ABC +from collections import defaultdict +from dataclasses import dataclass, field +from logging import Logger +from typing import Any, Optional + +from discord import CategoryChannel, Member, Message, Reaction, Thread, User +from discord.ext.commands import Bot + +from commanderbot.ext.automod.event._types import AutomodGuildStateRef +from commanderbot.lib.predicates import is_thread +from commanderbot.lib.types import GuildChannel + +__all__ = ("AutomodEvent",) + + +@dataclass +class AutomodEvent(ABC): + """ + Base class for all automod events. + """ + + state: AutomodGuildStateRef + bot: Bot + log: Logger + + _metadata: defaultdict[str, dict] = field( + init=False, default_factory=lambda: defaultdict(dict) + ) + + @property + def channel(self) -> Optional[GuildChannel | Thread]: + """Return the relevant channel, if any.""" + return None + + @property + def thread(self) -> Optional[Thread]: + """Return the relevant thread, if any.""" + if is_thread(self.channel): + return self.channel + + @property + def category(self) -> Optional[CategoryChannel]: + """Return the relevant category, if any.""" + if self.channel: + return self.channel.category + + @property + def message(self) -> Optional[Message]: + """Return the relevant message, if any.""" + return None + + @property + def reaction(self) -> Optional[Reaction]: + """Return the relevant reaction, if any.""" + return None + + @property + def author(self) -> Optional[Member | User]: + """Return the relevant author, if any.""" + return None + + @property + def actor(self) -> Optional[Member | User]: + """Return the acting user, if any.""" + return None + + @property + def member(self) -> Optional[Member]: + """Return the member-in-question, if any.""" + return None + + @property + def user(self) -> Optional[User]: + """Return the user-in-question, if any.""" + return None + + def set_metadata(self, namespace: str, name: str, value: Any): + """Attach metadata to the event.""" + self._metadata[namespace][name] = value + + def remove_metadata(self, namespace: str, name: str): + """Remove metadata from the event.""" + del self._metadata[namespace][name] diff --git a/commanderbot/ext/automod/events/__init__.py b/commanderbot/ext/automod/events/__init__.py index 8ac746b..e1e4026 100644 --- a/commanderbot/ext/automod/events/__init__.py +++ b/commanderbot/ext/automod/events/__init__.py @@ -1,5 +1,9 @@ +from .discord_automod.blocked_member_interactions import * +from .discord_automod.blocked_message import * +from .discord_automod.timed_out import * from .guild_channel_created import * from .guild_channel_deleted import * +from .guild_channel_pins_updated import * from .guild_channel_updated import * from .member_joined import * from .member_left import * @@ -8,10 +12,6 @@ from .message_deleted import * from .message_edited import * from .message_sent import * -from .raw_message_deleted import * -from .raw_message_edited import * -from .raw_reaction_added import * -from .raw_reaction_removed import * from .reaction_added import * from .reaction_removed import * from .thread_created import * diff --git a/commanderbot/ext/automod/events/discord_automod/blocked_member_interactions.py b/commanderbot/ext/automod/events/discord_automod/blocked_member_interactions.py new file mode 100644 index 0000000..339e3ff --- /dev/null +++ b/commanderbot/ext/automod/events/discord_automod/blocked_member_interactions.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from typing import override + +import discord +from discord import Member, User + +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import is_member, is_user +from commanderbot.lib.types import DiscordAutomodRuleID + +__all__ = ("DiscordAutomodBlockedMemberInteractions",) + + +@dataclass +class DiscordAutomodBlockedMemberInteractions(AutomodEvent): + _execution: discord.AutoModAction + + @property + def rule_id(self) -> DiscordAutomodRuleID: + """The ID of the Discord automod rule.""" + return self._execution.rule_id + + @property + def matched_content(self) -> str: + """The matched content from the username/nickname.""" + assert isinstance(self._execution.matched_content, str) + return self._execution.matched_content + + @property + def matched_keyword(self) -> str: + """The keyword the username/nickname matched.""" + assert isinstance(self._execution.matched_keyword, str) + return self._execution.matched_keyword + + @property + @override + def actor(self) -> Member: + assert is_member(self._execution.member) + return self._execution.member + + @property + @override + def member(self) -> Member: + assert is_member(self._execution.member) + return self._execution.member + + @property + @override + def user(self) -> User: + assert is_user(self._execution.member) + return self._execution.member diff --git a/commanderbot/ext/automod/events/discord_automod/blocked_message.py b/commanderbot/ext/automod/events/discord_automod/blocked_message.py new file mode 100644 index 0000000..e267789 --- /dev/null +++ b/commanderbot/ext/automod/events/discord_automod/blocked_message.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from typing import override + +import discord +from discord import Member, Thread, User + +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import ( + is_member, + is_messagable_guild_channel, + is_thread, + is_user, +) +from commanderbot.lib.types import DiscordAutomodRuleID, MessageableGuildChannel + +__all__ = ("DiscordAutomodBlockedMessage",) + + +@dataclass +class DiscordAutomodBlockedMessage(AutomodEvent): + _execution: discord.AutoModAction + + @property + def rule_id(self) -> DiscordAutomodRuleID: + """The ID of the Discord automod rule.""" + return self._execution.rule_id + + @property + def content(self) -> str: + """The content of the message.""" + return self._execution.content + + @property + def matched_content(self) -> str: + """The matched content from the message.""" + assert isinstance(self._execution.matched_content, str) + return self._execution.matched_content + + @property + def matched_keyword(self) -> str: + """The keyword the content matched.""" + assert isinstance(self._execution.matched_keyword, str) + return self._execution.matched_keyword + + @property + @override + def channel(self) -> MessageableGuildChannel | Thread: + assert is_messagable_guild_channel(self._execution.channel) or is_thread( + self._execution.channel + ) + return self._execution.channel + + @property + @override + def author(self) -> Member: + assert is_member(self._execution.member) + return self._execution.member + + @property + @override + def actor(self) -> Member: + assert is_member(self._execution.member) + return self._execution.member + + @property + @override + def member(self) -> Member: + assert is_member(self._execution.member) + return self._execution.member + + @property + @override + def user(self) -> User: + assert is_user(self._execution.member) + return self._execution.member diff --git a/commanderbot/ext/automod/events/discord_automod/timed_out.py b/commanderbot/ext/automod/events/discord_automod/timed_out.py new file mode 100644 index 0000000..9db447c --- /dev/null +++ b/commanderbot/ext/automod/events/discord_automod/timed_out.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from datetime import timedelta +from typing import override + +import discord +from discord import Member, Thread, User + +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import ( + is_member, + is_messagable_guild_channel, + is_thread, + is_user, +) +from commanderbot.lib.types import DiscordAutomodRuleID, MessageableGuildChannel + +__all__ = ("DiscordAutomodTimedOut",) + + +@dataclass +class DiscordAutomodTimedOut(AutomodEvent): + _execution: discord.AutoModAction + + @property + def rule_id(self) -> DiscordAutomodRuleID: + """The ID of the Discord automod rule.""" + return self._execution.rule_id + + @property + def duration(self) -> timedelta: + """The duration of the timeout.""" + assert isinstance(self._execution.action.duration, timedelta) + return self._execution.action.duration + + @property + @override + def channel(self) -> MessageableGuildChannel | Thread: + assert is_messagable_guild_channel(self._execution.channel) or is_thread( + self._execution.channel + ) + return self._execution.channel + + @property + @override + def author(self) -> Member: + assert is_member(self._execution.member) + return self._execution.member + + @property + @override + def actor(self) -> Member: + assert is_member(self._execution.member) + return self._execution.member + + @property + @override + def member(self) -> Member: + assert is_member(self._execution.member) + return self._execution.member + + @property + @override + def user(self) -> User: + assert is_user(self._execution.member) + return self._execution.member diff --git a/commanderbot/ext/automod/events/guild_channel_created.py b/commanderbot/ext/automod/events/guild_channel_created.py index 1bf167e..7d8bc67 100644 --- a/commanderbot/ext/automod/events/guild_channel_created.py +++ b/commanderbot/ext/automod/events/guild_channel_created.py @@ -1,16 +1,17 @@ from dataclasses import dataclass +from typing import override -from discord import TextChannel, Thread - -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.types import GuildChannel __all__ = ("GuildChannelCreated",) @dataclass -class GuildChannelCreated(AutomodEventBase): - _channel: TextChannel | Thread +class GuildChannelCreated(AutomodEvent): + _channel: GuildChannel @property - def channel(self) -> TextChannel | Thread: + @override + def channel(self) -> GuildChannel: return self._channel diff --git a/commanderbot/ext/automod/events/guild_channel_deleted.py b/commanderbot/ext/automod/events/guild_channel_deleted.py index 2f89f93..e5145cd 100644 --- a/commanderbot/ext/automod/events/guild_channel_deleted.py +++ b/commanderbot/ext/automod/events/guild_channel_deleted.py @@ -1,16 +1,17 @@ from dataclasses import dataclass +from typing import override -from discord import TextChannel, Thread - -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.types import GuildChannel __all__ = ("GuildChannelDeleted",) @dataclass -class GuildChannelDeleted(AutomodEventBase): - _channel: TextChannel | Thread +class GuildChannelDeleted(AutomodEvent): + _channel: GuildChannel @property - def channel(self) -> TextChannel | Thread: + @override + def channel(self) -> GuildChannel: return self._channel diff --git a/commanderbot/ext/automod/events/guild_channel_pins_updated.py b/commanderbot/ext/automod/events/guild_channel_pins_updated.py new file mode 100644 index 0000000..4ec4ec3 --- /dev/null +++ b/commanderbot/ext/automod/events/guild_channel_pins_updated.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional, override + +from discord import Thread + +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.types import MessageableGuildChannel + +__all__ = ("GuildChannelPinsUpdated",) + + +@dataclass +class GuildChannelPinsUpdated(AutomodEvent): + _channel: MessageableGuildChannel | Thread + last_pin: Optional[datetime] + + @property + @override + def channel(self) -> MessageableGuildChannel | Thread: + return self._channel diff --git a/commanderbot/ext/automod/events/guild_channel_updated.py b/commanderbot/ext/automod/events/guild_channel_updated.py index efa8fcf..c17b4aa 100644 --- a/commanderbot/ext/automod/events/guild_channel_updated.py +++ b/commanderbot/ext/automod/events/guild_channel_updated.py @@ -1,17 +1,21 @@ from dataclasses import dataclass +from typing import override -from discord import TextChannel, Thread - -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.types import GuildChannel __all__ = ("GuildChannelUpdated",) @dataclass -class GuildChannelUpdated(AutomodEventBase): - _before: TextChannel | Thread - _after: TextChannel | Thread +class GuildChannelUpdated(AutomodEvent): + before: GuildChannel + """The guild channel's old info.""" + + after: GuildChannel + """The guild channel's updated info.""" @property - def channel(self) -> TextChannel | Thread: - return self._after + @override + def channel(self) -> GuildChannel: + return self.after diff --git a/commanderbot/ext/automod/events/member_joined.py b/commanderbot/ext/automod/events/member_joined.py index 1d5f810..8ec8b33 100644 --- a/commanderbot/ext/automod/events/member_joined.py +++ b/commanderbot/ext/automod/events/member_joined.py @@ -1,16 +1,30 @@ from dataclasses import dataclass +from typing import override -from discord import Member +from discord import Member, User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import is_user __all__ = ("MemberJoined",) @dataclass -class MemberJoined(AutomodEventBase): +class MemberJoined(AutomodEvent): _member: Member @property + @override + def actor(self) -> Member: + return self._member + + @property + @override def member(self) -> Member: return self._member + + @property + @override + def user(self) -> User: + assert is_user(self._member) + return self._member diff --git a/commanderbot/ext/automod/events/member_left.py b/commanderbot/ext/automod/events/member_left.py index 034350e..2cde765 100644 --- a/commanderbot/ext/automod/events/member_left.py +++ b/commanderbot/ext/automod/events/member_left.py @@ -1,16 +1,30 @@ from dataclasses import dataclass +from typing import override -from discord import Member +from discord import Member, User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import is_user __all__ = ("MemberLeft",) @dataclass -class MemberLeft(AutomodEventBase): +class MemberLeft(AutomodEvent): _member: Member @property + @override + def actor(self) -> Member: + return self._member + + @property + @override def member(self) -> Member: return self._member + + @property + @override + def user(self) -> User: + assert is_user(self._member) + return self._member diff --git a/commanderbot/ext/automod/events/member_typing.py b/commanderbot/ext/automod/events/member_typing.py index be3423f..3a14165 100644 --- a/commanderbot/ext/automod/events/member_typing.py +++ b/commanderbot/ext/automod/events/member_typing.py @@ -1,27 +1,39 @@ from dataclasses import dataclass from datetime import datetime +from typing import override -from discord import Member, TextChannel, Thread +from discord import Member, Thread, User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import is_user +from commanderbot.lib.types import MessageableGuildChannel __all__ = ("MemberTyping",) @dataclass -class MemberTyping(AutomodEventBase): - _channel: TextChannel | Thread +class MemberTyping(AutomodEvent): + _channel: MessageableGuildChannel | Thread _member: Member - _when: datetime + when: datetime @property - def channel(self) -> TextChannel | Thread: + @override + def channel(self) -> MessageableGuildChannel | Thread: return self._channel @property + @override def actor(self) -> Member: return self._member @property + @override def member(self) -> Member: return self._member + + @property + @override + def user(self) -> User: + assert is_user(self._member) + return self._member diff --git a/commanderbot/ext/automod/events/member_updated.py b/commanderbot/ext/automod/events/member_updated.py index 4d71688..6b386fa 100644 --- a/commanderbot/ext/automod/events/member_updated.py +++ b/commanderbot/ext/automod/events/member_updated.py @@ -1,21 +1,31 @@ from dataclasses import dataclass +from typing import override -from discord import Member +from discord import Member, User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import is_user __all__ = ("MemberUpdated",) @dataclass -class MemberUpdated(AutomodEventBase): - _before: Member - _after: Member +class MemberUpdated(AutomodEvent): + before: Member + after: Member @property + @override def actor(self) -> Member: - return self._after + return self.after @property + @override def member(self) -> Member: - return self._after + return self.after + + @property + @override + def user(self) -> User: + assert is_user(self.after) + return self.after diff --git a/commanderbot/ext/automod/events/message_deleted.py b/commanderbot/ext/automod/events/message_deleted.py index ada2cf1..0987758 100644 --- a/commanderbot/ext/automod/events/message_deleted.py +++ b/commanderbot/ext/automod/events/message_deleted.py @@ -1,33 +1,55 @@ from dataclasses import dataclass +from typing import Optional, override -from discord import Member, TextChannel, Thread +from discord import Member, Message, Thread, User -from commanderbot.ext.automod.automod_event import AutomodEventBase -from commanderbot.lib import TextMessage +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import ( + is_member, + is_messagable_guild_channel, + is_thread, + is_user, +) +from commanderbot.lib.types import MessageableGuildChannel __all__ = ("MessageDeleted",) @dataclass -class MessageDeleted(AutomodEventBase): - _message: TextMessage +class MessageDeleted(AutomodEvent): + _message: Message @property - def channel(self) -> TextChannel | Thread: + @override + def channel(self) -> MessageableGuildChannel | Thread: + assert is_messagable_guild_channel(self._message.channel) or is_thread( + self._message.channel + ) return self._message.channel @property - def message(self) -> TextMessage: + @override + def message(self) -> Message: return self._message @property - def author(self) -> Member: + @override + def author(self) -> Member | User: return self._message.author @property - def actor(self) -> Member: + @override + def actor(self) -> Member | User: return self._message.author @property - def member(self) -> Member: - return self._message.author + @override + def member(self) -> Optional[Member]: + if is_member(self._message.author): + return self._message.author + + @property + @override + def user(self) -> Optional[User]: + if is_user(self._message.author): + return self._message.author diff --git a/commanderbot/ext/automod/events/message_edited.py b/commanderbot/ext/automod/events/message_edited.py index 2cbbdac..1ed0c74 100644 --- a/commanderbot/ext/automod/events/message_edited.py +++ b/commanderbot/ext/automod/events/message_edited.py @@ -1,34 +1,61 @@ from dataclasses import dataclass +from typing import Optional, override -from discord import Member, TextChannel, Thread +from discord import Member, Message, Thread, User -from commanderbot.ext.automod.automod_event import AutomodEventBase -from commanderbot.lib import TextMessage +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import ( + is_member, + is_messagable_guild_channel, + is_thread, + is_user, +) +from commanderbot.lib.types import MessageableGuildChannel __all__ = ("MessageEdited",) @dataclass -class MessageEdited(AutomodEventBase): - _before: TextMessage - _after: TextMessage +class MessageEdited(AutomodEvent): + before: Message + """The previous version of the message.""" + + after: Message + """The current version of the message.""" @property - def channel(self) -> TextChannel | Thread: - return self._after.channel + @override + def channel(self) -> MessageableGuildChannel | Thread: + assert is_messagable_guild_channel(self.after.channel) or is_thread( + self.after.channel + ) + return self.after.channel @property - def message(self) -> TextMessage: - return self._after + @override + def message(self) -> Message: + return self.after @property + @override def author(self) -> Member: - return self._after.author + assert is_member(self.after.author) + return self.after.author @property + @override def actor(self) -> Member: - return self._after.author + assert is_member(self.after.author) + return self.after.author @property + @override def member(self) -> Member: - return self._after.author + assert is_member(self.after.author) + return self.after.author + + @property + @override + def user(self) -> Optional[User]: + assert is_user(self.after.author) + return self.after.author diff --git a/commanderbot/ext/automod/events/message_sent.py b/commanderbot/ext/automod/events/message_sent.py index e2a97be..92bce0f 100644 --- a/commanderbot/ext/automod/events/message_sent.py +++ b/commanderbot/ext/automod/events/message_sent.py @@ -1,33 +1,57 @@ from dataclasses import dataclass +from typing import override -from discord import Member, TextChannel, Thread +from discord import Member, Message, Thread, User -from commanderbot.ext.automod.automod_event import AutomodEventBase -from commanderbot.lib import TextMessage +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import ( + is_member, + is_messagable_guild_channel, + is_thread, + is_user, +) +from commanderbot.lib.types import MessageableGuildChannel __all__ = ("MessageSent",) @dataclass -class MessageSent(AutomodEventBase): - _message: TextMessage +class MessageSent(AutomodEvent): + _message: Message @property - def channel(self) -> TextChannel | Thread: + @override + def channel(self) -> MessageableGuildChannel | Thread: + assert is_messagable_guild_channel(self._message.channel) or is_thread( + self._message.channel + ) return self._message.channel @property - def message(self) -> TextMessage: + @override + def message(self) -> Message: return self._message @property + @override def author(self) -> Member: + assert is_member(self._message.author) return self._message.author @property + @override def actor(self) -> Member: + assert is_member(self._message.author) return self._message.author @property + @override def member(self) -> Member: + assert is_member(self._message.author) + return self._message.author + + @property + @override + def user(self) -> User: + assert is_user(self._message.author) return self._message.author diff --git a/commanderbot/ext/automod/events/raw_message_deleted.py b/commanderbot/ext/automod/events/raw_message_deleted.py deleted file mode 100644 index 3930100..0000000 --- a/commanderbot/ext/automod/events/raw_message_deleted.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass - -from discord import RawMessageDeleteEvent - -from commanderbot.ext.automod.automod_event import AutomodEventBase - -__all__ = ("RawMessageDeleted",) - - -@dataclass -class RawMessageDeleted(AutomodEventBase): - payload: RawMessageDeleteEvent diff --git a/commanderbot/ext/automod/events/raw_message_edited.py b/commanderbot/ext/automod/events/raw_message_edited.py deleted file mode 100644 index 47269f9..0000000 --- a/commanderbot/ext/automod/events/raw_message_edited.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass - -from discord import RawMessageUpdateEvent - -from commanderbot.ext.automod.automod_event import AutomodEventBase - -__all__ = ("RawMessageEdited",) - - -@dataclass -class RawMessageEdited(AutomodEventBase): - payload: RawMessageUpdateEvent diff --git a/commanderbot/ext/automod/events/raw_reaction_added.py b/commanderbot/ext/automod/events/raw_reaction_added.py deleted file mode 100644 index ff3c4b2..0000000 --- a/commanderbot/ext/automod/events/raw_reaction_added.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass - -from discord import RawReactionActionEvent - -from commanderbot.ext.automod.automod_event import AutomodEventBase - -__all__ = ("RawReactionAdded",) - - -@dataclass -class RawReactionAdded(AutomodEventBase): - payload: RawReactionActionEvent diff --git a/commanderbot/ext/automod/events/raw_reaction_removed.py b/commanderbot/ext/automod/events/raw_reaction_removed.py deleted file mode 100644 index f904603..0000000 --- a/commanderbot/ext/automod/events/raw_reaction_removed.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass - -from discord import RawReactionActionEvent - -from commanderbot.ext.automod.automod_event import AutomodEventBase - -__all__ = ("RawReactionRemoved",) - - -@dataclass -class RawReactionRemoved(AutomodEventBase): - payload: RawReactionActionEvent diff --git a/commanderbot/ext/automod/events/reaction_added.py b/commanderbot/ext/automod/events/reaction_added.py index 4551672..3a518f8 100644 --- a/commanderbot/ext/automod/events/reaction_added.py +++ b/commanderbot/ext/automod/events/reaction_added.py @@ -1,38 +1,55 @@ from dataclasses import dataclass +from typing import override -from discord import Member, TextChannel, Thread +from discord import Member, Message, Reaction, Thread, User -from commanderbot.ext.automod.automod_event import AutomodEventBase -from commanderbot.lib import TextMessage, TextReaction +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import is_messagable_guild_channel, is_thread, is_user +from commanderbot.lib.types import MessageableGuildChannel __all__ = ("ReactionAdded",) @dataclass -class ReactionAdded(AutomodEventBase): - _reaction: TextReaction +class ReactionAdded(AutomodEvent): + _reaction: Reaction _member: Member @property - def channel(self) -> TextChannel | Thread: - return self._reaction.message.channel + @override + def channel(self) -> MessageableGuildChannel | Thread: + assert is_messagable_guild_channel(self.message.channel) or is_thread( + self.message.channel + ) + return self.message.channel @property - def message(self) -> TextMessage: - return self._reaction.message + @override + def message(self) -> Message: + return self.reaction.message @property - def reaction(self) -> TextReaction: + @override + def reaction(self) -> Reaction: return self._reaction @property - def author(self) -> Member: - return self._reaction.message.author + @override + def author(self) -> Member | User: + return self.message.author @property + @override def actor(self) -> Member: return self._member @property + @override def member(self) -> Member: return self._member + + @property + @override + def user(self) -> User: + assert is_user(self._member) + return self._member diff --git a/commanderbot/ext/automod/events/reaction_removed.py b/commanderbot/ext/automod/events/reaction_removed.py index 6147834..4c8f7f3 100644 --- a/commanderbot/ext/automod/events/reaction_removed.py +++ b/commanderbot/ext/automod/events/reaction_removed.py @@ -1,38 +1,55 @@ from dataclasses import dataclass +from typing import override -from discord import Member, TextChannel, Thread +from discord import Member, Message, Reaction, Thread, User -from commanderbot.ext.automod.automod_event import AutomodEventBase -from commanderbot.lib import TextMessage, TextReaction +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import is_messagable_guild_channel, is_thread, is_user +from commanderbot.lib.types import MessageableGuildChannel __all__ = ("ReactionRemoved",) @dataclass -class ReactionRemoved(AutomodEventBase): - _reaction: TextReaction +class ReactionRemoved(AutomodEvent): + _reaction: Reaction _member: Member @property - def channel(self) -> TextChannel | Thread: - return self._reaction.message.channel + @override + def channel(self) -> MessageableGuildChannel | Thread: + assert is_messagable_guild_channel(self.message.channel) or is_thread( + self.message.channel + ) + return self.message.channel @property - def message(self) -> TextMessage: - return self._reaction.message + @override + def message(self) -> Message: + return self.reaction.message @property - def reaction(self) -> TextReaction: + @override + def reaction(self) -> Reaction: return self._reaction @property - def author(self) -> Member: - return self._reaction.message.author + @override + def author(self) -> Member | User: + return self.message.author @property + @override def actor(self) -> Member: return self._member @property + @override def member(self) -> Member: return self._member + + @property + @override + def user(self) -> User: + assert is_user(self._member) + return self._member diff --git a/commanderbot/ext/automod/events/thread_created.py b/commanderbot/ext/automod/events/thread_created.py index 8976e28..1bbd51d 100644 --- a/commanderbot/ext/automod/events/thread_created.py +++ b/commanderbot/ext/automod/events/thread_created.py @@ -1,20 +1,23 @@ from dataclasses import dataclass +from typing import override from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent __all__ = ("ThreadCreated",) @dataclass -class ThreadCreated(AutomodEventBase): +class ThreadCreated(AutomodEvent): _thread: Thread @property + @override def channel(self) -> Thread: - return self.thread + return self._thread @property + @override def thread(self) -> Thread: return self._thread diff --git a/commanderbot/ext/automod/events/thread_deleted.py b/commanderbot/ext/automod/events/thread_deleted.py index 5adc947..ec1cb03 100644 --- a/commanderbot/ext/automod/events/thread_deleted.py +++ b/commanderbot/ext/automod/events/thread_deleted.py @@ -1,20 +1,23 @@ from dataclasses import dataclass +from typing import override from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent __all__ = ("ThreadDeleted",) @dataclass -class ThreadDeleted(AutomodEventBase): +class ThreadDeleted(AutomodEvent): _thread: Thread @property + @override def channel(self) -> Thread: - return self.thread + return self._thread @property + @override def thread(self) -> Thread: return self._thread diff --git a/commanderbot/ext/automod/events/thread_joined.py b/commanderbot/ext/automod/events/thread_joined.py index 982b8b3..9354543 100644 --- a/commanderbot/ext/automod/events/thread_joined.py +++ b/commanderbot/ext/automod/events/thread_joined.py @@ -1,20 +1,23 @@ from dataclasses import dataclass +from typing import override from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent __all__ = ("ThreadJoined",) @dataclass -class ThreadJoined(AutomodEventBase): +class ThreadJoined(AutomodEvent): _thread: Thread @property + @override def channel(self) -> Thread: - return self.thread + return self._thread @property + @override def thread(self) -> Thread: return self._thread diff --git a/commanderbot/ext/automod/events/thread_member_joined.py b/commanderbot/ext/automod/events/thread_member_joined.py index 4211da9..e7a23c0 100644 --- a/commanderbot/ext/automod/events/thread_member_joined.py +++ b/commanderbot/ext/automod/events/thread_member_joined.py @@ -1,27 +1,43 @@ from dataclasses import dataclass +from typing import override -from discord import Member, Thread, ThreadMember +from discord import Member, Thread, ThreadMember, User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import is_member, is_user __all__ = ("ThreadMemberJoined",) @dataclass -class ThreadMemberJoined(AutomodEventBase): +class ThreadMemberJoined(AutomodEvent): _member: ThreadMember @property + @override def channel(self) -> Thread: - return self.thread + return self._member.thread @property + @override def thread(self) -> Thread: return self._member.thread @property + @override + def actor(self) -> Member: + return self.member + + @property + @override def member(self) -> Member: - # ... Seriously? + # ... We still need to do this? member = self._member.thread.guild.get_member(self._member.id) - assert member is not None + assert is_member(member) return member + + @property + @override + def user(self) -> User: + assert is_user(self.member) + return self.member diff --git a/commanderbot/ext/automod/events/thread_member_left.py b/commanderbot/ext/automod/events/thread_member_left.py index 972fa6a..4f1295e 100644 --- a/commanderbot/ext/automod/events/thread_member_left.py +++ b/commanderbot/ext/automod/events/thread_member_left.py @@ -1,27 +1,43 @@ from dataclasses import dataclass +from typing import override -from discord import Member, Thread, ThreadMember +from discord import Member, Thread, ThreadMember, User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import is_member, is_user __all__ = ("ThreadMemberLeft",) @dataclass -class ThreadMemberLeft(AutomodEventBase): +class ThreadMemberLeft(AutomodEvent): _member: ThreadMember @property + @override def channel(self) -> Thread: - return self.thread + return self._member.thread @property + @override def thread(self) -> Thread: return self._member.thread @property + @override + def actor(self) -> Member: + return self.member + + @property + @override def member(self) -> Member: - # ... Seriously? + # ... We still need to do this? member = self._member.thread.guild.get_member(self._member.id) - assert member is not None + assert is_member(member) return member + + @property + @override + def user(self) -> User: + assert is_user(self.member) + return self.member diff --git a/commanderbot/ext/automod/events/thread_removed.py b/commanderbot/ext/automod/events/thread_removed.py index 849a3b2..12d1fe3 100644 --- a/commanderbot/ext/automod/events/thread_removed.py +++ b/commanderbot/ext/automod/events/thread_removed.py @@ -1,20 +1,23 @@ from dataclasses import dataclass +from typing import override from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent __all__ = ("ThreadRemoved",) @dataclass -class ThreadRemoved(AutomodEventBase): +class ThreadRemoved(AutomodEvent): _thread: Thread @property + @override def channel(self) -> Thread: - return self.thread + return self._thread @property + @override def thread(self) -> Thread: return self._thread diff --git a/commanderbot/ext/automod/events/thread_updated.py b/commanderbot/ext/automod/events/thread_updated.py index aca5154..f1f76e2 100644 --- a/commanderbot/ext/automod/events/thread_updated.py +++ b/commanderbot/ext/automod/events/thread_updated.py @@ -1,21 +1,27 @@ from dataclasses import dataclass +from typing import override from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent __all__ = ("ThreadUpdated",) @dataclass -class ThreadUpdated(AutomodEventBase): - _before: Thread - _after: Thread +class ThreadUpdated(AutomodEvent): + before: Thread + """The thread's old info.""" + + after: Thread + """The thread's updated info.""" @property + @override def channel(self) -> Thread: - return self.thread + return self.after @property + @override def thread(self) -> Thread: - return self._after + return self.after diff --git a/commanderbot/ext/automod/events/user_banned.py b/commanderbot/ext/automod/events/user_banned.py index a5b3b8a..cf2ba53 100644 --- a/commanderbot/ext/automod/events/user_banned.py +++ b/commanderbot/ext/automod/events/user_banned.py @@ -1,16 +1,23 @@ from dataclasses import dataclass +from typing import override from discord import User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent __all__ = ("UserBanned",) @dataclass -class UserBanned(AutomodEventBase): +class UserBanned(AutomodEvent): _user: User @property + @override + def actor(self) -> User: + return self._user + + @property + @override def user(self) -> User: return self._user diff --git a/commanderbot/ext/automod/events/user_unbanned.py b/commanderbot/ext/automod/events/user_unbanned.py index 5c418ff..a99643a 100644 --- a/commanderbot/ext/automod/events/user_unbanned.py +++ b/commanderbot/ext/automod/events/user_unbanned.py @@ -1,16 +1,23 @@ from dataclasses import dataclass +from typing import override from discord import User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent __all__ = ("UserUnbanned",) @dataclass -class UserUnbanned(AutomodEventBase): +class UserUnbanned(AutomodEvent): _user: User @property + @override + def actor(self) -> User: + return self._user + + @property + @override def user(self) -> User: return self._user diff --git a/commanderbot/ext/automod/events/user_updated.py b/commanderbot/ext/automod/events/user_updated.py index 78e1c2b..22ac886 100644 --- a/commanderbot/ext/automod/events/user_updated.py +++ b/commanderbot/ext/automod/events/user_updated.py @@ -1,18 +1,27 @@ from dataclasses import dataclass +from typing import override -from discord import Member, User +from discord import User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import AutomodEvent __all__ = ("UserUpdated",) @dataclass -class UserUpdated(AutomodEventBase): - _before: User - _after: User - _member: Member +class UserUpdated(AutomodEvent): + before: User + """The user's old info.""" + + after: User + """The user's updated info.""" + + @property + @override + def actor(self) -> User: + return self.after @property - def member(self) -> Member: - return self._member + @override + def user(self) -> User: + return self.after From c29b8498b0cfeeb3ab04b8f0ca048b8090a2ab34 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 30 Jun 2026 01:19:10 -0700 Subject: [PATCH 022/158] Removed unnecessary file. --- commanderbot/ext/automod/automod_event.py | 263 ---------------------- 1 file changed, 263 deletions(-) delete mode 100644 commanderbot/ext/automod/automod_event.py diff --git a/commanderbot/ext/automod/automod_event.py b/commanderbot/ext/automod/automod_event.py deleted file mode 100644 index 0b9d799..0000000 --- a/commanderbot/ext/automod/automod_event.py +++ /dev/null @@ -1,263 +0,0 @@ -from dataclasses import dataclass, field -from logging import Logger -from typing import Any, ClassVar, Iterable, Optional, Protocol, Type, cast - -from discord import Member, TextChannel, Thread, User -from discord.ext.commands import Bot -from discord.utils import format_dt, utcnow - -from commanderbot.lib import ( - ShallowFormatter, - TextMessage, - TextReaction, - ValueFormatter, - is_thread, -) - - -class AutomodEvent(Protocol): - bot: Bot - log: Logger - - @property - def channel(self) -> Optional[TextChannel | Thread]: - """Return the relevant channel, if any.""" - ... - - @property - def thread(self) -> Optional[Thread]: - """Return the relevant thread, if any.""" - ... - - @property - def message(self) -> Optional[TextMessage]: - """Return the relevant message, if any.""" - ... - - @property - def reaction(self) -> Optional[TextReaction]: - """Return the relevant reaction, if any.""" - ... - - @property - def author(self) -> Optional[Member]: - """Return the relevant author, if any.""" - ... - - @property - def actor(self) -> Optional[Member]: - """Return the acting user, if any.""" - ... - - @property - def member(self) -> Optional[Member]: - """Return the member-in-question, if any.""" - ... - - @property - def user(self) -> Optional[User]: - """Return the user-in-question, if any.""" - ... - - def set_metadata(self, key: str, value: Any): - """Attach metadata to the event.""" - ... - - def remove_metadata(self, key: str): - """Remove metadata from the event.""" - ... - - def get_fields(self, unsafe: bool = False) -> dict[str, Any]: - """Get the full event data.""" - ... - - def format_content(self, content: str, *, unsafe: bool = False) -> str: - """Format a string with event data.""" - ... - - -# @implements AutomodEvent -@dataclass -class AutomodEventBase: - bot: Bot - log: Logger - - _metadata: dict[str, Any] = field(init=False, default_factory=dict) - - SAFE_TYPES: ClassVar[tuple[Type, ...]] = (bool, int, float, str) - - def __init__( - self, - bot: Bot, - log: Logger, - **data: dict[str, Any], - ) -> None: - self.bot = bot - self.log = log - self._metadata = {} - - @property - def channel(self) -> Optional[TextChannel | Thread]: - return None - - @property - def thread(self) -> Optional[Thread]: - if is_thread(self.channel): - return self.channel - - @property - def message(self) -> Optional[TextMessage]: - return None - - @property - def reaction(self) -> Optional[TextReaction]: - return None - - @property - def author(self) -> Optional[Member]: - return None - - @property - def actor(self) -> Optional[Member]: - return None - - @property - def member(self) -> Optional[Member]: - return None - - @property - def user(self) -> Optional[User]: - return cast(User, self.member) - - def set_metadata(self, key: str, value: Any): - self._metadata[key] = value - - def remove_metadata(self, key: str): - del self._metadata[key] - - def get_fields(self, unsafe: bool = False) -> dict[str, Any]: - if unsafe: - return self._get_fields_unsafe() - return self._get_fields_safe() - - def format_content(self, content: str, *, unsafe: bool = False) -> str: - # NOTE Beware of untrusted format strings! - # Instead of providing a handful of library objects with arbitrary (and - # potentially sensitive) data to the format string, we build a flattened set of - # arguments and pass them to a safe formatter. Pass `unsafe=True` to explicitly - # enable unsafe formatting for things like field access. - fields = self.get_fields(unsafe) - if unsafe: - return content.format_map(fields) - return ShallowFormatter().format(content, **fields) - - def _get_fields_unsafe(self) -> dict[str, Any]: - format_args = self._get_fields_safe() - format_args.update( - channel=self.channel, - message=self.message, - reaction=self.reaction, - author=self.author, - actor=self.actor, - member=self.member, - ) - format_args.update(self._metadata) - return format_args - - def _get_fields_safe(self) -> dict[str, Any]: - return {k: v for k, v in self._yield_safe_fields()} - - def _is_value_safe(self, v: Any) -> bool: - return (type(v) in self.SAFE_TYPES) or ( - isinstance(v, ValueFormatter) and (type(v.value) in self.SAFE_TYPES) - ) - - def _yield_safe_fields(self) -> Iterable[tuple[str, Any]]: - if self.channel is not None: - yield "channel_id", self.channel.id, - yield "channel_name", self.channel.name, - yield "channel_mention", self.channel.mention, - if self.thread is not None: - yield "thread_id", self.thread.id, - yield "thread_name", self.thread.name, - yield "thread_mention", self.thread.mention, - yield "thread_archived", self.thread.archived, - yield "thread_locked", self.thread.locked, - yield "thread_slowmode_delay", self.thread.slowmode_delay, - yield "thread_auto_archive_duration", self.thread.auto_archive_duration, - if (thread_owner := self.thread.owner) is not None: - yield from self._yield_safe_member_fields("thread_owner", thread_owner) - if self.message is not None: - yield "message_id", self.message.id - yield "message_content", self.message.content - yield "message_clean_content", self.message.clean_content - yield "message_jump_url", self.message.jump_url - if self.reaction is not None: - yield "reaction_emoji", self.reaction.emoji - yield "reaction_count", self.reaction.count - if self.author is not None: - yield from self._yield_safe_member_fields("author", self.author) - if self.actor is not None: - yield from self._yield_safe_member_fields("actor", self.actor) - if self.member is not None: - yield from self._yield_safe_member_fields("member", self.member) - if self.user is not None: - yield from self._yield_safe_user_fields("user", self.user) - for k, v in self._yield_extra_fields(): - if self._is_value_safe(v): - yield k, v - for k, v in self._yield_metadata_fields(): - if self._is_value_safe(v): - yield k, v - - def _yield_safe_member_fields( - self, prefix: str, member: Member - ) -> Iterable[tuple[str, Any]]: - yield from self._yield_safe_user_fields(prefix, cast(User, member)) - yield f"{prefix}_nick", member.nick - yield from self._yield_member_date_fields(prefix, member) - - def _yield_safe_user_fields( - self, prefix: str, user: User - ) -> Iterable[tuple[str, Any]]: - yield f"{prefix}_id", user.id - yield f"{prefix}_name", f"{user}" - yield f"{prefix}_username", user.name - yield f"{prefix}_discriminator", user.discriminator - yield f"{prefix}_mention", user.mention - yield f"{prefix}_display_name", user.display_name - - def _yield_member_date_fields( - self, prefix: str, member: Member - ) -> Iterable[tuple[str, Any]]: - # joined_at - joined_at_value = "Unknown" - if joined_at := member.joined_at: - joined_at_value = format_dt(joined_at, style="R") - yield f"{prefix}_joined_at", ValueFormatter(joined_at_value) - - # member_for - member_for_value = "Unknown" - if joined_at := member.joined_at: - member_for = utcnow() - joined_at - member_for_str = f"{member_for.days} days" - if member_for.days < 7: - hh = int(member_for.total_seconds() / 3600) - mm = int(member_for.total_seconds() / 60) % 60 - member_for_str = f"{hh} hours, {mm} minutes" - member_for_value = member_for_str - yield f"{prefix}_member_for", member_for_value - - # created_at - yield f"{prefix}_created_at", ValueFormatter( - format_dt(member.created_at, style="R") - ) - - def _yield_extra_fields(self) -> Iterable[tuple[str, Any]]: - """Override this to provide additional fields based on the event type.""" - if False: - yield - - def _yield_metadata_fields(self) -> Iterable[tuple[str, Any]]: - for k, v in self._metadata.items(): - yield k, v From c75f1a070ff8b2a2855b908e247a14133fc16cb1 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 30 Jun 2026 01:25:19 -0700 Subject: [PATCH 023/158] Moved reference types to their own file --- commanderbot/ext/automod/event/_types.py | 8 -------- commanderbot/ext/automod/event/event_base.py | 2 +- commanderbot/ext/automod/types.py | 16 ++++++++++++++++ 3 files changed, 17 insertions(+), 9 deletions(-) delete mode 100644 commanderbot/ext/automod/event/_types.py create mode 100644 commanderbot/ext/automod/types.py diff --git a/commanderbot/ext/automod/event/_types.py b/commanderbot/ext/automod/event/_types.py deleted file mode 100644 index 7e924c6..0000000 --- a/commanderbot/ext/automod/event/_types.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from commanderbot.ext.automod.automod_guild_state import AutomodGuildState - - type AutomodGuildStateRef = AutomodGuildState -else: - type AutomodGuildStateRef = Any diff --git a/commanderbot/ext/automod/event/event_base.py b/commanderbot/ext/automod/event/event_base.py index fb88bef..f6d3187 100644 --- a/commanderbot/ext/automod/event/event_base.py +++ b/commanderbot/ext/automod/event/event_base.py @@ -7,7 +7,7 @@ from discord import CategoryChannel, Member, Message, Reaction, Thread, User from discord.ext.commands import Bot -from commanderbot.ext.automod.event._types import AutomodGuildStateRef +from commanderbot.ext.automod.types import AutomodGuildStateRef from commanderbot.lib.predicates import is_thread from commanderbot.lib.types import GuildChannel diff --git a/commanderbot/ext/automod/types.py b/commanderbot/ext/automod/types.py new file mode 100644 index 0000000..d2b5a94 --- /dev/null +++ b/commanderbot/ext/automod/types.py @@ -0,0 +1,16 @@ +from typing import TYPE_CHECKING, Any + +__all__ = ("AutomodGuildStateRef", "AutomodRuleRef") + +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + from commanderbot.ext.automod.rule import AutomodRule + + type AutomodGuildStateRef = AutomodGuildState + """A reference to an `AutomodGuildStateRef` for type checkers.""" + + type AutomodRuleRef = AutomodRule + """A reference to an `AutomodRule` for type checkers.""" +else: + type AutomodGuildStateRef = Any + type AutomodRuleRef = Any From bf996b7f6745d862de41645cb3c4b879c63a8e04 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 30 Jun 2026 04:04:16 -0700 Subject: [PATCH 024/158] Added triggers and started reimplementing `AutomodRule` --- commanderbot/ext/automod/rule/__init__.py | 2 + commanderbot/ext/automod/rule/metadata.py | 34 +++ commanderbot/ext/automod/rule/rule.py | 43 ++++ commanderbot/ext/automod/trigger/__init__.py | 2 + .../ext/automod/trigger/trigger_base.py | 53 +++++ commanderbot/ext/automod/trigger/types.py | 35 +++ commanderbot/ext/automod/triggers/__init__.py | 20 ++ .../ext/automod/triggers/abc/__init__.py | 0 .../ext/automod/triggers/abc/thread_base.py | 40 ---- .../blocked_member_interactions.py | 38 +++ .../discord_automod/blocked_message.py | 60 +++++ .../triggers/discord_automod/timed_out.py | 36 +++ .../automod/triggers/member_gained_flags.py | 43 ++++ .../ext/automod/triggers/member_joined.py | 22 +- .../ext/automod/triggers/member_left.py | 22 +- .../ext/automod/triggers/member_lost_flags.py | 43 ++++ .../ext/automod/triggers/member_typing.py | 87 +++---- .../ext/automod/triggers/member_updated.py | 58 ----- .../triggers/mentions_removed_from_message.py | 218 +++++++++--------- commanderbot/ext/automod/triggers/message.py | 104 +++------ .../ext/automod/triggers/message_deleted.py | 16 +- .../ext/automod/triggers/message_edited.py | 16 +- .../ext/automod/triggers/message_sent.py | 16 +- commanderbot/ext/automod/triggers/reaction.py | 125 ++++------ .../ext/automod/triggers/reaction_added.py | 29 +-- .../ext/automod/triggers/reaction_removed.py | 29 +-- .../ext/automod/triggers/thread_created.py | 58 +++-- .../ext/automod/triggers/thread_deleted.py | 51 ++++ .../ext/automod/triggers/thread_updated.py | 29 --- .../ext/automod/triggers/user_banned.py | 22 +- .../ext/automod/triggers/user_unbanned.py | 22 +- .../ext/automod/triggers/user_updated.py | 28 --- 32 files changed, 775 insertions(+), 626 deletions(-) create mode 100644 commanderbot/ext/automod/rule/__init__.py create mode 100644 commanderbot/ext/automod/rule/metadata.py create mode 100644 commanderbot/ext/automod/rule/rule.py create mode 100644 commanderbot/ext/automod/trigger/__init__.py create mode 100644 commanderbot/ext/automod/trigger/trigger_base.py create mode 100644 commanderbot/ext/automod/trigger/types.py delete mode 100644 commanderbot/ext/automod/triggers/abc/__init__.py delete mode 100644 commanderbot/ext/automod/triggers/abc/thread_base.py create mode 100644 commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py create mode 100644 commanderbot/ext/automod/triggers/discord_automod/blocked_message.py create mode 100644 commanderbot/ext/automod/triggers/discord_automod/timed_out.py create mode 100644 commanderbot/ext/automod/triggers/member_gained_flags.py create mode 100644 commanderbot/ext/automod/triggers/member_lost_flags.py delete mode 100644 commanderbot/ext/automod/triggers/member_updated.py create mode 100644 commanderbot/ext/automod/triggers/thread_deleted.py delete mode 100644 commanderbot/ext/automod/triggers/thread_updated.py delete mode 100644 commanderbot/ext/automod/triggers/user_updated.py diff --git a/commanderbot/ext/automod/rule/__init__.py b/commanderbot/ext/automod/rule/__init__.py new file mode 100644 index 0000000..2575fa8 --- /dev/null +++ b/commanderbot/ext/automod/rule/__init__.py @@ -0,0 +1,2 @@ +from .metadata import * +from .rule import * diff --git a/commanderbot/ext/automod/rule/metadata.py b/commanderbot/ext/automod/rule/metadata.py new file mode 100644 index 0000000..6f5251f --- /dev/null +++ b/commanderbot/ext/automod/rule/metadata.py @@ -0,0 +1,34 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from commanderbot.lib.types import UserID + +__all__ = ("AutomodRuleMetadata",) + + +class AutomodRuleMetadata(BaseModel): + """ + Contains metadata about an automod rule. This is separate from the rule + so users can't modify it. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + name: str + """The name of the rule this metadata is for""" + + hits: int + """How many times the rule's conditions have passed and actions have run.""" + + added_by_id: UserID + """The ID of the user who created the rule.""" + + modified_by_id: UserID + """The ID of the last user that modified the rule.""" + + added_on: datetime + """The `datetime` the rule was created.""" + + modified_on: datetime + """The last `datetime` the rule was modified.""" diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py new file mode 100644 index 0000000..e2999e0 --- /dev/null +++ b/commanderbot/ext/automod/rule/rule.py @@ -0,0 +1,43 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.trigger import AutomodTriggerCollection + +__all__ = ("AutomodRule",) + + +class AutomodRule(BaseModel): + """ + An automod rule details how to perform an automated task. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + name: str + """The name of the rule.""" + + description: Optional[str] = None + """Describe what the rule does.""" + + disabled: Optional[bool] = None + """Is the rule disabled?""" + + triggers: AutomodTriggerCollection + """A list of triggers that cause the rule to run.""" + + async def _poll_triggers(self, event: AutomodEvent) -> bool: + for trigger in self.triggers: + if await trigger.poll(self, event): + return True + return False + + async def run(self, event: AutomodEvent) -> bool: + if self.disabled: + return False + + if await self._poll_triggers(event): + return True + + return False diff --git a/commanderbot/ext/automod/trigger/__init__.py b/commanderbot/ext/automod/trigger/__init__.py new file mode 100644 index 0000000..6a6b05c --- /dev/null +++ b/commanderbot/ext/automod/trigger/__init__.py @@ -0,0 +1,2 @@ +from .trigger_base import * +from .types import * diff --git a/commanderbot/ext/automod/trigger/trigger_base.py b/commanderbot/ext/automod/trigger/trigger_base.py new file mode 100644 index 0000000..e10a9b8 --- /dev/null +++ b/commanderbot/ext/automod/trigger/trigger_base.py @@ -0,0 +1,53 @@ +from abc import ABC +from typing import ClassVar, Optional + +from pydantic import BaseModel, ConfigDict + +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.types import AutomodRuleRef + +__all__ = ("AutomodTrigger",) + + +class AutomodTrigger(ABC, BaseModel): + """ + Base class for all automod triggers. + + Triggers specify which events they listen for and may have some configuration. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + type: str + """The trigger type.""" + + description: Optional[str] = None + """Describe what the trigger does.""" + + disabled: Optional[bool] = None + """Is the trigger disabled?""" + + event_types: ClassVar[tuple[type[AutomodEvent], ...]] = tuple() + + async def poll(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + """Checks whether an event activates the trigger.""" + + # Skip event if the trigger is disabled + if self.disabled: + return False + + # Skip event if it's not an event we're listening for + event_type = type(event) + if event_type not in self.event_types: + return False + + # Skip event if it should be ignored + if await self.ignore(rule, event): + return False + + # We probably care about the event if we got this far + return True + + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + """Override this if more than just the event type needs to be checked.""" + return False diff --git a/commanderbot/ext/automod/trigger/types.py b/commanderbot/ext/automod/trigger/types.py new file mode 100644 index 0000000..fb2d760 --- /dev/null +++ b/commanderbot/ext/automod/trigger/types.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from pydantic import Field + +from commanderbot.ext.automod import triggers + +__all__ = ("AutomodTriggerCollection",) + +type AutomodTriggers = ( + triggers.DiscordAutomodBlockedMemberInteractions + | triggers.DiscordAutomodBlockedMessage + | triggers.DiscordAutomodTimedOut + | triggers.MemberJoined + | triggers.MemberLeft + | triggers.MemberTyping + | triggers.MentionsRemovedFromMessage + | triggers.MemberGainedFlags + | triggers.MemberLostFlags + | triggers.MessageDeleted + | triggers.MessageEdited + | triggers.MessageSent + | triggers.Message + | triggers.Reaction + | triggers.ReactionAdded + | triggers.ReactionRemoved + | triggers.ThreadCreated + | triggers.ThreadDeleted + | triggers.UserBanned + | triggers.UserUnbanned +) + +AutomodTriggerCollection = Annotated[ + list[Annotated[AutomodTriggers, Field(discriminator="type")]], + Field(default_factory=list), +] diff --git a/commanderbot/ext/automod/triggers/__init__.py b/commanderbot/ext/automod/triggers/__init__.py index e69de29..9b201e4 100644 --- a/commanderbot/ext/automod/triggers/__init__.py +++ b/commanderbot/ext/automod/triggers/__init__.py @@ -0,0 +1,20 @@ +from .discord_automod.blocked_member_interactions import * +from .discord_automod.blocked_message import * +from .discord_automod.timed_out import * +from .member_gained_flags import * +from .member_joined import * +from .member_left import * +from .member_lost_flags import * +from .member_typing import * +from .mentions_removed_from_message import * +from .message import * +from .message_deleted import * +from .message_edited import * +from .message_sent import * +from .reaction import * +from .reaction_added import * +from .reaction_removed import * +from .thread_created import * +from .thread_deleted import * +from .user_banned import * +from .user_unbanned import * diff --git a/commanderbot/ext/automod/triggers/abc/__init__.py b/commanderbot/ext/automod/triggers/abc/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/commanderbot/ext/automod/triggers/abc/thread_base.py b/commanderbot/ext/automod/triggers/abc/thread_base.py deleted file mode 100644 index 014edc6..0000000 --- a/commanderbot/ext/automod/triggers/abc/thread_base.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Protocol, Type, TypeVar - -from discord import Thread - -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_trigger import AutomodTriggerBase -from commanderbot.lib import JsonObject -from commanderbot.lib.guards import ChannelsGuard - -ST = TypeVar("ST") - - -class EventWithThread(AutomodEvent, Protocol): - thread: Thread - - -@dataclass -class ThreadBase(AutomodTriggerBase): - parent_channels: Optional[ChannelsGuard] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - parent_channels = ChannelsGuard.from_field_optional(data, "parent_channels") - return cls( - description=data.get("description"), - parent_channels=parent_channels, - ) - - def ignore(self, event: EventWithThread) -> bool: - # If no parent channels are defined, don't ignore the thread. - if self.parent_channels is None: - return False - - # If the thread has no parent, ignore it. - if not event.thread.parent: - return True - - # Otherwise, ignore the thread according to its parent. - return self.parent_channels.ignore(event.thread.parent) diff --git a/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py b/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py new file mode 100644 index 0000000..3da7a9c --- /dev/null +++ b/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py @@ -0,0 +1,38 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import DiscordAutomodRulesGuard, RolesGuard +from commanderbot.ext.automod.trigger import AutomodTrigger +from commanderbot.ext.automod.types import AutomodRuleRef + +__all__ = ("DiscordAutomodBlockedMemberInteractions",) + + +class DiscordAutomodBlockedMemberInteractions(AutomodTrigger): + """ + Triggers when Discord's automod blocks member interactions. + """ + + type: Literal["discord_automod_blocked_member_interactions"] = ( + "discord_automod_blocked_member_interactions" + ) + event_types = (events.DiscordAutomodBlockedMemberInteractions,) + + rules: Optional[DiscordAutomodRulesGuard] = None + """The Discord automod rules to match against. If empty, all rules will match.""" + + roles: Optional[RolesGuard] = None + """The roles of the blocked user to match against. If empty, all roles will match.""" + + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance(event, events.DiscordAutomodBlockedMemberInteractions) + + if self.rules and self.rules.ignore(event.rule_id): + return True + + if self.roles and self.roles.ignore(event.member): + return True + + return False diff --git a/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py b/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py new file mode 100644 index 0000000..81c5e3d --- /dev/null +++ b/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py @@ -0,0 +1,60 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, + DiscordAutomodRulesGuard, + RolesGuard, +) +from commanderbot.ext.automod.trigger import AutomodTrigger +from commanderbot.ext.automod.types import AutomodRuleRef + +__all__ = ("DiscordAutomodBlockedMessage",) + + +class DiscordAutomodBlockedMessage(AutomodTrigger): + """ + Triggers when Discord's automod blocks a message. + """ + + type: Literal["discord_automod_blocked_message"] = "discord_automod_blocked_message" + event_types = (events.DiscordAutomodBlockedMessage,) + + rules: Optional[DiscordAutomodRulesGuard] = None + """The Discord automod rules to match against. If empty, all rules will match.""" + + categories: Optional[CategoriesGuard] = None + """The categories to match against. If empty, all categories will match.""" + + channel_types: Optional[ChannelTypesGuard] = None + """The channel types to match against. If empty, all channel types will match.""" + + channels: Optional[ChannelsGuard] = None + """The channels to match against. If empty, all channels will match.""" + + author_roles: Optional[RolesGuard] = None + """The author roles to match against. If empty, all roles will match.""" + + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance(event, events.DiscordAutomodBlockedMessage) + + if self.rules and self.rules.ignore(event.rule_id): + return True + + if self.categories and self.categories.ignore(event.category): + return True + + if self.channel_types and self.channel_types.ignore(event.channel): + return True + + if self.channels and self.channels.ignore(event.channel): + return True + + if self.author_roles and self.author_roles.ignore(event.author): + return True + + return False diff --git a/commanderbot/ext/automod/triggers/discord_automod/timed_out.py b/commanderbot/ext/automod/triggers/discord_automod/timed_out.py new file mode 100644 index 0000000..eec723a --- /dev/null +++ b/commanderbot/ext/automod/triggers/discord_automod/timed_out.py @@ -0,0 +1,36 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import DiscordAutomodRulesGuard, RolesGuard +from commanderbot.ext.automod.trigger import AutomodTrigger +from commanderbot.ext.automod.types import AutomodRuleRef + +__all__ = ("DiscordAutomodTimedOut",) + + +class DiscordAutomodTimedOut(AutomodTrigger): + """ + Triggers when Discord's automod times out a member. + """ + + type: Literal["discord_automod_timed_out"] = "discord_automod_timed_out" + event_types = (events.DiscordAutomodTimedOut,) + + rules: Optional[DiscordAutomodRulesGuard] = None + """The Discord automod rules to match against. If empty, all rules will match.""" + + roles: Optional[RolesGuard] = None + """The roles of the timed out user to match against. If empty, all roles will match.""" + + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance(event, events.DiscordAutomodTimedOut) + + if self.rules and self.rules.ignore(event.rule_id): + return True + + if self.roles and self.roles.ignore(event.member): + return True + + return False diff --git a/commanderbot/ext/automod/triggers/member_gained_flags.py b/commanderbot/ext/automod/triggers/member_gained_flags.py new file mode 100644 index 0000000..d0a9619 --- /dev/null +++ b/commanderbot/ext/automod/triggers/member_gained_flags.py @@ -0,0 +1,43 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import FlagsGuard, RolesGuard +from commanderbot.ext.automod.types import AutomodRuleRef +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("MemberGainedFlags",) + + +class MemberGainedFlags(AutomodTrigger): + """ + Triggers when a `discord.Member` gains certain flags. + """ + + type: Literal["member_gained_flags"] = "member_gained_flags" + event_types = (events.MemberUpdated,) + + flags: Optional[FlagsGuard] = None + """The flags to match against. If empty, all flags will match.""" + + roles: Optional[RolesGuard] = None + """The roles to match against. If empty, all roles will match.""" + + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance(event, events.MemberUpdated) + + if self.flags and not self.flags.gained_any(event.before, event.after): + return True + + before_flags = event.before.flags.value + after_flags = event.after.flags.value + before_public_flags = event.before.public_flags.value + after_public_flags = event.after.public_flags.value + if before_flags >= after_flags and before_public_flags >= after_public_flags: + return True + + if self.roles and self.roles.ignore(event.member): + return True + + return False diff --git a/commanderbot/ext/automod/triggers/member_joined.py b/commanderbot/ext/automod/triggers/member_joined.py index dc85065..9dfa6ab 100644 --- a/commanderbot/ext/automod/triggers/member_joined.py +++ b/commanderbot/ext/automod/triggers/member_joined.py @@ -1,23 +1,15 @@ -from dataclasses import dataclass +from typing import Literal from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.trigger import AutomodTrigger +__all__ = ("MemberJoined",) -@dataclass -class MemberJoined(AutomodTriggerBase): - """ - Fires when an `on_member_join` event is received. - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_member_join +class MemberJoined(AutomodTrigger): + """ + Triggers when a member joins the guild. """ + type: Literal["member_joined"] = "member_joined" event_types = (events.MemberJoined,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return MemberJoined.from_data(data) diff --git a/commanderbot/ext/automod/triggers/member_left.py b/commanderbot/ext/automod/triggers/member_left.py index 6bd56ba..061afd2 100644 --- a/commanderbot/ext/automod/triggers/member_left.py +++ b/commanderbot/ext/automod/triggers/member_left.py @@ -1,23 +1,15 @@ -from dataclasses import dataclass +from typing import Literal from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.trigger import AutomodTrigger +__all__ = ("MemberLeft",) -@dataclass -class MemberLeft(AutomodTriggerBase): - """ - Fires when an `on_member_remove` event is received. - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_member_remove +class MemberLeft(AutomodTrigger): + """ + Triggers when a member leaves the guild. """ + type: Literal["member_left"] = "member_left" event_types = (events.MemberLeft,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return MemberLeft.from_data(data) diff --git a/commanderbot/ext/automod/triggers/member_lost_flags.py b/commanderbot/ext/automod/triggers/member_lost_flags.py new file mode 100644 index 0000000..110de1d --- /dev/null +++ b/commanderbot/ext/automod/triggers/member_lost_flags.py @@ -0,0 +1,43 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import FlagsGuard, RolesGuard +from commanderbot.ext.automod.types import AutomodRuleRef +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("MemberLostFlags",) + + +class MemberLostFlags(AutomodTrigger): + """ + Triggers when a `discord.Member` loses certain flags. + """ + + type: Literal["member_lost_flags"] = "member_lost_flags" + event_types = (events.MemberUpdated,) + + flags: Optional[FlagsGuard] = None + """The flags to match against. If empty, all flags will match.""" + + roles: Optional[RolesGuard] = None + """The roles to match against. If empty, all roles will match.""" + + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance(event, events.MemberUpdated) + + if self.flags and not self.flags.lost_any(event.before, event.after): + return True + + before_flags = event.before.flags.value + after_flags = event.after.flags.value + before_public_flags = event.before.public_flags.value + after_public_flags = event.after.public_flags.value + if before_flags <= after_flags and before_public_flags <= after_public_flags: + return True + + if self.roles and self.roles.ignore(event.member): + return True + + return False diff --git a/commanderbot/ext/automod/triggers/member_typing.py b/commanderbot/ext/automod/triggers/member_typing.py index 563092f..0d628d3 100644 --- a/commanderbot/ext/automod/triggers/member_typing.py +++ b/commanderbot/ext/automod/triggers/member_typing.py @@ -1,72 +1,53 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_trigger import AutomodTrigger, AutomodTriggerBase -from commanderbot.lib import JsonObject -from commanderbot.lib.guards import ChannelsGuard, ChannelTypesGuard, RolesGuard +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, + RolesGuard, +) +from commanderbot.ext.automod.types import AutomodRuleRef +from commanderbot.ext.automod.trigger import AutomodTrigger -ST = TypeVar("ST") +__all__ = ("MemberTyping",) -@dataclass -class MemberTyping(AutomodTriggerBase): +class MemberTyping(AutomodTrigger): """ - Fires when an `on_typing` event is received. - - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_typing - - Attributes - ---------- - channel_types - The channel types to match against. If empty, all channel types will match. - channels - The channels to match against. If empty, all channels will match. - roles - The roles to match against. If empty, all roles will match. + Triggers when a user is typing. """ + type: Literal["member_typing"] = "member_typing" event_types = (events.MemberTyping,) + categories: Optional[CategoriesGuard] = None + """The categories to match against. If empty, all categories will match.""" + channel_types: Optional[ChannelTypesGuard] = None + """The channel types to match against. If empty, all channel types will match.""" + channels: Optional[ChannelsGuard] = None - roles: Optional[RolesGuard] = None + """The channels to match against. If empty, all channels will match.""" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - channel_types = ChannelTypesGuard.from_field_optional(data, "channel_types") - channels = ChannelsGuard.from_field_optional(data, "channels") - roles = RolesGuard.from_field_optional(data, "roles") - return cls( - description=data.get("description"), - channel_types=channel_types, - channels=channels, - roles=roles, - ) + roles: Optional[RolesGuard] = None + """The roles to match against. If empty, all roles will match.""" - def ignore_by_channel_type(self, event: AutomodEvent) -> bool: - if self.channel_types is None: - return False - return self.channel_types.ignore(event.channel) + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance(event, events.MemberTyping) - def ignore_by_channel(self, event: AutomodEvent) -> bool: - if self.channels is None: - return False - return self.channels.ignore(event.channel) + if self.categories and self.categories.ignore(event.category): + return True - def ignore_by_role(self, event: AutomodEvent) -> bool: - if self.roles is None: - return False - return self.roles.ignore(event.member) + if self.channel_types and self.channel_types.ignore(event.channel): + return True - def ignore(self, event: AutomodEvent) -> bool: - return ( - self.ignore_by_channel_type(event) - or self.ignore_by_channel(event) - or self.ignore_by_role(event) - ) + if self.channels and self.channels.ignore(event.channel): + return True + if self.roles and self.roles.ignore(event.member): + return True -def create_trigger(data: JsonObject) -> AutomodTrigger: - return MemberTyping.from_data(data) + return False diff --git a/commanderbot/ext/automod/triggers/member_updated.py b/commanderbot/ext/automod/triggers/member_updated.py deleted file mode 100644 index c4f22ad..0000000 --- a/commanderbot/ext/automod/triggers/member_updated.py +++ /dev/null @@ -1,58 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar - -from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) -from commanderbot.lib import JsonObject -from commanderbot.lib.guards import RolesGuard - -ST = TypeVar("ST") - - -@dataclass -class MemberUpdated(AutomodTriggerBase): - """ - Fires when an `on_typing` event is received. - - This occurs when one or more of the following things change: - - status - - activity - - nickname - - roles - - pending - - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_typing - - Attributes - ---------- - roles - The roles to match against. If empty, all roles will match. - """ - - event_types = (events.MemberUpdated,) - - roles: Optional[RolesGuard] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - roles = RolesGuard.from_field_optional(data, "roles") - return cls( - description=data.get("description"), - roles=roles, - ) - - def ignore_by_role(self, event: AutomodEvent) -> bool: - if self.roles is None: - return False - return self.roles.ignore(event.member) - - def ignore(self, event: AutomodEvent) -> bool: - return self.ignore_by_role(event) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return MemberUpdated.from_data(data) diff --git a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py index 7a2c413..1926de2 100644 --- a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py +++ b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py @@ -1,158 +1,156 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar, cast +from typing import Literal, Optional, override -from discord import Member, Message, Role, TextChannel, Thread, User +from discord import Member, Role, User from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_trigger import AutomodTrigger, AutomodTriggerBase -from commanderbot.lib import JsonObject -from commanderbot.lib.guards import ChannelsGuard, RolesGuard +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, + RolesGuard, +) +from commanderbot.ext.automod.types import AutomodRuleRef +from commanderbot.ext.automod.trigger import AutomodTrigger -ST = TypeVar("ST") +__all__ = ("MentionsRemovedFromMessage",) -@dataclass -class MentionsRemovedFromMessage(AutomodTriggerBase): +class MentionsRemovedFromMessage(AutomodTrigger): """ - Fires when mentions are removed from a message. - - This triggers listens for `on_message_edit` and `on_message_delete` events, and uses - different logic based on which type of event was received. + Triggers when a message with mentions is edited or deleted. This can be used for detecting suspected ghost pings. - - Attributes - ---------- - channels - The channels to match against. If empty, all channels will match. - author_roles - The author roles to match against. If empty, all roles will match. - victom_roles - The victom roles to match against. If empty, all roles will match. """ + type: Literal["mentions_removed_from_message"] = "mentions_removed_from_message" event_types = (events.MessageEdited, events.MessageDeleted) + categories: Optional[CategoriesGuard] = None + """The categories to match against. If empty, all categories will match.""" + + channel_types: Optional[ChannelTypesGuard] = None + """The channel types to match against. If empty, all channel types will match.""" + channels: Optional[ChannelsGuard] = None + """The channels to match against. If empty, all channels will match.""" + author_roles: Optional[RolesGuard] = None + """The author roles to match against. If empty, all roles will match.""" + victim_roles: Optional[RolesGuard] = None + """The victim roles to match against. If empty, all roles will match.""" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - channels = ChannelsGuard.from_field_optional(data, "channels") - author_roles = RolesGuard.from_field_optional(data, "author_roles") - victim_roles = RolesGuard.from_field_optional(data, "victim_roles") - return cls( - description=data.get("description"), - channels=channels, - author_roles=author_roles, - victim_roles=victim_roles, - ) + victim_user_roles: Optional[RolesGuard] = None + """The roles of the victim users to match against. If empty, all roles will match.""" - def ignore(self, event: AutomodEvent) -> bool: - channel = cast(TextChannel | Thread, event.channel) - author = cast(Member, event.author) + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance(event, (events.MessageEdited, events.MessageDeleted)) - # Make sure we care about the channel. - if self.channels and self.channels.ignore(channel): + if self.categories and self.categories.ignore(event.category): return True - # Make sure we care about the author. - if self.author_roles and self.author_roles.ignore(author): + if self.channel_types and self.channel_types.ignore(event.channel): + return True + + if self.channels and self.channels.ignore(event.channel): + return True + + if self.author_roles and self.author_roles.ignore(event.author): return True - # We want to preserve the order of mentions. removed_user_mentions: set[User | Member] = set() removed_role_mentions: set[Role] = set() - - # If the message was edited, check for mentions that were present in the message - # only prior to editing. - if isinstance(event, events.MessageEdited): - # Check for removed user mentions. - after_user_mention_ids = set(user.id for user in event._after.mentions) - for mentioned_user in event._before.mentions: - if mentioned_user.id not in after_user_mention_ids: - removed_user_mentions.add(mentioned_user) - - # Check for removed role mentions. - after_role_mention_ids = set(role.id for role in event._after.role_mentions) - for mentioned_role in event._before.role_mentions: - if mentioned_role.id not in after_role_mention_ids: - removed_role_mentions.add(mentioned_role) - - # If the message was deleted, check for any mentions at all. - elif isinstance(event, events.MessageDeleted): - # Check for removed user mentions. - deleted_user_mentions = event.message.mentions - for user in deleted_user_mentions: - removed_user_mentions.add(user) - - # Check for removed role mentions. - deleted_role_mentions = event.message.role_mentions - for role in deleted_role_mentions: - removed_role_mentions.add(role) - - # Check if the message was a reply. - if reference := event.message.reference: - if isinstance(resolved := reference.resolved, Message): - removed_user_mentions.add(resolved.author) - - # Remove any excluded mentions. - if self.victim_roles: - removed_user_mentions = set( + match event: + # If the message was edited, check for mentions that were removed + case events.MessageEdited(): + # Check for removed user mentions + after_user_mention_ids = {user.id for user in event.after.mentions} + for before_user_mention in event.before.mentions: + if before_user_mention.id not in after_user_mention_ids: + removed_user_mentions.add(before_user_mention) + + # Check for removed role mentions + after_role_mention_ids = {role.id for role in event.after.role_mentions} + for before_role_mention in event.before.role_mentions: + if before_role_mention.id not in after_role_mention_ids: + removed_role_mentions.add(before_role_mention) + + # If the message was deleted, get any mentions at all + case events.MessageDeleted(): + # Get removed user mentions + for user_mention in event.message.mentions: + removed_user_mentions.add(user_mention) + + # Get removed role mentions + for role_mention in event.message.role_mentions: + removed_role_mentions.add(role_mention) + + # Remove the author's own mention + if event.author in removed_user_mentions: + removed_user_mentions.remove(event.author) + + # Remove any excluded mentions + if self.victim_user_roles: + removed_user_mentions = { user for user in removed_user_mentions - if self.victim_roles.member_matches(user) - ) - removed_role_mentions = set( + if self.victim_user_roles.member_matches(user) + } + + if self.victim_roles: + removed_role_mentions = { role for role in removed_role_mentions if self.victim_roles.role_matches(role) - ) + } + + # Ignore event if we have no mentions at all + removed_mentions: list[Role | User | Member] = [ + *removed_role_mentions, + *removed_user_mentions, + ] + if not removed_mentions: + return True - # Remove the author's own mention, if any. - if author in removed_user_mentions: - removed_user_mentions.remove(author) + # Add removed mentions fields to event + event.set_metadata( + rule.name, + "removed_mentions", + " ".join((m.mention for m in removed_mentions)), + ) + event.set_metadata( + rule.name, + "removed_mention_names", + " ".join((f"`{m}`" for m in removed_mentions)), + ) - # Build removed user mention fields, if any. + # Add removed user mentions fields to event if removed_user_mentions: event.set_metadata( + rule.name, "removed_user_mentions", - " ".join(user.mention for user in removed_user_mentions), + " ".join((m.mention for m in removed_user_mentions)), ) event.set_metadata( + rule.name, "removed_user_mention_names", - " ".join(f"`{user}`" for user in removed_user_mentions), + " ".join((f"`{m}`" for m in removed_user_mentions)), ) - # Build removed role mention fields, if any. + # Add removed role mentions fields to event if removed_role_mentions: event.set_metadata( + rule.name, "removed_role_mentions", - " ".join(role.mention for role in removed_role_mentions), + " ".join((m.mention for m in removed_role_mentions)), ) event.set_metadata( + rule.name, "removed_role_mention_names", - " ".join(f"`{role}`" for role in removed_role_mentions), + " ".join((f"`{m}`" for m in removed_role_mentions)), ) - # If there were removed mentions of any kind, build fields and don't ignore. - removed_mentions = list((*removed_user_mentions, *removed_role_mentions)) - if removed_mentions: - event.set_metadata( - "removed_mentions", - " ".join(thing.mention for thing in removed_mentions), - ) - event.set_metadata( - "removed_mention_names", - " ".join(f"`{thing}`" for thing in removed_mentions), - ) - return False - - # If there were no removed mentions, ignore. - return True - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return MentionsRemovedFromMessage.from_data(data) + # If we got this far, we had removed mentions + return False diff --git a/commanderbot/ext/automod/triggers/message.py b/commanderbot/ext/automod/triggers/message.py index 151493f..beeaabd 100644 --- a/commanderbot/ext/automod/triggers/message.py +++ b/commanderbot/ext/automod/triggers/message.py @@ -1,89 +1,55 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_trigger import AutomodTrigger, AutomodTriggerBase -from commanderbot.lib import JsonObject -from commanderbot.lib.guards import ChannelsGuard, ChannelTypesGuard, RolesGuard +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, + RolesGuard, +) +from commanderbot.ext.automod.types import AutomodRuleRef +from commanderbot.ext.automod.trigger import AutomodTrigger -ST = TypeVar("ST") +__all__ = ("Message",) -@dataclass -class Message(AutomodTriggerBase): +class Message(AutomodTrigger): """ - Fires when an `on_message` or `on_message_edit` event is received. - - See: - - https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_message - - https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_message_edit - - Attributes - ---------- - content - The exact message content to match. If provided, the message must match one of - these strings exactly. For more complex/flexible matching logic, consider using - the `message_content_contains` or `message_content_matches` conditions. - channel_types - The channel types to match against. If empty, all channel types will match. - channels - The channels to match against. If empty, all channels will match. - author_roles - The author roles to match against. If empty, all roles will match. + Triggers when a message is sent or edited. """ + type: Literal["message"] = "message" event_types = (events.MessageSent, events.MessageEdited) - content: Optional[list[str]] = None + categories: Optional[CategoriesGuard] = None + """The categories to match against. If empty, all categories will match.""" + channel_types: Optional[ChannelTypesGuard] = None + """The channel types to match against. If empty, all channel types will match.""" + channels: Optional[ChannelsGuard] = None + """The channels to match against. If empty, all channels will match.""" + author_roles: Optional[RolesGuard] = None + """The author roles to match against. If empty, all roles will match.""" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - content = data.get("content") - if isinstance(content, str): - content = [content] - channel_types = ChannelTypesGuard.from_field_optional(data, "channel_types") - channels = ChannelsGuard.from_field_optional(data, "channels") - author_roles = RolesGuard.from_field_optional(data, "author_roles") - return cls( - description=data.get("description"), - content=content, - channel_types=channel_types, - channels=channels, - author_roles=author_roles, + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance( + event, (events.MessageSent, events.MessageEdited, events.MessageDeleted) ) - def ignore_by_content(self, event: AutomodEvent) -> bool: - if (self.content is None) or (event.message is None): - return False - return event.message.content not in self.content - - def ignore_by_channel_type(self, event: AutomodEvent) -> bool: - if self.channel_types is None: - return False - return self.channel_types.ignore(event.channel) + if self.categories and self.categories.ignore(event.category): + return True - def ignore_by_channel(self, event: AutomodEvent) -> bool: - if self.channels is None: - return False - return self.channels.ignore(event.channel) + if self.channel_types and self.channel_types.ignore(event.channel): + return True - def ignore_by_author_role(self, event: AutomodEvent) -> bool: - if self.author_roles is None: - return False - return self.author_roles.ignore(event.author) - - def ignore(self, event: AutomodEvent) -> bool: - return ( - self.ignore_by_content(event) - or self.ignore_by_channel_type(event) - or self.ignore_by_channel(event) - or self.ignore_by_author_role(event) - ) + if self.channels and self.channels.ignore(event.channel): + return True + if self.author_roles and self.author_roles.ignore(event.author): + return True -def create_trigger(data: JsonObject) -> AutomodTrigger: - return Message.from_data(data) + return False diff --git a/commanderbot/ext/automod/triggers/message_deleted.py b/commanderbot/ext/automod/triggers/message_deleted.py index 540979f..8db709f 100644 --- a/commanderbot/ext/automod/triggers/message_deleted.py +++ b/commanderbot/ext/automod/triggers/message_deleted.py @@ -1,21 +1,15 @@ -from dataclasses import dataclass +from typing import Literal from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger from commanderbot.ext.automod.triggers.message import Message -from commanderbot.lib import JsonObject + +__all__ = ("MessageDeleted",) -@dataclass class MessageDeleted(Message): """ - Fires when an `on_message_delete` event is received. - - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_message_delete + Triggers when a message is deleted. """ + type: Literal["message_deleted"] = "message_deleted" event_types = (events.MessageDeleted,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return MessageDeleted.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_edited.py b/commanderbot/ext/automod/triggers/message_edited.py index 36a176c..c186855 100644 --- a/commanderbot/ext/automod/triggers/message_edited.py +++ b/commanderbot/ext/automod/triggers/message_edited.py @@ -1,21 +1,15 @@ -from dataclasses import dataclass +from typing import Literal from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger from commanderbot.ext.automod.triggers.message import Message -from commanderbot.lib import JsonObject + +__all__ = ("MessageEdited",) -@dataclass class MessageEdited(Message): """ - Fires when an `on_message_edit` event is received. - - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_message_edit + Triggers when a message is edited. """ + type: Literal["message_edited"] = "message_edited" event_types = (events.MessageEdited,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return MessageEdited.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_sent.py b/commanderbot/ext/automod/triggers/message_sent.py index d216786..26a8f01 100644 --- a/commanderbot/ext/automod/triggers/message_sent.py +++ b/commanderbot/ext/automod/triggers/message_sent.py @@ -1,21 +1,15 @@ -from dataclasses import dataclass +from typing import Literal from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger from commanderbot.ext.automod.triggers.message import Message -from commanderbot.lib import JsonObject + +__all__ = ("MessageSent",) -@dataclass class MessageSent(Message): """ - Fires when an `on_message` event is received. - - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_message + Triggers when a message is sent. """ + type: Literal["message_sent"] = "message_sent" event_types = (events.MessageSent,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return MessageSent.from_data(data) diff --git a/commanderbot/ext/automod/triggers/reaction.py b/commanderbot/ext/automod/triggers/reaction.py index 7f46b0e..6209291 100644 --- a/commanderbot/ext/automod/triggers/reaction.py +++ b/commanderbot/ext/automod/triggers/reaction.py @@ -1,101 +1,66 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_trigger import AutomodTrigger, AutomodTriggerBase -from commanderbot.lib import JsonObject -from commanderbot.lib.guards import ( +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import ( + CategoriesGuard, ChannelsGuard, ChannelTypesGuard, ReactionsGuard, RolesGuard, ) +from commanderbot.ext.automod.types import AutomodRuleRef +from commanderbot.ext.automod.trigger import AutomodTrigger -ST = TypeVar("ST") +__all__ = ("Reaction",) -@dataclass -class Reaction(AutomodTriggerBase): +class Reaction(AutomodTrigger): """ - Fires when an `on_reaction_add` or `on_reaction_remove` event is received. - - See: - - https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_reaction_add - - https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_reaction_remove - - Attributes - ---------- - reactions - The reactions to match against. If empty, all reactions will match. - channel_types - The channel types to match against. If empty, all channel types will match. - channels - The channels to match against. If empty, all channels will match. - author_roles - The author roles to match against. If empty, all roles will match. - actor_roles - The actor roles to match against. If empty, all roles will match. + Triggers when a member reacts to a message. """ + type: Literal["reaction"] = "reaction" event_types = (events.ReactionAdded, events.ReactionRemoved) reactions: Optional[ReactionsGuard] = None + """The reactions to match against. If empty, all reactions will match.""" + + categories: Optional[CategoriesGuard] = None + """The categories to match against. If empty, all categories will match.""" + channel_types: Optional[ChannelTypesGuard] = None + """The channel types to match against. If empty, all channel types will match.""" + channels: Optional[ChannelsGuard] = None + """The channels to match against. If empty, all channels will match.""" + author_roles: Optional[RolesGuard] = None + """The author roles to match against. If empty, all roles will match.""" + actor_roles: Optional[RolesGuard] = None + """The actor roles to match against. If empty, all roles will match.""" + + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance(event, (events.ReactionAdded, events.ReactionRemoved)) + + if self.reactions and self.reactions.ignore(event.reaction): + return True + + if self.categories and self.categories.ignore(event.category): + return True + + if self.channel_types and self.channel_types.ignore(event.channel): + return True + + if self.channels and self.channels.ignore(event.channel): + return True + + if self.author_roles and self.author_roles.ignore(event.author): + return True + + if self.actor_roles and self.actor_roles.ignore(event.actor): + return True - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - reactions = ReactionsGuard.from_field_optional(data, "reactions") - channel_types = ChannelTypesGuard.from_field_optional(data, "channel_types") - channels = ChannelsGuard.from_field_optional(data, "channels") - author_roles = RolesGuard.from_field_optional(data, "author_roles") - actor_roles = RolesGuard.from_field_optional(data, "actor_roles") - return cls( - reactions=reactions, - description=data.get("description"), - channel_types=channel_types, - channels=channels, - author_roles=author_roles, - actor_roles=actor_roles, - ) - - def ignore_by_reaction(self, event: AutomodEvent) -> bool: - if self.reactions is None: - return False - return self.reactions.ignore(event.reaction) - - def ignore_by_channel_type(self, event: AutomodEvent) -> bool: - if self.channel_types is None: - return False - return self.channel_types.ignore(event.channel) - - def ignore_by_channel(self, event: AutomodEvent) -> bool: - if self.channels is None: - return False - return self.channels.ignore(event.channel) - - def ignore_by_author_role(self, event: AutomodEvent) -> bool: - if self.author_roles is None: - return False - return self.author_roles.ignore(event.author) - - def ignore_by_actor_role(self, event: AutomodEvent) -> bool: - if self.actor_roles is None: - return False - return self.actor_roles.ignore(event.actor) - - def ignore(self, event: AutomodEvent) -> bool: - return ( - self.ignore_by_reaction(event) - or self.ignore_by_channel_type(event) - or self.ignore_by_channel(event) - or self.ignore_by_author_role(event) - or self.ignore_by_actor_role(event) - ) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return Reaction.from_data(data) + return False diff --git a/commanderbot/ext/automod/triggers/reaction_added.py b/commanderbot/ext/automod/triggers/reaction_added.py index d101293..0fccd61 100644 --- a/commanderbot/ext/automod/triggers/reaction_added.py +++ b/commanderbot/ext/automod/triggers/reaction_added.py @@ -1,34 +1,15 @@ -from dataclasses import dataclass +from typing import Literal from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger from commanderbot.ext.automod.triggers.reaction import Reaction -from commanderbot.lib import JsonObject + +__all__ = ("ReactionAdded",) -@dataclass class ReactionAdded(Reaction): """ - Fires when an `on_reaction_add` event is received. - - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_reaction_add - - Attributes - ---------- - reactions - The reactions to match against. If empty, all reactions will match. - channel_types - The channel types to match against. If empty, all channel types will match. - channels - The channels to match against. If empty, all channels will match. - author_roles - The author roles to match against. If empty, all roles will match. - actor_roles - The actor roles to match against. If empty, all roles will match. + Triggers when a reaction is added. """ + type: Literal["reaction_added"] = "reaction_added" event_types = (events.ReactionAdded,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return ReactionAdded.from_data(data) diff --git a/commanderbot/ext/automod/triggers/reaction_removed.py b/commanderbot/ext/automod/triggers/reaction_removed.py index 5b97473..af19ae9 100644 --- a/commanderbot/ext/automod/triggers/reaction_removed.py +++ b/commanderbot/ext/automod/triggers/reaction_removed.py @@ -1,34 +1,15 @@ -from dataclasses import dataclass +from typing import Literal from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger from commanderbot.ext.automod.triggers.reaction import Reaction -from commanderbot.lib import JsonObject + +__all__ = ("ReactionRemoved",) -@dataclass class ReactionRemoved(Reaction): """ - Fires when an `on_reaction_remove` event is received. - - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_reaction_remove - - Attributes - ---------- - reactions - The reactions to match against. If empty, all reactions will match. - channel_types - The channel types to match against. If empty, all channel types will match. - channels - The channels to match against. If empty, all channels will match. - author_roles - The author roles to match against. If empty, all roles will match. - actor_roles - The actor roles to match against. If empty, all roles will match. + Triggers when a reaction is removed. """ + type: Literal["reaction_removed"] = "reaction_removed" event_types = (events.ReactionRemoved,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return ReactionRemoved.from_data(data) diff --git a/commanderbot/ext/automod/triggers/thread_created.py b/commanderbot/ext/automod/triggers/thread_created.py index 2ee85c0..655de0a 100644 --- a/commanderbot/ext/automod/triggers/thread_created.py +++ b/commanderbot/ext/automod/triggers/thread_created.py @@ -1,29 +1,51 @@ -from dataclasses import dataclass -from typing import TypeVar +from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger -from commanderbot.ext.automod.triggers.abc.thread_base import ThreadBase -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, +) +from commanderbot.ext.automod.types import AutomodRuleRef +from commanderbot.ext.automod.trigger import AutomodTrigger -ST = TypeVar("ST") +__all__ = ("ThreadCreated",) -@dataclass -class ThreadCreated(ThreadBase): +class ThreadCreated(AutomodTrigger): """ - Fires when an `on_thread_create` event is received. - - See: https://discordpy.readthedocs.io/en/stable/api.html#discord.on_thread_create - - Attributes - ---------- - parent_channels - The parent channels to match against. If empty, all channels will match. + Triggers when a thread is created. """ + type: Literal["thread_created"] = "thread_created" event_types = (events.ThreadCreated,) + parent_categories: Optional[CategoriesGuard] = None + """The parent categories to match against. If empty, all parent categories will match.""" + + parent_channel_types: Optional[ChannelTypesGuard] = None + """The parent channel types to match against. If empty, all parent channel types will match.""" + + parent_channels: Optional[ChannelsGuard] = None + """The parent channels to match against. If empty, all channels will match.""" + + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance(event, events.ThreadCreated) + + # The thread must have a parent + parent = event.thread.parent + if not parent: + return True + + if self.parent_categories and self.parent_categories.ignore(parent.category): + return True + + if self.parent_channel_types and self.parent_channel_types.ignore(parent): + return True + + if self.parent_channels and self.parent_channels.ignore(parent): + return True -def create_trigger(data: JsonObject) -> AutomodTrigger: - return ThreadCreated.from_data(data) + return False diff --git a/commanderbot/ext/automod/triggers/thread_deleted.py b/commanderbot/ext/automod/triggers/thread_deleted.py new file mode 100644 index 0000000..aab6ffe --- /dev/null +++ b/commanderbot/ext/automod/triggers/thread_deleted.py @@ -0,0 +1,51 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, +) +from commanderbot.ext.automod.types import AutomodRuleRef +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("ThreadDeleted",) + + +class ThreadDeleted(AutomodTrigger): + """ + Triggers when a thread is deleted. + """ + + type: Literal["thread_deleted"] = "thread_deleted" + event_types = (events.ThreadDeleted,) + + parent_categories: Optional[CategoriesGuard] = None + """The parent categories to match against. If empty, all parent categories will match.""" + + parent_channel_types: Optional[ChannelTypesGuard] = None + """The parent channel types to match against. If empty, all parent channel types will match.""" + + parent_channels: Optional[ChannelsGuard] = None + """The parent channels to match against. If empty, all channels will match.""" + + @override + async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + assert isinstance(event, events.ThreadDeleted) + + # The thread must have a parent + parent = event.thread.parent + if not parent: + return True + + if self.parent_categories and self.parent_categories.ignore(parent.category): + return True + + if self.parent_channel_types and self.parent_channel_types.ignore(parent): + return True + + if self.parent_channels and self.parent_channels.ignore(parent): + return True + + return False diff --git a/commanderbot/ext/automod/triggers/thread_updated.py b/commanderbot/ext/automod/triggers/thread_updated.py deleted file mode 100644 index 4791a9e..0000000 --- a/commanderbot/ext/automod/triggers/thread_updated.py +++ /dev/null @@ -1,29 +0,0 @@ -from dataclasses import dataclass -from typing import TypeVar - -from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger -from commanderbot.ext.automod.triggers.abc.thread_base import ThreadBase -from commanderbot.lib import JsonObject - -ST = TypeVar("ST") - - -@dataclass -class ThreadUpdated(ThreadBase): - """ - Fires when an `on_thread_update` event is received. - - See: https://discordpy.readthedocs.io/en/stable/api.html#discord.on_thread_update - - Attributes - ---------- - parent_channels - The parent channels to match against. If empty, all channels will match. - """ - - event_types = (events.ThreadUpdated,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return ThreadUpdated.from_data(data) diff --git a/commanderbot/ext/automod/triggers/user_banned.py b/commanderbot/ext/automod/triggers/user_banned.py index edfe12c..740c54e 100644 --- a/commanderbot/ext/automod/triggers/user_banned.py +++ b/commanderbot/ext/automod/triggers/user_banned.py @@ -1,23 +1,15 @@ -from dataclasses import dataclass +from typing import Literal from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.trigger import AutomodTrigger +__all__ = ("UserBanned",) -@dataclass -class UserBanned(AutomodTriggerBase): - """ - Fires when an `on_member_ban` event is received. - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_member_ban +class UserBanned(AutomodTrigger): + """ + Triggers when a user is banned. """ + type: Literal["user_banned"] = "user_banned" event_types = (events.UserBanned,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return UserBanned.from_data(data) diff --git a/commanderbot/ext/automod/triggers/user_unbanned.py b/commanderbot/ext/automod/triggers/user_unbanned.py index 6dbe94f..39f48ac 100644 --- a/commanderbot/ext/automod/triggers/user_unbanned.py +++ b/commanderbot/ext/automod/triggers/user_unbanned.py @@ -1,23 +1,15 @@ -from dataclasses import dataclass +from typing import Literal from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.trigger import AutomodTrigger +__all__ = ("UserUnbanned",) -@dataclass -class UserUnbanned(AutomodTriggerBase): - """ - Fires when an `on_member_unban` event is received. - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_member_unban +class UserUnbanned(AutomodTrigger): + """ + Triggers when a user is unbanned. """ + type: Literal["user_unbanned"] = "user_unbanned" event_types = (events.UserUnbanned,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return UserUnbanned.from_data(data) diff --git a/commanderbot/ext/automod/triggers/user_updated.py b/commanderbot/ext/automod/triggers/user_updated.py deleted file mode 100644 index 555f733..0000000 --- a/commanderbot/ext/automod/triggers/user_updated.py +++ /dev/null @@ -1,28 +0,0 @@ -from dataclasses import dataclass - -from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) -from commanderbot.lib import JsonObject - - -@dataclass -class UserUpdated(AutomodTriggerBase): - """ - Fires when an `on_user_update` event is received. - - This occurs when one or more of the following things change: - - avatar - - username - - discriminator - - See: https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_user_update - """ - - event_types = (events.UserUpdated,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return UserUpdated.from_data(data) From bc43c5eedc7efb0d0637adeb458476bd3a8e5010 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 2 Jul 2026 01:51:30 -0700 Subject: [PATCH 025/158] Events no longer contain metadata --- commanderbot/ext/automod/event/event_base.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/commanderbot/ext/automod/event/event_base.py b/commanderbot/ext/automod/event/event_base.py index f6d3187..75c87d4 100644 --- a/commanderbot/ext/automod/event/event_base.py +++ b/commanderbot/ext/automod/event/event_base.py @@ -1,8 +1,7 @@ from abc import ABC -from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import dataclass from logging import Logger -from typing import Any, Optional +from typing import Optional from discord import CategoryChannel, Member, Message, Reaction, Thread, User from discord.ext.commands import Bot @@ -24,10 +23,6 @@ class AutomodEvent(ABC): bot: Bot log: Logger - _metadata: defaultdict[str, dict] = field( - init=False, default_factory=lambda: defaultdict(dict) - ) - @property def channel(self) -> Optional[GuildChannel | Thread]: """Return the relevant channel, if any.""" @@ -74,11 +69,3 @@ def member(self) -> Optional[Member]: def user(self) -> Optional[User]: """Return the user-in-question, if any.""" return None - - def set_metadata(self, namespace: str, name: str, value: Any): - """Attach metadata to the event.""" - self._metadata[namespace][name] = value - - def remove_metadata(self, namespace: str, name: str): - """Remove metadata from the event.""" - del self._metadata[namespace][name] From 5659443f8a5470de31a6fe639e672feb5e73d032 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 2 Jul 2026 02:43:25 -0700 Subject: [PATCH 026/158] Added a context dataclass that's passed to triggers, conditions, and actions --- commanderbot/ext/automod/automod_context.py | 31 +++++++++++++++++ commanderbot/ext/automod/rule/rule.py | 22 +++++++++--- .../ext/automod/trigger/trigger_base.py | 10 +++--- .../blocked_member_interactions.py | 11 +++--- .../discord_automod/blocked_message.py | 17 +++++----- .../triggers/discord_automod/timed_out.py | 11 +++--- .../automod/triggers/member_gained_flags.py | 6 ++-- .../ext/automod/triggers/member_lost_flags.py | 6 ++-- .../ext/automod/triggers/member_typing.py | 15 ++++---- .../triggers/mentions_removed_from_message.py | 34 +++++++------------ commanderbot/ext/automod/triggers/message.py | 15 ++++---- commanderbot/ext/automod/triggers/reaction.py | 19 +++++------ .../ext/automod/triggers/thread_created.py | 9 +++-- .../ext/automod/triggers/thread_deleted.py | 9 +++-- 14 files changed, 121 insertions(+), 94 deletions(-) create mode 100644 commanderbot/ext/automod/automod_context.py diff --git a/commanderbot/ext/automod/automod_context.py b/commanderbot/ext/automod/automod_context.py new file mode 100644 index 0000000..a7e6c4c --- /dev/null +++ b/commanderbot/ext/automod/automod_context.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field +from typing import Any + +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.types import AutomodRuleRef + +__all__ = ("AutomodContext",) + + +@dataclass +class AutomodContext: + """ + The execution context that's given to all triggers, conditions, and actions. + """ + + rule: AutomodRuleRef + """The rule that's currently running.""" + + event: AutomodEvent + """The event the rule is processing.""" + + _metadata: dict[str, Any] = field(init=False, default_factory=dict) + """Any additional data attached to the execution context.""" + + def set_metadata(self, key: str, value: Any): + """Add metadata to the execution context""" + self._metadata[key] = value + + def remove_metadata(self, key: str): + """Remove metadata from the execution context""" + del self._metadata[key] diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index e2999e0..b455a15 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, ConfigDict from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.trigger import AutomodTriggerCollection __all__ = ("AutomodRule",) @@ -27,17 +28,28 @@ class AutomodRule(BaseModel): triggers: AutomodTriggerCollection """A list of triggers that cause the rule to run.""" - async def _poll_triggers(self, event: AutomodEvent) -> bool: + async def _poll_triggers(self, context: AutomodContext) -> bool: for trigger in self.triggers: - if await trigger.poll(self, event): + if await trigger.poll(context): return True return False + async def _check_conditions(self, context: AutomodContext) -> bool: + return True + + async def _apply_actions(self, context: AutomodContext): + return + async def run(self, event: AutomodEvent) -> bool: if self.disabled: return False - if await self._poll_triggers(event): - return True + context = AutomodContext(self, event) + if not await self._poll_triggers(context): + return False + + if not self._check_conditions(context): + return False - return False + await self._apply_actions(context) + return True diff --git a/commanderbot/ext/automod/trigger/trigger_base.py b/commanderbot/ext/automod/trigger/trigger_base.py index e10a9b8..6d36444 100644 --- a/commanderbot/ext/automod/trigger/trigger_base.py +++ b/commanderbot/ext/automod/trigger/trigger_base.py @@ -3,8 +3,8 @@ from pydantic import BaseModel, ConfigDict +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.event import AutomodEvent -from commanderbot.ext.automod.types import AutomodRuleRef __all__ = ("AutomodTrigger",) @@ -29,7 +29,7 @@ class AutomodTrigger(ABC, BaseModel): event_types: ClassVar[tuple[type[AutomodEvent], ...]] = tuple() - async def poll(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + async def poll(self, context: AutomodContext) -> bool: """Checks whether an event activates the trigger.""" # Skip event if the trigger is disabled @@ -37,17 +37,17 @@ async def poll(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: return False # Skip event if it's not an event we're listening for - event_type = type(event) + event_type = type(context.event) if event_type not in self.event_types: return False # Skip event if it should be ignored - if await self.ignore(rule, event): + if await self.ignore(context): return False # We probably care about the event if we got this far return True - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + async def ignore(self, context: AutomodContext) -> bool: """Override this if more than just the event type needs to be checked.""" return False diff --git a/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py b/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py index 3da7a9c..533a6de 100644 --- a/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py +++ b/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py @@ -1,10 +1,9 @@ from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import DiscordAutomodRulesGuard, RolesGuard from commanderbot.ext.automod.trigger import AutomodTrigger -from commanderbot.ext.automod.types import AutomodRuleRef __all__ = ("DiscordAutomodBlockedMemberInteractions",) @@ -26,13 +25,13 @@ class DiscordAutomodBlockedMemberInteractions(AutomodTrigger): """The roles of the blocked user to match against. If empty, all roles will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: - assert isinstance(event, events.DiscordAutomodBlockedMemberInteractions) + async def ignore(self, context: AutomodContext) -> bool: + assert isinstance(context.event, events.DiscordAutomodBlockedMemberInteractions) - if self.rules and self.rules.ignore(event.rule_id): + if self.rules and self.rules.ignore(context.event.rule_id): return True - if self.roles and self.roles.ignore(event.member): + if self.roles and self.roles.ignore(context.event.member): return True return False diff --git a/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py b/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py index 81c5e3d..32c2b2b 100644 --- a/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py +++ b/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py @@ -1,7 +1,7 @@ from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import ( CategoriesGuard, ChannelsGuard, @@ -10,7 +10,6 @@ RolesGuard, ) from commanderbot.ext.automod.trigger import AutomodTrigger -from commanderbot.ext.automod.types import AutomodRuleRef __all__ = ("DiscordAutomodBlockedMessage",) @@ -39,22 +38,22 @@ class DiscordAutomodBlockedMessage(AutomodTrigger): """The author roles to match against. If empty, all roles will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: - assert isinstance(event, events.DiscordAutomodBlockedMessage) + async def ignore(self, context: AutomodContext) -> bool: + assert isinstance(context.event, events.DiscordAutomodBlockedMessage) - if self.rules and self.rules.ignore(event.rule_id): + if self.rules and self.rules.ignore(context.event.rule_id): return True - if self.categories and self.categories.ignore(event.category): + if self.categories and self.categories.ignore(context.event.category): return True - if self.channel_types and self.channel_types.ignore(event.channel): + if self.channel_types and self.channel_types.ignore(context.event.channel): return True - if self.channels and self.channels.ignore(event.channel): + if self.channels and self.channels.ignore(context.event.channel): return True - if self.author_roles and self.author_roles.ignore(event.author): + if self.author_roles and self.author_roles.ignore(context.event.author): return True return False diff --git a/commanderbot/ext/automod/triggers/discord_automod/timed_out.py b/commanderbot/ext/automod/triggers/discord_automod/timed_out.py index eec723a..662aac7 100644 --- a/commanderbot/ext/automod/triggers/discord_automod/timed_out.py +++ b/commanderbot/ext/automod/triggers/discord_automod/timed_out.py @@ -1,10 +1,9 @@ from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import DiscordAutomodRulesGuard, RolesGuard from commanderbot.ext.automod.trigger import AutomodTrigger -from commanderbot.ext.automod.types import AutomodRuleRef __all__ = ("DiscordAutomodTimedOut",) @@ -24,13 +23,13 @@ class DiscordAutomodTimedOut(AutomodTrigger): """The roles of the timed out user to match against. If empty, all roles will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: - assert isinstance(event, events.DiscordAutomodTimedOut) + async def ignore(self, context: AutomodContext) -> bool: + assert isinstance(context.event, events.DiscordAutomodTimedOut) - if self.rules and self.rules.ignore(event.rule_id): + if self.rules and self.rules.ignore(context.event.rule_id): return True - if self.roles and self.roles.ignore(event.member): + if self.roles and self.roles.ignore(context.event.member): return True return False diff --git a/commanderbot/ext/automod/triggers/member_gained_flags.py b/commanderbot/ext/automod/triggers/member_gained_flags.py index d0a9619..c68def7 100644 --- a/commanderbot/ext/automod/triggers/member_gained_flags.py +++ b/commanderbot/ext/automod/triggers/member_gained_flags.py @@ -1,9 +1,8 @@ from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import FlagsGuard, RolesGuard -from commanderbot.ext.automod.types import AutomodRuleRef from commanderbot.ext.automod.trigger import AutomodTrigger __all__ = ("MemberGainedFlags",) @@ -24,7 +23,8 @@ class MemberGainedFlags(AutomodTrigger): """The roles to match against. If empty, all roles will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + async def ignore(self, context: AutomodContext) -> bool: + event = context.event assert isinstance(event, events.MemberUpdated) if self.flags and not self.flags.gained_any(event.before, event.after): diff --git a/commanderbot/ext/automod/triggers/member_lost_flags.py b/commanderbot/ext/automod/triggers/member_lost_flags.py index 110de1d..0a30a2c 100644 --- a/commanderbot/ext/automod/triggers/member_lost_flags.py +++ b/commanderbot/ext/automod/triggers/member_lost_flags.py @@ -1,9 +1,8 @@ from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import FlagsGuard, RolesGuard -from commanderbot.ext.automod.types import AutomodRuleRef from commanderbot.ext.automod.trigger import AutomodTrigger __all__ = ("MemberLostFlags",) @@ -24,7 +23,8 @@ class MemberLostFlags(AutomodTrigger): """The roles to match against. If empty, all roles will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + async def ignore(self, context: AutomodContext) -> bool: + event = context.event assert isinstance(event, events.MemberUpdated) if self.flags and not self.flags.lost_any(event.before, event.after): diff --git a/commanderbot/ext/automod/triggers/member_typing.py b/commanderbot/ext/automod/triggers/member_typing.py index 0d628d3..9ac7b6a 100644 --- a/commanderbot/ext/automod/triggers/member_typing.py +++ b/commanderbot/ext/automod/triggers/member_typing.py @@ -1,14 +1,13 @@ from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import ( CategoriesGuard, ChannelsGuard, ChannelTypesGuard, RolesGuard, ) -from commanderbot.ext.automod.types import AutomodRuleRef from commanderbot.ext.automod.trigger import AutomodTrigger __all__ = ("MemberTyping",) @@ -35,19 +34,19 @@ class MemberTyping(AutomodTrigger): """The roles to match against. If empty, all roles will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: - assert isinstance(event, events.MemberTyping) + async def ignore(self, context: AutomodContext) -> bool: + assert isinstance(context.event, events.MemberTyping) - if self.categories and self.categories.ignore(event.category): + if self.categories and self.categories.ignore(context.event.category): return True - if self.channel_types and self.channel_types.ignore(event.channel): + if self.channel_types and self.channel_types.ignore(context.event.channel): return True - if self.channels and self.channels.ignore(event.channel): + if self.channels and self.channels.ignore(context.event.channel): return True - if self.roles and self.roles.ignore(event.member): + if self.roles and self.roles.ignore(context.event.member): return True return False diff --git a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py index 1926de2..6a8b2a6 100644 --- a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py +++ b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py @@ -3,14 +3,13 @@ from discord import Member, Role, User from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import ( CategoriesGuard, ChannelsGuard, ChannelTypesGuard, RolesGuard, ) -from commanderbot.ext.automod.types import AutomodRuleRef from commanderbot.ext.automod.trigger import AutomodTrigger __all__ = ("MentionsRemovedFromMessage",) @@ -45,7 +44,8 @@ class MentionsRemovedFromMessage(AutomodTrigger): """The roles of the victim users to match against. If empty, all roles will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + async def ignore(self, context: AutomodContext) -> bool: + event = context.event assert isinstance(event, (events.MessageEdited, events.MessageDeleted)) if self.categories and self.categories.ignore(event.category): @@ -115,39 +115,31 @@ async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: return True # Add removed mentions fields to event - event.set_metadata( - rule.name, - "removed_mentions", - " ".join((m.mention for m in removed_mentions)), + context.set_metadata( + "removed_mentions", " ".join((m.mention for m in removed_mentions)) ) - event.set_metadata( - rule.name, - "removed_mention_names", - " ".join((f"`{m}`" for m in removed_mentions)), + context.set_metadata( + "removed_mention_names", " ".join((f"`{m}`" for m in removed_mentions)) ) # Add removed user mentions fields to event if removed_user_mentions: - event.set_metadata( - rule.name, - "removed_user_mentions", + context.set_metadata( + "removed_user_mention_names", " ".join((m.mention for m in removed_user_mentions)), ) - event.set_metadata( - rule.name, - "removed_user_mention_names", + context.set_metadata( + "removed_user_mentions", " ".join((f"`{m}`" for m in removed_user_mentions)), ) # Add removed role mentions fields to event if removed_role_mentions: - event.set_metadata( - rule.name, + context.set_metadata( "removed_role_mentions", " ".join((m.mention for m in removed_role_mentions)), ) - event.set_metadata( - rule.name, + context.set_metadata( "removed_role_mention_names", " ".join((f"`{m}`" for m in removed_role_mentions)), ) diff --git a/commanderbot/ext/automod/triggers/message.py b/commanderbot/ext/automod/triggers/message.py index beeaabd..22ad5f1 100644 --- a/commanderbot/ext/automod/triggers/message.py +++ b/commanderbot/ext/automod/triggers/message.py @@ -1,14 +1,13 @@ from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import ( CategoriesGuard, ChannelsGuard, ChannelTypesGuard, RolesGuard, ) -from commanderbot.ext.automod.types import AutomodRuleRef from commanderbot.ext.automod.trigger import AutomodTrigger __all__ = ("Message",) @@ -35,21 +34,21 @@ class Message(AutomodTrigger): """The author roles to match against. If empty, all roles will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: + async def ignore(self, context: AutomodContext) -> bool: assert isinstance( - event, (events.MessageSent, events.MessageEdited, events.MessageDeleted) + context.event, (events.MessageSent, events.MessageEdited, events.MessageDeleted) ) - if self.categories and self.categories.ignore(event.category): + if self.categories and self.categories.ignore(context.event.category): return True - if self.channel_types and self.channel_types.ignore(event.channel): + if self.channel_types and self.channel_types.ignore(context.event.channel): return True - if self.channels and self.channels.ignore(event.channel): + if self.channels and self.channels.ignore(context.event.channel): return True - if self.author_roles and self.author_roles.ignore(event.author): + if self.author_roles and self.author_roles.ignore(context.event.author): return True return False diff --git a/commanderbot/ext/automod/triggers/reaction.py b/commanderbot/ext/automod/triggers/reaction.py index 6209291..7258e00 100644 --- a/commanderbot/ext/automod/triggers/reaction.py +++ b/commanderbot/ext/automod/triggers/reaction.py @@ -1,7 +1,7 @@ from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import ( CategoriesGuard, ChannelsGuard, @@ -9,7 +9,6 @@ ReactionsGuard, RolesGuard, ) -from commanderbot.ext.automod.types import AutomodRuleRef from commanderbot.ext.automod.trigger import AutomodTrigger __all__ = ("Reaction",) @@ -42,25 +41,25 @@ class Reaction(AutomodTrigger): """The actor roles to match against. If empty, all roles will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: - assert isinstance(event, (events.ReactionAdded, events.ReactionRemoved)) + async def ignore(self, context: AutomodContext) -> bool: + assert isinstance(context.event, (events.ReactionAdded, events.ReactionRemoved)) - if self.reactions and self.reactions.ignore(event.reaction): + if self.reactions and self.reactions.ignore(context.event.reaction): return True - if self.categories and self.categories.ignore(event.category): + if self.categories and self.categories.ignore(context.event.category): return True - if self.channel_types and self.channel_types.ignore(event.channel): + if self.channel_types and self.channel_types.ignore(context.event.channel): return True - if self.channels and self.channels.ignore(event.channel): + if self.channels and self.channels.ignore(context.event.channel): return True - if self.author_roles and self.author_roles.ignore(event.author): + if self.author_roles and self.author_roles.ignore(context.event.author): return True - if self.actor_roles and self.actor_roles.ignore(event.actor): + if self.actor_roles and self.actor_roles.ignore(context.event.actor): return True return False diff --git a/commanderbot/ext/automod/triggers/thread_created.py b/commanderbot/ext/automod/triggers/thread_created.py index 655de0a..a962ed6 100644 --- a/commanderbot/ext/automod/triggers/thread_created.py +++ b/commanderbot/ext/automod/triggers/thread_created.py @@ -1,13 +1,12 @@ from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import ( CategoriesGuard, ChannelsGuard, ChannelTypesGuard, ) -from commanderbot.ext.automod.types import AutomodRuleRef from commanderbot.ext.automod.trigger import AutomodTrigger __all__ = ("ThreadCreated",) @@ -31,11 +30,11 @@ class ThreadCreated(AutomodTrigger): """The parent channels to match against. If empty, all channels will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: - assert isinstance(event, events.ThreadCreated) + async def ignore(self, context: AutomodContext) -> bool: + assert isinstance(context.event, events.ThreadCreated) # The thread must have a parent - parent = event.thread.parent + parent = context.event.thread.parent if not parent: return True diff --git a/commanderbot/ext/automod/triggers/thread_deleted.py b/commanderbot/ext/automod/triggers/thread_deleted.py index aab6ffe..591b764 100644 --- a/commanderbot/ext/automod/triggers/thread_deleted.py +++ b/commanderbot/ext/automod/triggers/thread_deleted.py @@ -1,13 +1,12 @@ from typing import Literal, Optional, override from commanderbot.ext.automod import events -from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import ( CategoriesGuard, ChannelsGuard, ChannelTypesGuard, ) -from commanderbot.ext.automod.types import AutomodRuleRef from commanderbot.ext.automod.trigger import AutomodTrigger __all__ = ("ThreadDeleted",) @@ -31,11 +30,11 @@ class ThreadDeleted(AutomodTrigger): """The parent channels to match against. If empty, all channels will match.""" @override - async def ignore(self, rule: AutomodRuleRef, event: AutomodEvent) -> bool: - assert isinstance(event, events.ThreadDeleted) + async def ignore(self, context: AutomodContext) -> bool: + assert isinstance(context.event, events.ThreadDeleted) # The thread must have a parent - parent = event.thread.parent + parent = context.event.thread.parent if not parent: return True From 2d9b43c7e2ac7e191ee14429833949bb27a4fb55 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 13:13:17 -0700 Subject: [PATCH 027/158] Removed `IntegerRange` from commanderbot.lib --- commanderbot/lib/integer_range.py | 45 ------------------------------- 1 file changed, 45 deletions(-) delete mode 100644 commanderbot/lib/integer_range.py diff --git a/commanderbot/lib/integer_range.py b/commanderbot/lib/integer_range.py deleted file mode 100644 index 28ce195..0000000 --- a/commanderbot/lib/integer_range.py +++ /dev/null @@ -1,45 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from commanderbot.lib.from_data_mixin import FromDataMixin - -__all__ = ("IntegerRange",) - - -@dataclass -class IntegerRange(FromDataMixin): - """ - An integer range with optional upper and lower bounds. - - Attributes - ---------- - min - The lower bound of the range. - max - The upper bound of the range. - """ - - min: Optional[int] - max: Optional[int] - - @classmethod - def try_from_data(cls, data): - if isinstance(data, int): - return cls(min=data, max=data) - elif isinstance(data, list): - return cls(min=data[0], max=data[1]) - elif isinstance(data, dict): - return cls( - min=data.get("min"), - max=data.get("max"), - ) - - def includes(self, value: int) -> bool: - if (self.min is not None) and (value < self.min): - return False - if (self.max is not None) and (value > self.max): - return False - return True - - def excludes(self, count: int) -> bool: - return not self.includes(count) From 8bf557098a4b9b9be662ced67da3d2bcbe18ceab Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 13:14:41 -0700 Subject: [PATCH 028/158] Moved some data from `AutomodEvent` to `AutomodContext` --- commanderbot/ext/automod/automod_context.py | 20 ++++++++++++++++---- commanderbot/ext/automod/event/event_base.py | 7 ------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/commanderbot/ext/automod/automod_context.py b/commanderbot/ext/automod/automod_context.py index a7e6c4c..308dc93 100644 --- a/commanderbot/ext/automod/automod_context.py +++ b/commanderbot/ext/automod/automod_context.py @@ -1,11 +1,17 @@ from dataclasses import dataclass, field -from typing import Any +from logging import Logger +from typing import TYPE_CHECKING, Any + +from discord.ext.commands import Bot from commanderbot.ext.automod.event import AutomodEvent -from commanderbot.ext.automod.types import AutomodRuleRef __all__ = ("AutomodContext",) +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + from commanderbot.ext.automod.rule import AutomodRule + @dataclass class AutomodContext: @@ -13,11 +19,17 @@ class AutomodContext: The execution context that's given to all triggers, conditions, and actions. """ - rule: AutomodRuleRef + bot: Bot + log: Logger + + state: AutomodGuildState + """The guild state that received the event.""" + + rule: AutomodRule """The rule that's currently running.""" event: AutomodEvent - """The event the rule is processing.""" + """The event that the rule is processing.""" _metadata: dict[str, Any] = field(init=False, default_factory=dict) """Any additional data attached to the execution context.""" diff --git a/commanderbot/ext/automod/event/event_base.py b/commanderbot/ext/automod/event/event_base.py index 75c87d4..80426a9 100644 --- a/commanderbot/ext/automod/event/event_base.py +++ b/commanderbot/ext/automod/event/event_base.py @@ -1,12 +1,9 @@ from abc import ABC from dataclasses import dataclass -from logging import Logger from typing import Optional from discord import CategoryChannel, Member, Message, Reaction, Thread, User -from discord.ext.commands import Bot -from commanderbot.ext.automod.types import AutomodGuildStateRef from commanderbot.lib.predicates import is_thread from commanderbot.lib.types import GuildChannel @@ -19,10 +16,6 @@ class AutomodEvent(ABC): Base class for all automod events. """ - state: AutomodGuildStateRef - bot: Bot - log: Logger - @property def channel(self) -> Optional[GuildChannel | Thread]: """Return the relevant channel, if any.""" From 7682ff5a6fab4904c8201f4a0628093df8c9fa24 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 13:15:07 -0700 Subject: [PATCH 029/158] Updated docstring. --- commanderbot/ext/automod/guards/reactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/guards/reactions.py b/commanderbot/ext/automod/guards/reactions.py index 6230db1..1adf5c6 100644 --- a/commanderbot/ext/automod/guards/reactions.py +++ b/commanderbot/ext/automod/guards/reactions.py @@ -22,7 +22,7 @@ class ReactionsGuard(BaseModel): """The reactions to exclude. A reaction will match if it's not in this set.""" count: Optional[IntegerRangeGuard] = None - """The number of reactions to check for.""" + """The number of times the reaction was made.""" def _ignore_by_includes(self, reaction: Reaction) -> bool: if self.include: From 4ee790e7369732d9950025dd9c9dba1a4c251438 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 13:16:38 -0700 Subject: [PATCH 030/158] Fixed an issue where `RolesGuard` would ignore `Members` and not `Users` --- commanderbot/ext/automod/guards/roles.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commanderbot/ext/automod/guards/roles.py b/commanderbot/ext/automod/guards/roles.py index 379ec42..6feff87 100644 --- a/commanderbot/ext/automod/guards/roles.py +++ b/commanderbot/ext/automod/guards/roles.py @@ -3,7 +3,7 @@ from discord import Member, Role, User from pydantic import BaseModel, ConfigDict, Field -from commanderbot.lib.predicates import is_user +from commanderbot.lib.predicates import is_member from commanderbot.lib.types import RoleID __all__ = ("RolesGuard",) @@ -40,14 +40,14 @@ def ignore(self, member: Optional[Member | User]) -> bool: return False # Ignore users since they don't have roles - if is_user(member): + if not is_member(member): return True roles = {role.id for role in member.roles} return self._ignore_by_includes(roles) or self._ignore_by_excludes(roles) def member_matches(self, member: User | Member) -> bool: - if is_user(member): + if not is_member(member): return False # If `include` roles are defined, check if the member has *any* of them From eff23edf18796a8bcff29608ad9a5904c71aa821 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 13:17:11 -0700 Subject: [PATCH 031/158] Added a `count` parameter to `has_any()` --- commanderbot/ext/automod/guards/flags.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/commanderbot/ext/automod/guards/flags.py b/commanderbot/ext/automod/guards/flags.py index 4b603ba..7e3d62e 100644 --- a/commanderbot/ext/automod/guards/flags.py +++ b/commanderbot/ext/automod/guards/flags.py @@ -25,10 +25,13 @@ def _get_flags(self, actor: Actor) -> Generator[tuple[str, bool]]: yield from ((k, v) for k, v in actor.flags) yield from ((k, v) for k, v in actor.public_flags) - def has_any(self, actor: Actor) -> bool: + def has_any(self, actor: Actor, *, count: int = 1) -> bool: + set_flags: int = 0 for flag_name, value in self._get_flags(actor): if flag_name in self.flags and value: - return True + set_flags += 1 + if set_flags == count: + return True return False def has_all(self, actor: Actor) -> bool: From c4883034c4693934c0a46e6dbbc3d31506844491 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 13:18:11 -0700 Subject: [PATCH 032/158] Added a type alias for `timedelta` that has a Json schema for Pydantic --- commanderbot/lib/__init__.py | 1 - commanderbot/lib/types.py | 76 ++++++++++++++++-------------------- 2 files changed, 33 insertions(+), 44 deletions(-) diff --git a/commanderbot/lib/__init__.py b/commanderbot/lib/__init__.py index 49b1737..353d149 100644 --- a/commanderbot/lib/__init__.py +++ b/commanderbot/lib/__init__.py @@ -6,7 +6,6 @@ from .event_data import * from .exceptions import * from .from_data_mixin import * -from .integer_range import * from .intents import * from .json import * from .json_serializable import * diff --git a/commanderbot/lib/types.py b/commanderbot/lib/types.py index b659396..f988b5d 100644 --- a/commanderbot/lib/types.py +++ b/commanderbot/lib/types.py @@ -1,21 +1,19 @@ -from typing import Any, Literal, TypeAlias +from datetime import timedelta +from typing import Annotated, Any, Literal, TypeAlias from discord import ( CategoryChannel, DMChannel, ForumChannel, GroupChannel, - Guild, Member, - Message, - Reaction, StageChannel, TextChannel, Thread, User, VoiceChannel, ) -from discord.ext.commands import Context, MessageConverter +from pydantic import WithJsonSchema __all__ = ( "IDType", @@ -40,8 +38,7 @@ "ThreadableChannel", "ConnectableChannel", "ChannelTypeNames", - "TextMessage", - "TextReaction", + "Timedelta", ) @@ -149,39 +146,32 @@ """ -class TextMessage(Message): - """ - A [Message] in a [TextChannel] or [Thread]. - - This is a dummy class that can be used in casts to convince static analysis that - this [Message] does indeed contain a [TextChannel] and [Guild]. - - This is not intended to be used anywhere other than type-hinting. - """ - - channel: TextChannel | Thread - guild: Guild - - @classmethod - async def convert(cls, ctx: Context, argument: Any): - """ - Attempt to convert the given argument into a `Role` from within a `Guild`. - - Note that discord.py's built-in `Role` is special-cased, so what we do here is - explicitly make this subclass convertible and then just return the underlying - `Role` anyway. - """ - return await MessageConverter().convert(ctx, argument) - - -class TextReaction(Reaction): - """ - A [Reaction] to a [Message] in a [TextChannel]. - - This is a dummy class that can be used in casts to convince static analysis that - this [Reaction] does indeed contain a [TextMessage]. - - This is not intended to be used anywhere other than type-hinting. - """ - - message: TextMessage +Timedelta = Annotated[ + timedelta, + WithJsonSchema( + { + "anyOf": [ + { + "type": "number", + "examples": [5, 1.0, 3.5], + }, + { + "type": "string", + "format": "iso-8061-duration", + "pattern": r"^[+-]?P(?:(?:\d+W)|(?=.*(?:\d+[YMDHMS]))(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H(?:\d+M(?:\d+S)?)?|\d+M(?:\d+S)?|\d+S))?)$", + "examples": ["PT5H30M", "PT2DT5H15M10S", "P2Y5M2W1DT4H20M"], + }, + { + "type": "string", + "format": "duration", + "pattern": r"^[+-]?\s*(?:(?:\d+)\s*(?i:(?:days?|d))\s*,?\s*)?\d{2}:\d{2}:\d{2}(?:\.\d+)?$", + "examples": ["05:30:00", "2 days, 5:15:00", "5d, 4:20:00"], + }, + ] + }, + mode="validation", + ), +] +""" +An alias for `datetime.timedelta`, but with a Json schema for Pydantic. +""" From 6f0e8d6eefe2d814cfbceb6e41a054641a624f09 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 13:19:58 -0700 Subject: [PATCH 033/158] Renamed some types and `AutomodRule` now checks if a trigger is disabled --- commanderbot/ext/automod/trigger/trigger_base.py | 4 ---- commanderbot/ext/automod/trigger/types.py | 9 +++------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/commanderbot/ext/automod/trigger/trigger_base.py b/commanderbot/ext/automod/trigger/trigger_base.py index 6d36444..b1d0a25 100644 --- a/commanderbot/ext/automod/trigger/trigger_base.py +++ b/commanderbot/ext/automod/trigger/trigger_base.py @@ -32,10 +32,6 @@ class AutomodTrigger(ABC, BaseModel): async def poll(self, context: AutomodContext) -> bool: """Checks whether an event activates the trigger.""" - # Skip event if the trigger is disabled - if self.disabled: - return False - # Skip event if it's not an event we're listening for event_type = type(context.event) if event_type not in self.event_types: diff --git a/commanderbot/ext/automod/trigger/types.py b/commanderbot/ext/automod/trigger/types.py index fb2d760..d469975 100644 --- a/commanderbot/ext/automod/trigger/types.py +++ b/commanderbot/ext/automod/trigger/types.py @@ -4,9 +4,9 @@ from commanderbot.ext.automod import triggers -__all__ = ("AutomodTriggerCollection",) +__all__ = ("AutomodTriggerType",) -type AutomodTriggers = ( +type _AutomodTriggerType = ( triggers.DiscordAutomodBlockedMemberInteractions | triggers.DiscordAutomodBlockedMessage | triggers.DiscordAutomodTimedOut @@ -29,7 +29,4 @@ | triggers.UserUnbanned ) -AutomodTriggerCollection = Annotated[ - list[Annotated[AutomodTriggers, Field(discriminator="type")]], - Field(default_factory=list), -] +type AutomodTriggerType = Annotated[_AutomodTriggerType, Field(discriminator="type")] From e88b23143647b41e6af1579aff7bff8ac065be85 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 18:42:37 -0700 Subject: [PATCH 034/158] Updated docstrings --- .../ext/automod/guards/integer_range.py | 23 +++++-------------- commanderbot/ext/automod/guards/roles.py | 8 ++++--- .../automod/triggers/member_gained_flags.py | 2 +- .../ext/automod/triggers/member_lost_flags.py | 2 +- .../triggers/mentions_removed_from_message.py | 6 ++--- 5 files changed, 16 insertions(+), 25 deletions(-) diff --git a/commanderbot/ext/automod/guards/integer_range.py b/commanderbot/ext/automod/guards/integer_range.py index 358105b..56a539d 100644 --- a/commanderbot/ext/automod/guards/integer_range.py +++ b/commanderbot/ext/automod/guards/integer_range.py @@ -1,6 +1,6 @@ from typing import Any, Optional -from pydantic import BaseModel, GetCoreSchemaHandler +from pydantic import BaseModel, ConfigDict, GetCoreSchemaHandler from pydantic_core import core_schema __all__ = ("IntegerRangeGuard",) @@ -11,6 +11,8 @@ class IntegerRangeGuard(BaseModel): An integer range with optional upper and lower bounds. """ + model_config = ConfigDict(use_attribute_docstrings=True) + min: Optional[int] = None """The lower bound of the range (inclusive).""" @@ -33,6 +35,7 @@ def excludes(self, value: int) -> bool: def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: + default_schema = handler(source_type) from_int_schema = core_schema.chain_schema( steps=[ core_schema.int_schema(), @@ -45,32 +48,18 @@ def __get_pydantic_core_schema__( ), ) - from_dict_schema = core_schema.chain_schema( - [ - core_schema.dict_schema( - keys_schema=core_schema.literal_schema(["min", "max"]), - values_schema=core_schema.union_schema( - [core_schema.int_schema(), core_schema.none_schema()] - ), - ), - core_schema.no_info_plain_validator_function( - lambda data: cls(min=data["min"], max=data["max"]) - ), - ] - ) - return core_schema.json_or_python_schema( json_schema=core_schema.union_schema( [ from_int_schema, - from_dict_schema, + default_schema, ] ), python_schema=core_schema.union_schema( [ core_schema.is_instance_schema(cls), from_int_schema, - from_dict_schema, + default_schema, ] ), serialization=core_schema.plain_serializer_function_ser_schema( diff --git a/commanderbot/ext/automod/guards/roles.py b/commanderbot/ext/automod/guards/roles.py index 6feff87..0cec068 100644 --- a/commanderbot/ext/automod/guards/roles.py +++ b/commanderbot/ext/automod/guards/roles.py @@ -11,16 +11,18 @@ class RolesGuard(BaseModel): """ - Checks whether a member matches a set of roles. + Checks whether a member/role matches a set of roles. """ model_config = ConfigDict(use_attribute_docstrings=True) include: set[RoleID] = Field(default_factory=set) - """The roles to include. A member will match if they have at least one of these.""" + """The roles to include. A role will match if it's in this set.""" exclude: set[RoleID] = Field(default_factory=set) - """The roles to exclude. A member will match if they have none of these.""" + """ + The roles to exclude. A role will match if it's not in this set. + """ def _ignore_by_includes(self, roles: set[RoleID]) -> bool: if self.include: diff --git a/commanderbot/ext/automod/triggers/member_gained_flags.py b/commanderbot/ext/automod/triggers/member_gained_flags.py index c68def7..5064d94 100644 --- a/commanderbot/ext/automod/triggers/member_gained_flags.py +++ b/commanderbot/ext/automod/triggers/member_gained_flags.py @@ -10,7 +10,7 @@ class MemberGainedFlags(AutomodTrigger): """ - Triggers when a `discord.Member` gains certain flags. + Triggers when a member gains certain flags. """ type: Literal["member_gained_flags"] = "member_gained_flags" diff --git a/commanderbot/ext/automod/triggers/member_lost_flags.py b/commanderbot/ext/automod/triggers/member_lost_flags.py index 0a30a2c..66e77d1 100644 --- a/commanderbot/ext/automod/triggers/member_lost_flags.py +++ b/commanderbot/ext/automod/triggers/member_lost_flags.py @@ -10,7 +10,7 @@ class MemberLostFlags(AutomodTrigger): """ - Triggers when a `discord.Member` loses certain flags. + Triggers when a member loses certain flags. """ type: Literal["member_lost_flags"] = "member_lost_flags" diff --git a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py index 6a8b2a6..2a57fc3 100644 --- a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py +++ b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py @@ -114,7 +114,7 @@ async def ignore(self, context: AutomodContext) -> bool: if not removed_mentions: return True - # Add removed mentions fields to event + # Add removed mentions fields to context context.set_metadata( "removed_mentions", " ".join((m.mention for m in removed_mentions)) ) @@ -122,7 +122,7 @@ async def ignore(self, context: AutomodContext) -> bool: "removed_mention_names", " ".join((f"`{m}`" for m in removed_mentions)) ) - # Add removed user mentions fields to event + # Add removed user mentions fields to context if removed_user_mentions: context.set_metadata( "removed_user_mention_names", @@ -133,7 +133,7 @@ async def ignore(self, context: AutomodContext) -> bool: " ".join((f"`{m}`" for m in removed_user_mentions)), ) - # Add removed role mentions fields to event + # Add removed role mentions fields to context if removed_role_mentions: context.set_metadata( "removed_role_mentions", From 1040b3f5ad5e6c6d4a2868072cb530c06c82de62 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 18:44:58 -0700 Subject: [PATCH 035/158] Removed a few files we're not using now. --- commanderbot/ext/automod/automod_action.py | 48 ------- commanderbot/ext/automod/automod_condition.py | 49 ------- commanderbot/ext/automod/automod_entity.py | 111 --------------- .../ext/automod/automod_json_store.py | 128 ------------------ commanderbot/ext/automod/automod_rule.py | 117 ---------------- commanderbot/ext/automod/automod_trigger.py | 73 ---------- commanderbot/ext/automod/types.py | 16 --- commanderbot/ext/automod/utils.py | 66 --------- commanderbot/lib/__init__.py | 1 - commanderbot/lib/pattern_wrapper.py | 58 -------- 10 files changed, 667 deletions(-) delete mode 100644 commanderbot/ext/automod/automod_action.py delete mode 100644 commanderbot/ext/automod/automod_condition.py delete mode 100644 commanderbot/ext/automod/automod_entity.py delete mode 100644 commanderbot/ext/automod/automod_json_store.py delete mode 100644 commanderbot/ext/automod/automod_rule.py delete mode 100644 commanderbot/ext/automod/automod_trigger.py delete mode 100644 commanderbot/ext/automod/types.py delete mode 100644 commanderbot/ext/automod/utils.py delete mode 100644 commanderbot/lib/pattern_wrapper.py diff --git a/commanderbot/ext/automod/automod_action.py b/commanderbot/ext/automod/automod_action.py deleted file mode 100644 index f5b0538..0000000 --- a/commanderbot/ext/automod/automod_action.py +++ /dev/null @@ -1,48 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Iterable, Optional, Protocol - -from commanderbot.ext.automod import actions -from commanderbot.ext.automod.automod_entity import ( - AutomodEntity, - AutomodEntityBase, - deserialize_entities, -) -from commanderbot.ext.automod.automod_event import AutomodEvent - - -class AutomodAction(AutomodEntity, Protocol): - description: Optional[str] - - async def apply(self, event: AutomodEvent): - """Apply the action.""" - - -# @implements AutomodAction -@dataclass -class AutomodActionBase(AutomodEntityBase): - """ - Base action for inheriting base fields and functionality. - - Attributes - ---------- - description - A human-readable description of the action. - """ - - default_module_prefix = actions.__name__ - module_function_name = "create_action" - - description: Optional[str] - - async def apply(self, event: AutomodEvent): - """Override this to apply the action.""" - - -def deserialize_actions(data: Iterable[Any]) -> list[AutomodAction]: - return deserialize_entities( - entity_type=AutomodActionBase, - data=data, - defaults={ - "description": None, - }, - ) diff --git a/commanderbot/ext/automod/automod_condition.py b/commanderbot/ext/automod/automod_condition.py deleted file mode 100644 index 0eb8412..0000000 --- a/commanderbot/ext/automod/automod_condition.py +++ /dev/null @@ -1,49 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Iterable, Optional, Protocol - -from commanderbot.ext.automod import conditions -from commanderbot.ext.automod.automod_entity import ( - AutomodEntity, - AutomodEntityBase, - deserialize_entities, -) -from commanderbot.ext.automod.automod_event import AutomodEvent - - -class AutomodCondition(AutomodEntity, Protocol): - description: Optional[str] - - async def check(self, event: AutomodEvent) -> bool: - """Check whether the condition passes.""" - - -# @implements AutomodCondition -@dataclass -class AutomodConditionBase(AutomodEntityBase): - """ - Base condition for inheriting base fields and functionality. - - Attributes - ---------- - description - A human-readable description of the condition. - """ - - default_module_prefix = conditions.__name__ - module_function_name = "create_condition" - - description: Optional[str] - - async def check(self, event: AutomodEvent) -> bool: - """Override this to check whether the condition passes.""" - return False - - -def deserialize_conditions(data: Iterable[Any]) -> list[AutomodCondition]: - return deserialize_entities( - entity_type=AutomodConditionBase, - data=data, - defaults={ - "description": None, - }, - ) diff --git a/commanderbot/ext/automod/automod_entity.py b/commanderbot/ext/automod/automod_entity.py deleted file mode 100644 index bc46b98..0000000 --- a/commanderbot/ext/automod/automod_entity.py +++ /dev/null @@ -1,111 +0,0 @@ -from dataclasses import dataclass -from typing import Any, ClassVar, Iterable, Optional, Protocol, Type, TypeVar - -from commanderbot.ext.automod.utils import deserialize_module_object -from commanderbot.lib import JsonObject, JsonSerializable - -SelfType = TypeVar("SelfType") - - -class AutomodEntity(Protocol): - """Base interface for automod triggers, conditions, and actions.""" - - @classmethod - def from_data(cls: Type[SelfType], data: JsonObject) -> SelfType: - """Create an entity from data.""" - ... - - -# @implements AutomodEntity -@dataclass -class AutomodEntityBase(JsonSerializable): - """ - Contains common base logic for automod triggers, conditions, and actions. - - This includes logic for using the `type` field to load a python module and call one - of its functions to deserialize the given data and create a new object. - """ - - default_module_prefix: ClassVar[str] = "" - module_function_name: ClassVar[str] = "" - - ST = TypeVar("ST", bound="AutomodEntityBase") - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - """Override this if any fields require special handling.""" - return cls(**data) - - @classmethod - def get_type_string(cls) -> str: - """Override this if the external type field requires special handling.""" - if not cls.default_module_prefix: - raise ValueError( - f"Subclass of {AutomodEntityBase.__name__} lacks a" - + " `default_module_prefix`" - ) - default_check = f"{cls.default_module_prefix}." - full_type = cls.__module__ - if full_type.startswith(default_check): - short_type = full_type[len(default_check) :] - return short_type - return full_type - - # @implements JsonSerializable - def to_json(self) -> Any: - # We use a custom implementation to include `type` at serialization-time only. - type_str = self.get_type_string() - data = dict(type=type_str) - data.update(self.__dict__) - return data - - -ET = TypeVar("ET", bound="AutomodEntityBase") - - -def deserialize_entity( - entity_type: Type[ET], - data: Any, - defaults: Optional[dict[str, Any]] = None, -) -> ET: - """ - Create an entity from raw data. - - Note that the raw data should usually be an object with key-value pairs, but in the - case that a string is provided it will be used to populate the `type` field along - with any `defaults` given. - """ - # prepare the processed data - processed_data: dict[str, Any] = defaults.copy() if defaults else {} - if isinstance(data, dict): - processed_data.update(data) - elif isinstance(data, str): - processed_data["type"] = data - else: - raise ValueError(data) - # deserialize the entity - return deserialize_module_object( - data=processed_data, - default_module_prefix=entity_type.default_module_prefix, - function_name=entity_type.module_function_name, - ) - - -def deserialize_entities( - entity_type: Type[ET], - data: Iterable[Any], - defaults: Optional[dict[str, Any]] = None, -) -> list[ET]: - """ - Create a list of entities from raw data. - - Use `defaults` to provide default values for things like optional dataclass fields. - """ - return [ - deserialize_entity( - data=value, - entity_type=entity_type, - defaults=defaults, - ) - for value in data - ] diff --git a/commanderbot/ext/automod/automod_json_store.py b/commanderbot/ext/automod/automod_json_store.py deleted file mode 100644 index 08ec7b3..0000000 --- a/commanderbot/ext/automod/automod_json_store.py +++ /dev/null @@ -1,128 +0,0 @@ -from dataclasses import dataclass -from typing import Any, AsyncIterable, Optional - -from discord import Guild - -from commanderbot.ext.automod.automod_data import AutomodData -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_store import AutomodRule -from commanderbot.lib import JsonObject, LogOptions, RoleSet, utils -from commanderbot.lib.cogs import CogStore -from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter - - -# @implements AutomodStore -@dataclass -class AutomodJsonStore(CogStore): - """ - Implementation of `AutomodStore` that uses a simple JSON file to persist state. - """ - - db: JsonFileDatabaseAdapter[AutomodData] - - # @implements AutomodStore - async def get_default_log_options(self, guild: Guild) -> Optional[LogOptions]: - cache = await self.db.get_cache() - return await cache.get_default_log_options(guild) - - # @implements AutomodStore - async def set_default_log_options( - self, guild: Guild, log_options: Optional[LogOptions] - ) -> Optional[LogOptions]: - cache = await self.db.get_cache() - old_value = await cache.set_default_log_options(guild, log_options) - await self.db.dirty() - return old_value - - # @implements AutomodStore - async def get_permitted_roles(self, guild: Guild) -> Optional[RoleSet]: - cache = await self.db.get_cache() - return await cache.get_permitted_roles(guild) - - # @implements AutomodStore - async def set_permitted_roles( - self, guild: Guild, permitted_roles: Optional[RoleSet] - ) -> Optional[RoleSet]: - cache = await self.db.get_cache() - old_value = await cache.set_permitted_roles(guild, permitted_roles) - await self.db.dirty() - return old_value - - # @implements AutomodStore - async def all_rules(self, guild: Guild) -> AsyncIterable[AutomodRule]: - cache = await self.db.get_cache() - async for rule in cache.all_rules(guild): - yield rule - - # @implements AutomodStore - async def rules_for_event( - self, guild: Guild, event: AutomodEvent - ) -> AsyncIterable[AutomodRule]: - cache = await self.db.get_cache() - async for rule in cache.rules_for_event(guild, event): - yield rule - - # @implements AutomodStore - async def query_rules(self, guild: Guild, query: str) -> AsyncIterable[AutomodRule]: - cache = await self.db.get_cache() - async for rule in cache.query_rules(guild, query): - yield rule - - # @implements AutomodStore - async def get_rule(self, guild: Guild, name: str) -> Optional[AutomodRule]: - cache = await self.db.get_cache() - return await cache.get_rule(guild, name) - - # @implements AutomodStore - async def require_rule(self, guild: Guild, name: str) -> AutomodRule: - cache = await self.db.get_cache() - return await cache.require_rule(guild, name) - - # @implements AutomodStore - async def add_rule(self, guild: Guild, data: JsonObject) -> AutomodRule: - cache = await self.db.get_cache() - added_rule = await cache.add_rule(guild, data) - await self.db.dirty() - return added_rule - - # @implements AutomodStore - async def remove_rule(self, guild: Guild, name: str) -> AutomodRule: - cache = await self.db.get_cache() - removed_rule = await cache.remove_rule(guild, name) - await self.db.dirty() - return removed_rule - - # @implements AutomodStore - async def modify_rule( - self, - guild: Guild, - name: str, - path: utils.JsonPath, - op: utils.JsonPathOp, - data: Any, - ) -> AutomodRule: - cache = await self.db.get_cache() - modified_rule = await cache.modify_rule(guild, name, path, op, data) - await self.db.dirty() - return modified_rule - - # @implements AutomodStore - async def enable_rule(self, guild: Guild, name: str) -> AutomodRule: - cache = await self.db.get_cache() - modified_rule = await cache.enable_rule(guild, name) - await self.db.dirty() - return modified_rule - - # @implements AutomodStore - async def disable_rule(self, guild: Guild, name: str) -> AutomodRule: - cache = await self.db.get_cache() - modified_rule = await cache.disable_rule(guild, name) - await self.db.dirty() - return modified_rule - - # @implements AutomodStore - async def increment_rule_hits(self, guild: Guild, name: str) -> AutomodRule: - cache = await self.db.get_cache() - modified_rule = await cache.increment_rule_hits(guild, name) - await self.db.dirty() - return modified_rule diff --git a/commanderbot/ext/automod/automod_rule.py b/commanderbot/ext/automod/automod_rule.py deleted file mode 100644 index 8e56006..0000000 --- a/commanderbot/ext/automod/automod_rule.py +++ /dev/null @@ -1,117 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from typing import Optional, Self - -from discord.utils import utcnow - -from commanderbot.ext.automod.automod_action import AutomodAction, deserialize_actions -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - deserialize_conditions, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - deserialize_triggers, -) -from commanderbot.lib import JsonObject, LogOptions, utils - - -@dataclass -class AutomodRule: - """ - A piece of logic detailing how to perform an automated task. - - Attributes - ---------- - name - The name of the rule. Can be any arbitrary string, but snake_case tends to be - easier to type into chat. - added_on - The datetime the rule was created. - modified_on - The last datetime the rule was modified. - disabled - Whether the rule is currently disabled. Defaults to false. - hits - How many times the rule's conditions have passed and actions have run. - description - A human-readable description of the rule. - log - Override logging configuration for this rule. - triggers - A list of events that may trigger the rule. - conditions - A list of conditions that must *all* pass for the actions to run. - actions - A list of actions that will all run if the conditions pass. - """ - - name: str - description: Optional[str] - added_on: datetime - modified_on: datetime - disabled: bool - hits: int - - log: Optional[LogOptions] - - triggers: list[AutomodTrigger] - conditions: list[AutomodCondition] - actions: list[AutomodAction] - - @classmethod - def from_data(cls, data: JsonObject) -> Self: - now = utcnow() - added_on = utils.datetime_from_field_optional(data, "added_on") or now - modified_on = utils.datetime_from_field_optional(data, "modified_on") or now - return cls( - name=data["name"], - added_on=added_on, - modified_on=modified_on, - disabled=data.get("disabled", False), - hits=data.get("hits", 0), - description=data.get("description"), - log=LogOptions.from_field_optional(data, "log"), - triggers=deserialize_triggers(data.get("triggers", [])), - conditions=deserialize_conditions(data.get("conditions", [])), - actions=deserialize_actions(data.get("actions", [])), - ) - - def __hash__(self) -> int: - return hash(self.name) - - def build_title(self) -> str: - parts = [] - if self.disabled: - parts.append("(Disabled)") - parts.append(f"{self.name}:") - if self.description: - parts.append(self.description) - return " ".join(parts) - - def poll_triggers(self, event: AutomodEvent) -> bool: - """Check whether the event activates any triggers.""" - for trigger in self.triggers: - if trigger.poll(event): - return True - return False - - async def check_conditions(self, event: AutomodEvent) -> bool: - """Check whether all conditions pass.""" - for condition in self.conditions: - if not await condition.check(event): - return False - return True - - async def apply_actions(self, event: AutomodEvent): - """Apply all actions.""" - for action in self.actions: - await action.apply(event) - - async def run(self, event: AutomodEvent) -> bool: - """Apply actions if conditions pass.""" - if (not self.disabled) and await self.check_conditions(event): - await self.apply_actions(event) - return True - return False diff --git a/commanderbot/ext/automod/automod_trigger.py b/commanderbot/ext/automod/automod_trigger.py deleted file mode 100644 index 6571704..0000000 --- a/commanderbot/ext/automod/automod_trigger.py +++ /dev/null @@ -1,73 +0,0 @@ -from dataclasses import dataclass -from typing import Any, ClassVar, Iterable, Optional, Protocol, Type, TypeVar - -from commanderbot.ext.automod import triggers -from commanderbot.ext.automod.automod_entity import ( - AutomodEntity, - AutomodEntityBase, - deserialize_entities, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject - -ST = TypeVar("ST") - - -class AutomodTrigger(AutomodEntity, Protocol): - event_types: ClassVar[tuple[Type[AutomodEvent], ...]] - - description: Optional[str] - - def poll(self, event: AutomodEvent) -> Optional[bool]: - """Check whether an event activates the trigger.""" - - -# @implements AutomodTrigger -@dataclass -class AutomodTriggerBase(AutomodEntityBase): - """ - Base trigger for inheriting base fields and functionality. - - Attributes - ---------- - description - A human-readable description of the trigger. - """ - - default_module_prefix = triggers.__name__ - module_function_name = "create_trigger" - - event_types: ClassVar[tuple[Type[AutomodEvent], ...]] = tuple() - - description: Optional[str] - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - """Override this if the subclass defines additional fields.""" - return cls( - description=data.get("description"), - ) - - def poll(self, event: AutomodEvent) -> bool: - # Verify that we care about this event type. - event_type = type(event) - if event_type not in self.event_types: - return False - # Check whether the event should be ignored. - if self.ignore(event): - return False - return True - - def ignore(self, event: AutomodEvent) -> bool: - """Override this if more than just the event type needs to be checked.""" - return False - - -def deserialize_triggers(data: Iterable[Any]) -> list[AutomodTrigger]: - return deserialize_entities( - entity_type=AutomodTriggerBase, - data=data, - defaults={ - "description": None, - }, - ) diff --git a/commanderbot/ext/automod/types.py b/commanderbot/ext/automod/types.py deleted file mode 100644 index d2b5a94..0000000 --- a/commanderbot/ext/automod/types.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import TYPE_CHECKING, Any - -__all__ = ("AutomodGuildStateRef", "AutomodRuleRef") - -if TYPE_CHECKING: - from commanderbot.ext.automod.automod_guild_state import AutomodGuildState - from commanderbot.ext.automod.rule import AutomodRule - - type AutomodGuildStateRef = AutomodGuildState - """A reference to an `AutomodGuildStateRef` for type checkers.""" - - type AutomodRuleRef = AutomodRule - """A reference to an `AutomodRule` for type checkers.""" -else: - type AutomodGuildStateRef = Any - type AutomodRuleRef = Any diff --git a/commanderbot/ext/automod/utils.py b/commanderbot/ext/automod/utils.py deleted file mode 100644 index 54a0d7b..0000000 --- a/commanderbot/ext/automod/utils.py +++ /dev/null @@ -1,66 +0,0 @@ -from collections import defaultdict -from importlib import import_module -from typing import Any - -from commanderbot.lib import JsonObject, ResponsiveException - -module_function_cache: defaultdict[str, defaultdict[str, Any]] = defaultdict( - lambda: defaultdict(lambda: None) -) - - -def resolve_module_function(module_name: str, function_name: str) -> Any: - # check if it's already in our cache - if func := module_function_cache[module_name][function_name]: - return func - # if not, attempt to resolve it dynamically - module = import_module(module_name) - func = getattr(module, function_name) - module_function_cache[module_name][function_name] = func - return func - - -class MissingTypeField(ResponsiveException): - def __init__(self): - super().__init__(f"Missing `type` field") - - -class InvalidModule(Exception): - def __init__(self, module_name: str, function_name: str): - super().__init__( - f"Module `{module_name}` could not be imported," - + f" or does not contain a `{function_name}` function" - ) - - -class InvalidModuleFunction(Exception): - def __init__(self, module_name: str, function_name: str): - super().__init__( - f"Function `{function_name}` in module `{module_name}` caused an error" - ) - - -def deserialize_module_object( - data: JsonObject, - default_module_prefix: str, - function_name: str, -) -> Any: - # get the type name - type_name = str(data.pop("type")) - if not type_name: - raise MissingTypeField() - # determine the module name - module_name = type_name - if "." not in type_name: - module_name = f"{default_module_prefix}.{type_name}" - # attempt to resolve the module function - try: - func = resolve_module_function(module_name, function_name) - except Exception as ex: - raise InvalidModule(module_name, function_name) from ex - # attempt to call the function to create the object - try: - obj = func(data) - except Exception as ex: - raise InvalidModuleFunction(module_name, function_name) from ex - return obj diff --git a/commanderbot/lib/__init__.py b/commanderbot/lib/__init__.py index 353d149..272cc8b 100644 --- a/commanderbot/lib/__init__.py +++ b/commanderbot/lib/__init__.py @@ -10,7 +10,6 @@ from .json import * from .json_serializable import * from .log_options import * -from .pattern_wrapper import * from .predicates import * from .role_set import * from .shallow_formatter import * diff --git a/commanderbot/lib/pattern_wrapper.py b/commanderbot/lib/pattern_wrapper.py deleted file mode 100644 index 9c5c89a..0000000 --- a/commanderbot/lib/pattern_wrapper.py +++ /dev/null @@ -1,58 +0,0 @@ -import re -from dataclasses import dataclass -from typing import Any, AnyStr, Match, Optional - -from commanderbot.lib.from_data_mixin import FromDataMixin -from commanderbot.lib.json_serializable import JsonSerializable - -__all__ = ("PatternWrapper",) - - -@dataclass -class PatternWrapper(JsonSerializable, FromDataMixin): - """Wraps `re.Pattern` to simplify de/serialization.""" - - pattern: re.Pattern - - # TODO Other regex flags? #enhance - ignore_case: Optional[bool] = None - - @classmethod - def try_from_str(cls, data: str): - pattern = re.compile(data) - return cls(pattern) - - @classmethod - def try_from_dict(cls, data: dict): - raw_pattern = data["pattern"] - ignore_case = data.get("ignore_case") - flags = 0 - if ignore_case: - flags |= re.IGNORECASE - pattern = re.compile(raw_pattern, flags=flags) - return cls( - pattern=pattern, - ignore_case=ignore_case, - ) - - @classmethod - def try_from_data(cls, data): - if isinstance(data, str): - return cls.try_from_str(data) - elif isinstance(data, dict): - return cls.try_from_dict(data) - - # @implements JsonSerializable - def to_json(self) -> Any: - if self.ignore_case: - return dict( - pattern=self.pattern.pattern, - ignore_case=self.ignore_case, - ) - return self.pattern.pattern - - def search(self, string: AnyStr) -> Optional[Match[AnyStr]]: - return self.pattern.search(string) - - def match(self, string: AnyStr) -> Optional[Match[AnyStr]]: - return self.pattern.match(string) From 6d34343f600d1f5e048a8b6d2363f1c552e213b1 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 18:45:33 -0700 Subject: [PATCH 036/158] Added a type for Unicode normalization literals --- commanderbot/lib/types.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/commanderbot/lib/types.py b/commanderbot/lib/types.py index f988b5d..3e341d4 100644 --- a/commanderbot/lib/types.py +++ b/commanderbot/lib/types.py @@ -39,6 +39,7 @@ "ConnectableChannel", "ChannelTypeNames", "Timedelta", + "UnicodeNormalizationForms", ) @@ -158,14 +159,14 @@ { "type": "string", "format": "iso-8061-duration", - "pattern": r"^[+-]?P(?:(?:\d+W)|(?=.*(?:\d+[YMDHMS]))(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H(?:\d+M(?:\d+S)?)?|\d+M(?:\d+S)?|\d+S))?)$", + "pattern": r"^[+-]?P(?:(?:\d+W)|(?=.*(?:\d+[YMWDHMS]))(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+H(?:\d+M(?:\d+S)?)?|\d+M(?:\d+S)?|\d+S))?)", "examples": ["PT5H30M", "PT2DT5H15M10S", "P2Y5M2W1DT4H20M"], }, { "type": "string", "format": "duration", "pattern": r"^[+-]?\s*(?:(?:\d+)\s*(?i:(?:days?|d))\s*,?\s*)?\d{2}:\d{2}:\d{2}(?:\.\d+)?$", - "examples": ["05:30:00", "2 days, 5:15:00", "5d, 4:20:00"], + "examples": ["05:30:00", "2 days, 05:15:00", "5d, 04:20:00"], }, ] }, @@ -175,3 +176,5 @@ """ An alias for `datetime.timedelta`, but with a Json schema for Pydantic. """ + +UnicodeNormalizationForms: TypeAlias = Literal["NFC", "NFD", "NFKC", "NFKD"] From 5ed8598f1f804c8447ad10c7e9e86096db4c3a75 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 18:46:42 -0700 Subject: [PATCH 037/158] Reworked conditions --- .../ext/automod/condition/__init__.py | 2 + .../ext/automod/condition/condition_base.py | 32 +++++ commanderbot/ext/automod/condition/types.py | 45 +++++++ .../ext/automod/conditions/__init__.py | 31 +++++ .../ext/automod/conditions/abc/__init__.py | 9 ++ .../conditions/abc/target_account_age.py | 40 ++++++ .../conditions/abc/target_account_age_base.py | 42 ------ .../conditions/abc/target_has_all_flags.py | 25 ++++ .../conditions/abc/target_has_any_flags.py | 29 ++++ .../automod/conditions/abc/target_is_bot.py | 21 +++ .../conditions/abc/target_is_bot_base.py | 20 --- .../conditions/abc/target_is_not_bot.py | 21 +++ .../conditions/abc/target_is_not_bot_base.py | 20 --- .../conditions/abc/target_is_not_self.py | 21 +++ .../conditions/abc/target_is_not_self_base.py | 20 --- .../automod/conditions/abc/target_is_self.py | 21 +++ .../conditions/abc/target_is_self_base.py | 20 --- .../conditions/abc/target_member_for.py | 43 ++++++ .../conditions/abc/target_member_for_base.py | 44 ------ .../automod/conditions/abc/target_roles.py | 25 ++++ .../conditions/abc/target_roles_base.py | 32 ----- .../automod/conditions/actor_account_age.py | 36 ++--- .../automod/conditions/actor_has_all_flags.py | 20 +++ .../automod/conditions/actor_has_any_flags.py | 20 +++ .../ext/automod/conditions/actor_is_bot.py | 27 ++-- .../automod/conditions/actor_is_not_bot.py | 31 ++--- .../automod/conditions/actor_is_not_self.py | 27 ---- .../ext/automod/conditions/actor_is_self.py | 25 ---- .../automod/conditions/actor_member_for.py | 34 ++--- .../ext/automod/conditions/actor_roles.py | 32 ++--- commanderbot/ext/automod/conditions/all_of.py | 53 +++----- commanderbot/ext/automod/conditions/any_of.py | 69 ++++------ .../automod/conditions/author_account_age.py | 36 ++--- .../conditions/author_has_all_flags.py | 20 +++ .../conditions/author_has_any_flags.py | 20 +++ .../ext/automod/conditions/author_is_bot.py | 27 ++-- .../automod/conditions/author_is_not_bot.py | 31 ++--- .../automod/conditions/author_is_not_self.py | 31 ++--- .../ext/automod/conditions/author_is_self.py | 27 ++-- .../automod/conditions/author_member_for.py | 34 ++--- .../ext/automod/conditions/author_roles.py | 32 ++--- .../ext/automod/conditions/inverted.py | 24 ++++ .../ext/automod/conditions/member_for.py | 34 ----- .../conditions/message_content_contains.py | 127 +++++++----------- .../conditions/message_content_matches.py | 125 +++++++---------- .../conditions/message_has_attachments.py | 51 +++---- .../automod/conditions/message_has_embeds.py | 51 +++---- .../automod/conditions/message_has_links.py | 60 +++------ .../conditions/message_has_mentions.py | 57 +++----- .../conditions/message_mentions_roles.py | 78 +++++------ .../conditions/message_mentions_users.py | 70 ++++++---- .../ext/automod/conditions/none_of.py | 52 +++---- commanderbot/ext/automod/conditions/not.py | 45 ------- .../ext/automod/conditions/random_chance.py | 24 ++++ .../thread_auto_archive_duration.py | 42 ------ .../ext/automod/conditions/throw_error.py | 32 ++--- commanderbot/ext/automod/conditions/wait.py | 43 ++---- commanderbot/ext/automod/rule/rule.py | 25 +++- 58 files changed, 1002 insertions(+), 1133 deletions(-) create mode 100644 commanderbot/ext/automod/condition/__init__.py create mode 100644 commanderbot/ext/automod/condition/condition_base.py create mode 100644 commanderbot/ext/automod/condition/types.py create mode 100644 commanderbot/ext/automod/conditions/abc/target_account_age.py delete mode 100644 commanderbot/ext/automod/conditions/abc/target_account_age_base.py create mode 100644 commanderbot/ext/automod/conditions/abc/target_has_all_flags.py create mode 100644 commanderbot/ext/automod/conditions/abc/target_has_any_flags.py create mode 100644 commanderbot/ext/automod/conditions/abc/target_is_bot.py delete mode 100644 commanderbot/ext/automod/conditions/abc/target_is_bot_base.py create mode 100644 commanderbot/ext/automod/conditions/abc/target_is_not_bot.py delete mode 100644 commanderbot/ext/automod/conditions/abc/target_is_not_bot_base.py create mode 100644 commanderbot/ext/automod/conditions/abc/target_is_not_self.py delete mode 100644 commanderbot/ext/automod/conditions/abc/target_is_not_self_base.py create mode 100644 commanderbot/ext/automod/conditions/abc/target_is_self.py delete mode 100644 commanderbot/ext/automod/conditions/abc/target_is_self_base.py create mode 100644 commanderbot/ext/automod/conditions/abc/target_member_for.py delete mode 100644 commanderbot/ext/automod/conditions/abc/target_member_for_base.py create mode 100644 commanderbot/ext/automod/conditions/abc/target_roles.py delete mode 100644 commanderbot/ext/automod/conditions/abc/target_roles_base.py create mode 100644 commanderbot/ext/automod/conditions/actor_has_all_flags.py create mode 100644 commanderbot/ext/automod/conditions/actor_has_any_flags.py delete mode 100644 commanderbot/ext/automod/conditions/actor_is_not_self.py delete mode 100644 commanderbot/ext/automod/conditions/actor_is_self.py create mode 100644 commanderbot/ext/automod/conditions/author_has_all_flags.py create mode 100644 commanderbot/ext/automod/conditions/author_has_any_flags.py create mode 100644 commanderbot/ext/automod/conditions/inverted.py delete mode 100644 commanderbot/ext/automod/conditions/member_for.py delete mode 100644 commanderbot/ext/automod/conditions/not.py create mode 100644 commanderbot/ext/automod/conditions/random_chance.py delete mode 100644 commanderbot/ext/automod/conditions/thread_auto_archive_duration.py diff --git a/commanderbot/ext/automod/condition/__init__.py b/commanderbot/ext/automod/condition/__init__.py new file mode 100644 index 0000000..cd451c8 --- /dev/null +++ b/commanderbot/ext/automod/condition/__init__.py @@ -0,0 +1,2 @@ +from .condition_base import * +from .types import * diff --git a/commanderbot/ext/automod/condition/condition_base.py b/commanderbot/ext/automod/condition/condition_base.py new file mode 100644 index 0000000..7943d72 --- /dev/null +++ b/commanderbot/ext/automod/condition/condition_base.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from commanderbot.ext.automod.automod_context import AutomodContext + +__all__ = ("AutomodCondition",) + + +class AutomodCondition(ABC, BaseModel): + """ + Base class for all automod conditions. + + A condition is a predicate that must pass in order to run actions. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + type: str + """The condition type.""" + + description: Optional[str] = None + """Describe what the condition does.""" + + disabled: Optional[bool] = None + """Is the condition disabled?""" + + @abstractmethod + async def check(self, context: AutomodContext) -> bool: + """Check whether the condition passes.""" + pass diff --git a/commanderbot/ext/automod/condition/types.py b/commanderbot/ext/automod/condition/types.py new file mode 100644 index 0000000..45ba520 --- /dev/null +++ b/commanderbot/ext/automod/condition/types.py @@ -0,0 +1,45 @@ +from typing import Annotated + +from pydantic import Field + +from commanderbot.ext.automod import conditions + +__all__ = ("AutomodConditionType",) + +type _AutomodConditionType = ( + conditions.ActorAccountAge + | conditions.ActorHasAllFlags + | conditions.ActorHasAnyFlags + | conditions.ActorIsBot + | conditions.ActorIsNotBot + | conditions.ActorMemberFor + | conditions.ActorRoles + | conditions.AllOf + | conditions.AnyOf + | conditions.AuthorAccountAge + | conditions.AuthorHasAllFlags + | conditions.AuthorHasAnyFlags + | conditions.AuthorIsBot + | conditions.AuthorIsNotBot + | conditions.AuthorIsSelf + | conditions.AuthorIsNotSelf + | conditions.AuthorMemberFor + | conditions.AuthorRoles + | conditions.Inverted + | conditions.MessageContentContains + | conditions.MessageContentMatches + | conditions.MessageHasAttachments + | conditions.MessageHasEmbeds + | conditions.MessageHasLinks + | conditions.MessageHasMentions + | conditions.MessageMentionsRoles + | conditions.MessageMentionsUsers + | conditions.NoneOf + | conditions.RandomChance + | conditions.ThrowError + | conditions.Wait +) + +type AutomodConditionType = Annotated[ + _AutomodConditionType, Field(discriminator="type") +] diff --git a/commanderbot/ext/automod/conditions/__init__.py b/commanderbot/ext/automod/conditions/__init__.py index e69de29..5f72b75 100644 --- a/commanderbot/ext/automod/conditions/__init__.py +++ b/commanderbot/ext/automod/conditions/__init__.py @@ -0,0 +1,31 @@ +from .actor_account_age import * +from .actor_has_all_flags import * +from .actor_has_any_flags import * +from .actor_is_bot import * +from .actor_is_not_bot import * +from .actor_member_for import * +from .actor_roles import * +from .all_of import * +from .any_of import * +from .author_account_age import * +from .author_has_all_flags import * +from .author_has_any_flags import * +from .author_is_bot import * +from .author_is_not_bot import * +from .author_is_not_self import * +from .author_is_self import * +from .author_member_for import * +from .author_roles import * +from .inverted import * +from .message_content_contains import * +from .message_content_matches import * +from .message_has_attachments import * +from .message_has_embeds import * +from .message_has_links import * +from .message_has_mentions import * +from .message_mentions_roles import * +from .message_mentions_users import * +from .none_of import * +from .random_chance import * +from .throw_error import * +from .wait import * diff --git a/commanderbot/ext/automod/conditions/abc/__init__.py b/commanderbot/ext/automod/conditions/abc/__init__.py index e69de29..852ca2a 100644 --- a/commanderbot/ext/automod/conditions/abc/__init__.py +++ b/commanderbot/ext/automod/conditions/abc/__init__.py @@ -0,0 +1,9 @@ +from .target_account_age import * +from .target_has_all_flags import * +from .target_has_any_flags import * +from .target_is_bot import * +from .target_is_not_bot import * +from .target_is_not_self import * +from .target_is_self import * +from .target_member_for import * +from .target_roles import * diff --git a/commanderbot/ext/automod/conditions/abc/target_account_age.py b/commanderbot/ext/automod/conditions/abc/target_account_age.py new file mode 100644 index 0000000..25675db --- /dev/null +++ b/commanderbot/ext/automod/conditions/abc/target_account_age.py @@ -0,0 +1,40 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import User +from discord.utils import utcnow + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.lib.types import Timedelta + +__all__ = ("TargetAccountAge",) + + +class TargetAccountAge(AutomodCondition): + at_least: Optional[Timedelta] = None + """The lower bound to check against, if any (inclusive).""" + + at_most: Optional[Timedelta] = None + """The upper bound to check against, if any (inclusive).""" + + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[User]: + pass + + @override + async def check(self, context: AutomodContext) -> bool: + user = self.get_target(context) + if not user: + return False + + now = utcnow() + account_age = now - user.created_at + + if self.at_least is not None and account_age < self.at_least: + return False + + if self.at_most is not None and account_age > self.at_most: + return False + + return True diff --git a/commanderbot/ext/automod/conditions/abc/target_account_age_base.py b/commanderbot/ext/automod/conditions/abc/target_account_age_base.py deleted file mode 100644 index 19ed387..0000000 --- a/commanderbot/ext/automod/conditions/abc/target_account_age_base.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass -from datetime import timedelta -from typing import Optional, Type, TypeVar - -from discord import Member -from discord.utils import utcnow - -from commanderbot.ext.automod.automod_condition import AutomodConditionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject, utils - -ST = TypeVar("ST") - - -@dataclass -class TargetAccountAgeBase(AutomodConditionBase): - more_than: Optional[timedelta] = None - less_than: Optional[timedelta] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - more_than = utils.timedelta_from_field_optional(data, "more_than") - less_than = utils.timedelta_from_field_optional(data, "less_than") - return cls( - description=data.get("description"), - more_than=more_than, - less_than=less_than, - ) - - def get_target(self, event: AutomodEvent) -> Optional[Member]: - raise NotImplementedError() - - async def check(self, event: AutomodEvent) -> bool: - member = self.get_target(event) - if member is None: - return False - now = utcnow() - created_at = member.created_at - age = now - created_at - is_older = (self.more_than is None) or (age > self.more_than) - is_younger = (self.less_than is None) or (age < self.less_than) - return is_older and is_younger diff --git a/commanderbot/ext/automod/conditions/abc/target_has_all_flags.py b/commanderbot/ext/automod/conditions/abc/target_has_all_flags.py new file mode 100644 index 0000000..5e3f58f --- /dev/null +++ b/commanderbot/ext/automod/conditions/abc/target_has_all_flags.py @@ -0,0 +1,25 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import Member, User + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.guards import FlagsGuard + +__all__ = ("TargetHasAllFlags",) + + +class TargetHasAllFlags(AutomodCondition): + flags: FlagsGuard + """The flags to check.""" + + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[User | Member]: + pass + + @override + async def check(self, context: AutomodContext) -> bool: + if actor := self.get_target(context): + return self.flags.has_all(actor) + return False diff --git a/commanderbot/ext/automod/conditions/abc/target_has_any_flags.py b/commanderbot/ext/automod/conditions/abc/target_has_any_flags.py new file mode 100644 index 0000000..bd13306 --- /dev/null +++ b/commanderbot/ext/automod/conditions/abc/target_has_any_flags.py @@ -0,0 +1,29 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import Member, User +from pydantic import PositiveInt + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.guards import FlagsGuard + +__all__ = ("TargetHasAnyFlags",) + + +class TargetHasAnyFlags(AutomodCondition): + flags: FlagsGuard + """The flags to check.""" + + count: PositiveInt = 1 + """The number of flags to check for. If empty, the user/member only needs a single flag.""" + + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[User | Member]: + pass + + @override + async def check(self, context: AutomodContext) -> bool: + if actor := self.get_target(context): + return self.flags.has_any(actor, count=self.count) + return False diff --git a/commanderbot/ext/automod/conditions/abc/target_is_bot.py b/commanderbot/ext/automod/conditions/abc/target_is_bot.py new file mode 100644 index 0000000..b6a9de7 --- /dev/null +++ b/commanderbot/ext/automod/conditions/abc/target_is_bot.py @@ -0,0 +1,21 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import User + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition + +__all__ = ("TargetIsBot",) + + +class TargetIsBot(AutomodCondition): + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[User]: + pass + + @override + async def check(self, context: AutomodContext) -> bool: + if user := self.get_target(context): + return user.bot + return False diff --git a/commanderbot/ext/automod/conditions/abc/target_is_bot_base.py b/commanderbot/ext/automod/conditions/abc/target_is_bot_base.py deleted file mode 100644 index cf6463b..0000000 --- a/commanderbot/ext/automod/conditions/abc/target_is_bot_base.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar - -from discord import Member - -from commanderbot.ext.automod.automod_condition import AutomodConditionBase -from commanderbot.ext.automod.automod_event import AutomodEvent - -ST = TypeVar("ST") - - -@dataclass -class TargetIsBotBase(AutomodConditionBase): - def get_target(self, event: AutomodEvent) -> Optional[Member]: - raise NotImplementedError() - - async def check(self, event: AutomodEvent) -> bool: - if member := self.get_target(event): - return member.bot - return False diff --git a/commanderbot/ext/automod/conditions/abc/target_is_not_bot.py b/commanderbot/ext/automod/conditions/abc/target_is_not_bot.py new file mode 100644 index 0000000..b6bc734 --- /dev/null +++ b/commanderbot/ext/automod/conditions/abc/target_is_not_bot.py @@ -0,0 +1,21 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import User + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition + +__all__ = ("TargetIsNotBot",) + + +class TargetIsNotBot(AutomodCondition): + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[User]: + pass + + @override + async def check(self, context: AutomodContext) -> bool: + if user := self.get_target(context): + return not user.bot + return True diff --git a/commanderbot/ext/automod/conditions/abc/target_is_not_bot_base.py b/commanderbot/ext/automod/conditions/abc/target_is_not_bot_base.py deleted file mode 100644 index 05990ec..0000000 --- a/commanderbot/ext/automod/conditions/abc/target_is_not_bot_base.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar - -from discord import Member - -from commanderbot.ext.automod.automod_condition import AutomodConditionBase -from commanderbot.ext.automod.automod_event import AutomodEvent - -ST = TypeVar("ST") - - -@dataclass -class TargetIsNotBotBase(AutomodConditionBase): - def get_target(self, event: AutomodEvent) -> Optional[Member]: - raise NotImplementedError() - - async def check(self, event: AutomodEvent) -> bool: - if member := self.get_target(event): - return not member.bot - return True diff --git a/commanderbot/ext/automod/conditions/abc/target_is_not_self.py b/commanderbot/ext/automod/conditions/abc/target_is_not_self.py new file mode 100644 index 0000000..99ba97b --- /dev/null +++ b/commanderbot/ext/automod/conditions/abc/target_is_not_self.py @@ -0,0 +1,21 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import User + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition + +__all__ = ("TargetIsNotSelf",) + + +class TargetIsNotSelf(AutomodCondition): + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[User]: + pass + + @override + async def check(self, context: AutomodContext) -> bool: + if (user := self.get_target(context)) and (bot_user := context.bot.user): + return user.id != bot_user.id + return True diff --git a/commanderbot/ext/automod/conditions/abc/target_is_not_self_base.py b/commanderbot/ext/automod/conditions/abc/target_is_not_self_base.py deleted file mode 100644 index bd220eb..0000000 --- a/commanderbot/ext/automod/conditions/abc/target_is_not_self_base.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar - -from discord import Member - -from commanderbot.ext.automod.automod_condition import AutomodConditionBase -from commanderbot.ext.automod.automod_event import AutomodEvent - -ST = TypeVar("ST") - - -@dataclass -class TargetIsNotSelfBase(AutomodConditionBase): - def get_target(self, event: AutomodEvent) -> Optional[Member]: - raise NotImplementedError() - - async def check(self, event: AutomodEvent) -> bool: - if member := self.get_target(event): - return member.id != event.bot.user.id - return True diff --git a/commanderbot/ext/automod/conditions/abc/target_is_self.py b/commanderbot/ext/automod/conditions/abc/target_is_self.py new file mode 100644 index 0000000..724dac9 --- /dev/null +++ b/commanderbot/ext/automod/conditions/abc/target_is_self.py @@ -0,0 +1,21 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import User + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition + +__all__ = ("TargetIsSelf",) + + +class TargetIsSelf(AutomodCondition): + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[User]: + pass + + @override + async def check(self, context: AutomodContext) -> bool: + if (user := self.get_target(context)) and (bot_user := context.bot.user): + return user.id == bot_user.id + return False diff --git a/commanderbot/ext/automod/conditions/abc/target_is_self_base.py b/commanderbot/ext/automod/conditions/abc/target_is_self_base.py deleted file mode 100644 index b7a929c..0000000 --- a/commanderbot/ext/automod/conditions/abc/target_is_self_base.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar - -from discord import Member - -from commanderbot.ext.automod.automod_condition import AutomodConditionBase -from commanderbot.ext.automod.automod_event import AutomodEvent - -ST = TypeVar("ST") - - -@dataclass -class TargetIsSelfBase(AutomodConditionBase): - def get_target(self, event: AutomodEvent) -> Optional[Member]: - raise NotImplementedError() - - async def check(self, event: AutomodEvent) -> bool: - if member := self.get_target(event): - return member.id == event.bot.user.id - return False diff --git a/commanderbot/ext/automod/conditions/abc/target_member_for.py b/commanderbot/ext/automod/conditions/abc/target_member_for.py new file mode 100644 index 0000000..341d2a0 --- /dev/null +++ b/commanderbot/ext/automod/conditions/abc/target_member_for.py @@ -0,0 +1,43 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import Member +from discord.utils import utcnow + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.lib.types import Timedelta + +__all__ = ("TargetMemberFor",) + + +class TargetMemberFor(AutomodCondition): + at_least: Optional[Timedelta] = None + """The lower bound to check against, if any (inclusive).""" + + at_most: Optional[Timedelta] = None + """The upper bound to check against, if any (inclusive).""" + + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[Member]: + pass + + @override + async def check(self, context: AutomodContext) -> bool: + member = self.get_target(context) + if not member: + return False + + if member.joined_at is None: + return False + + now = utcnow() + member_for = now - member.joined_at + + if self.at_least is not None and member_for < self.at_least: + return False + + if self.at_most is not None and member_for > self.at_most: + return False + + return True diff --git a/commanderbot/ext/automod/conditions/abc/target_member_for_base.py b/commanderbot/ext/automod/conditions/abc/target_member_for_base.py deleted file mode 100644 index 69a4b94..0000000 --- a/commanderbot/ext/automod/conditions/abc/target_member_for_base.py +++ /dev/null @@ -1,44 +0,0 @@ -from dataclasses import dataclass -from datetime import timedelta -from typing import Optional, Type, TypeVar - -from discord import Member -from discord.utils import utcnow - -from commanderbot.ext.automod.automod_condition import AutomodConditionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject, utils - -ST = TypeVar("ST") - - -@dataclass -class TargetMemberForBase(AutomodConditionBase): - at_least: Optional[timedelta] = None - at_most: Optional[timedelta] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - at_least = utils.timedelta_from_field_optional(data, "at_least") - at_most = utils.timedelta_from_field_optional(data, "at_most") - return cls( - description=data.get("description"), - at_least=at_least, - at_most=at_most, - ) - - def get_target(self, event: AutomodEvent) -> Optional[Member]: - raise NotImplementedError() - - async def check(self, event: AutomodEvent) -> bool: - member = self.get_target(event) - if member is None: - return False - now = utcnow() - joined_at = member.joined_at - if not joined_at: - return False - member_for = now - joined_at - is_at_least = (self.at_least is None) or (member_for > self.at_least) - is_at_most = (self.at_most is None) or (member_for < self.at_most) - return is_at_least and is_at_most diff --git a/commanderbot/ext/automod/conditions/abc/target_roles.py b/commanderbot/ext/automod/conditions/abc/target_roles.py new file mode 100644 index 0000000..75b48da --- /dev/null +++ b/commanderbot/ext/automod/conditions/abc/target_roles.py @@ -0,0 +1,25 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import Member + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.guards import RolesGuard + +__all__ = ("TargetRoles",) + + +class TargetRoles(AutomodCondition): + roles: RolesGuard + """The roles to check against.""" + + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[Member]: + pass + + @override + async def check(self, context: AutomodContext) -> bool: + if member := self.get_target(context): + return not self.roles.ignore(member) + return False diff --git a/commanderbot/ext/automod/conditions/abc/target_roles_base.py b/commanderbot/ext/automod/conditions/abc/target_roles_base.py deleted file mode 100644 index 89b1c3e..0000000 --- a/commanderbot/ext/automod/conditions/abc/target_roles_base.py +++ /dev/null @@ -1,32 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar - -from discord import Member - -from commanderbot.ext.automod.automod_condition import AutomodConditionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject -from commanderbot.lib.guards.roles_guard import RolesGuard - -ST = TypeVar("ST") - - -@dataclass -class TargetRolesBase(AutomodConditionBase): - roles: RolesGuard - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - roles = RolesGuard.from_field(data, "roles") - return cls( - description=data.get("description"), - roles=roles, - ) - - def get_target(self, event: AutomodEvent) -> Optional[Member]: - raise NotImplementedError() - - async def check(self, event: AutomodEvent) -> bool: - if member := self.get_target(event): - return not self.roles.ignore(member) - return False diff --git a/commanderbot/ext/automod/conditions/actor_account_age.py b/commanderbot/ext/automod/conditions/actor_account_age.py index c2a065a..615fe36 100644 --- a/commanderbot/ext/automod/conditions/actor_account_age.py +++ b/commanderbot/ext/automod/conditions/actor_account_age.py @@ -1,34 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override -from discord import Member +from discord import User -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_account_age_base import ( - TargetAccountAgeBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetAccountAge +from commanderbot.lib.predicates import is_user -ST = TypeVar("ST") +__all__ = ("ActorAccountAge",) -@dataclass -class ActorAccountAge(TargetAccountAgeBase): +class ActorAccountAge(TargetAccountAge): """ Check if the actor's account is a certain age. - - Attributes - ---------- - more_than - The lower bound to check against, if any. - less_than - The upper bound to check against, if any. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor - + type: Literal["actor_account_age"] = "actor_account_age" -def create_condition(data: JsonObject) -> AutomodCondition: - return ActorAccountAge.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[User]: + if (user := context.event.actor) and is_user(user): + return user diff --git a/commanderbot/ext/automod/conditions/actor_has_all_flags.py b/commanderbot/ext/automod/conditions/actor_has_all_flags.py new file mode 100644 index 0000000..5075b35 --- /dev/null +++ b/commanderbot/ext/automod/conditions/actor_has_all_flags.py @@ -0,0 +1,20 @@ +from typing import Literal, Optional, override + +from discord import Member, User + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetHasAllFlags + +__all__ = ("ActorHasAllFlags",) + + +class ActorHasAllFlags(TargetHasAllFlags): + """ + Check if the actor has all flags from a selection. + """ + + type: Literal["actor_has_all_flags"] = "actor_has_all_flags" + + @override + def get_target(self, context: AutomodContext) -> Optional[User | Member]: + return context.event.actor diff --git a/commanderbot/ext/automod/conditions/actor_has_any_flags.py b/commanderbot/ext/automod/conditions/actor_has_any_flags.py new file mode 100644 index 0000000..8017786 --- /dev/null +++ b/commanderbot/ext/automod/conditions/actor_has_any_flags.py @@ -0,0 +1,20 @@ +from typing import Literal, Optional, override + +from discord import Member, User + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetHasAnyFlags + +__all__ = ("ActorHasAnyFlags",) + + +class ActorHasAnyFlags(TargetHasAnyFlags): + """ + Check if the actor has a number of flags from a selection. + """ + + type: Literal["actor_has_any_flags"] = "actor_has_any_flags" + + @override + def get_target(self, context: AutomodContext) -> Optional[User | Member]: + return context.event.actor diff --git a/commanderbot/ext/automod/conditions/actor_is_bot.py b/commanderbot/ext/automod/conditions/actor_is_bot.py index 2d0a38c..0acb6c9 100644 --- a/commanderbot/ext/automod/conditions/actor_is_bot.py +++ b/commanderbot/ext/automod/conditions/actor_is_bot.py @@ -1,25 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override -from discord import Member +from discord import User -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_is_bot_base import TargetIsBotBase -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetIsBot +from commanderbot.lib.predicates import is_user -ST = TypeVar("ST") +__all__ = ("ActorIsBot",) -@dataclass -class ActorIsBot(TargetIsBotBase): +class ActorIsBot(TargetIsBot): """ Check if the actor in context is a bot. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor + type: Literal["actor_is_bot"] = "actor_is_bot" - -def create_condition(data: JsonObject) -> AutomodCondition: - return ActorIsBot.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[User]: + if (user := context.event.actor) and is_user(user): + return user diff --git a/commanderbot/ext/automod/conditions/actor_is_not_bot.py b/commanderbot/ext/automod/conditions/actor_is_not_bot.py index 222712c..14d401d 100644 --- a/commanderbot/ext/automod/conditions/actor_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/actor_is_not_bot.py @@ -1,27 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override -from discord import Member +from discord import User -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_is_not_bot_base import ( - TargetIsNotBotBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetIsNotBot +from commanderbot.lib.predicates import is_user -ST = TypeVar("ST") +__all__ = ("ActorIsNotBot",) -@dataclass -class ActorIsNotBot(TargetIsNotBotBase): +class ActorIsNotBot(TargetIsNotBot): """ - Check if the actor in context is *not* a bot. + Check if the actor in context is not a bot. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor + type: Literal["actor_is_not_bot"] = "actor_is_not_bot" - -def create_condition(data: JsonObject) -> AutomodCondition: - return ActorIsNotBot.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[User]: + if (user := context.event.actor) and is_user(user): + return user diff --git a/commanderbot/ext/automod/conditions/actor_is_not_self.py b/commanderbot/ext/automod/conditions/actor_is_not_self.py deleted file mode 100644 index 7f2812c..0000000 --- a/commanderbot/ext/automod/conditions/actor_is_not_self.py +++ /dev/null @@ -1,27 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar - -from discord import Member - -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_is_not_self_base import ( - TargetIsNotSelfBase, -) -from commanderbot.lib import JsonObject - -ST = TypeVar("ST") - - -@dataclass -class ActorIsNotSelf(TargetIsNotSelfBase): - """ - Check if the actor in context is *not* the bot itself. - """ - - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor - - -def create_condition(data: JsonObject) -> AutomodCondition: - return ActorIsNotSelf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/actor_is_self.py b/commanderbot/ext/automod/conditions/actor_is_self.py deleted file mode 100644 index 8d62564..0000000 --- a/commanderbot/ext/automod/conditions/actor_is_self.py +++ /dev/null @@ -1,25 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar - -from discord import Member - -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_is_self_base import TargetIsSelfBase -from commanderbot.lib import JsonObject - -ST = TypeVar("ST") - - -@dataclass -class ActorNotSelf(TargetIsSelfBase): - """ - Check if the actor in context is the bot itself. - """ - - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor - - -def create_condition(data: JsonObject) -> AutomodCondition: - return ActorNotSelf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/actor_member_for.py b/commanderbot/ext/automod/conditions/actor_member_for.py index c7eb336..09051f1 100644 --- a/commanderbot/ext/automod/conditions/actor_member_for.py +++ b/commanderbot/ext/automod/conditions/actor_member_for.py @@ -1,34 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_member_for_base import ( - TargetMemberForBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetMemberFor +from commanderbot.lib.predicates import is_member -ST = TypeVar("ST") +__all__ = ("ActorMemberFor",) -@dataclass -class ActorMemberFor(TargetMemberForBase): +class ActorMemberFor(TargetMemberFor): """ Check if the actor has been on the server for a certain amount of time. - - Attributes - ---------- - at_least - The lower bound to check against, if any. - at_most - The upper bound to check against, if any. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor - + type: Literal["actor_member_for"] = "actor_member_for" -def create_condition(data: JsonObject) -> AutomodCondition: - return ActorMemberFor.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[Member]: + if (member := context.event.actor) and is_member(member): + return member diff --git a/commanderbot/ext/automod/conditions/actor_roles.py b/commanderbot/ext/automod/conditions/actor_roles.py index b501e64..3caca4a 100644 --- a/commanderbot/ext/automod/conditions/actor_roles.py +++ b/commanderbot/ext/automod/conditions/actor_roles.py @@ -1,32 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_roles_base import ( - TargetRolesBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetRoles +from commanderbot.lib.predicates import is_member -ST = TypeVar("ST") +__all__ = ("ActorRoles",) -@dataclass -class ActorRoles(TargetRolesBase): +class ActorRoles(TargetRoles): """ Check if the actor in context has certain roles. - - Attributes - ---------- - roles - The roles to match against. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor - + type: Literal["actor_roles"] = "actor_roles" -def create_condition(data: JsonObject) -> AutomodCondition: - return ActorRoles.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[Member]: + if (member := context.event.actor) and is_member(member): + return member diff --git a/commanderbot/ext/automod/conditions/all_of.py b/commanderbot/ext/automod/conditions/all_of.py index feea5ad..3824d7d 100644 --- a/commanderbot/ext/automod/conditions/all_of.py +++ b/commanderbot/ext/automod/conditions/all_of.py @@ -1,48 +1,31 @@ -from dataclasses import dataclass -from typing import Type, TypeVar +from typing import TYPE_CHECKING, Literal, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, - deserialize_conditions, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from pydantic import Field -ST = TypeVar("ST") +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +__all__ = ("AllOf",) -@dataclass -class AllOf(AutomodConditionBase): - """ - Check if all sub-conditions pass (logical AND). +if TYPE_CHECKING: + from commanderbot.ext.automod.condition import AutomodConditionType - Note that this is effectively equivalent to `any_of` with a `count` set to the - number of sub-conditions. The distinction exists for clarity and convenience. - Attributes - ---------- - conditions - The sub-conditions to check. +class AllOf(AutomodCondition): + """ + Check if all sub-conditions pass (logical AND). """ - conditions: tuple[AutomodCondition] + type: Literal["all_of"] = "all_of" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - raw_conditions = data["conditions"] - conditions = deserialize_conditions(raw_conditions) - return cls( - description=data.get("description"), - conditions=conditions, - ) + conditions: list[AutomodConditionType] = Field(default_factory=list, min_length=1) + """The sub-conditions to check.""" - async def check(self, event: AutomodEvent) -> bool: + @override + async def check(self, context: AutomodContext) -> bool: for condition in self.conditions: - if not await condition.check(event): + if condition.disabled: + continue + if not await condition.check(context): return False return True - - -def create_condition(data: JsonObject) -> AutomodCondition: - return AllOf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/any_of.py b/commanderbot/ext/automod/conditions/any_of.py index 509f03e..d49ef78 100644 --- a/commanderbot/ext/automod/conditions/any_of.py +++ b/commanderbot/ext/automod/conditions/any_of.py @@ -1,53 +1,40 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import TYPE_CHECKING, Literal, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, - deserialize_conditions, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from pydantic import Field, PositiveInt -ST = TypeVar("ST") +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +__all__ = ("AnyOf",) -@dataclass -class AnyOf(AutomodConditionBase): +if TYPE_CHECKING: + from commanderbot.ext.automod.condition import AutomodConditionType + + +class AnyOf(AutomodCondition): """ Check if a number of sub-conditions pass (logical OR). + """ + + type: Literal["any_of"] = "any_of" - Attributes - ---------- - conditions - The sub-conditions to check. - count - The number of sub-conditions that must pass. If unspecified, only a single - sub-condition is required to pass. + conditions: list[AutomodConditionType] = Field(default_factory=list, min_length=1) + """The sub-conditions to check.""" + + count: PositiveInt = 1 + """ + The number of sub-conditions that must pass. If empty, + only a single sub-condition is required to pass. """ - conditions: tuple[AutomodCondition] - count: Optional[int] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - raw_conditions = data["conditions"] - conditions = deserialize_conditions(raw_conditions) - return cls( - description=data.get("description"), - conditions=conditions, - count=data.get("count"), - ) - - async def check(self, event: AutomodEvent) -> bool: - remainder = self.count or 1 + @override + async def check(self, context: AutomodContext) -> bool: + checks_passed: int = 0 for condition in self.conditions: - if await condition.check(event): - remainder -= 1 - if remainder <= 0: + if condition.disabled: + continue + if await condition.check(context): + checks_passed += 1 + if checks_passed == self.count: return True return False - - -def create_condition(data: JsonObject) -> AutomodCondition: - return AnyOf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/author_account_age.py b/commanderbot/ext/automod/conditions/author_account_age.py index 9a6012a..dcfb938 100644 --- a/commanderbot/ext/automod/conditions/author_account_age.py +++ b/commanderbot/ext/automod/conditions/author_account_age.py @@ -1,34 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override -from discord import Member +from discord import User -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_account_age_base import ( - TargetAccountAgeBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetAccountAge +from commanderbot.lib.predicates import is_user -ST = TypeVar("ST") +__all__ = ("AuthorAccountAge",) -@dataclass -class AuthorAccountAge(TargetAccountAgeBase): +class AuthorAccountAge(TargetAccountAge): """ Check if the author's account is a certain age. - - Attributes - ---------- - more_than - The lower bound to check against, if any. - less_than - The upper bound to check against, if any. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author - + type: Literal["author_account_age"] = "author_account_age" -def create_condition(data: JsonObject) -> AutomodCondition: - return AuthorAccountAge.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[User]: + if (user := context.event.author) and is_user(user): + return user diff --git a/commanderbot/ext/automod/conditions/author_has_all_flags.py b/commanderbot/ext/automod/conditions/author_has_all_flags.py new file mode 100644 index 0000000..98904c1 --- /dev/null +++ b/commanderbot/ext/automod/conditions/author_has_all_flags.py @@ -0,0 +1,20 @@ +from typing import Literal, Optional, override + +from discord import Member, User + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetHasAllFlags + +__all__ = ("AuthorHasAllFlags",) + + +class AuthorHasAllFlags(TargetHasAllFlags): + """ + Check if the author has all flags from a selection. + """ + + type: Literal["author_has_all_flags"] = "author_has_all_flags" + + @override + def get_target(self, context: AutomodContext) -> Optional[User | Member]: + return context.event.author diff --git a/commanderbot/ext/automod/conditions/author_has_any_flags.py b/commanderbot/ext/automod/conditions/author_has_any_flags.py new file mode 100644 index 0000000..c5d1a19 --- /dev/null +++ b/commanderbot/ext/automod/conditions/author_has_any_flags.py @@ -0,0 +1,20 @@ +from typing import Literal, Optional, override + +from discord import Member, User + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetHasAnyFlags + +__all__ = ("AuthorHasAnyFlags",) + + +class AuthorHasAnyFlags(TargetHasAnyFlags): + """ + Check if the author has a number of flags from a selection. + """ + + type: Literal["author_has_any_flags"] = "author_has_any_flags" + + @override + def get_target(self, context: AutomodContext) -> Optional[User | Member]: + return context.event.author diff --git a/commanderbot/ext/automod/conditions/author_is_bot.py b/commanderbot/ext/automod/conditions/author_is_bot.py index 7c511d5..1fa72ab 100644 --- a/commanderbot/ext/automod/conditions/author_is_bot.py +++ b/commanderbot/ext/automod/conditions/author_is_bot.py @@ -1,25 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override -from discord import Member +from discord import User -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_is_bot_base import TargetIsBotBase -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetIsBot +from commanderbot.lib.predicates import is_user -ST = TypeVar("ST") +__all__ = ("AuthorIsBot",) -@dataclass -class AuthorIsBot(TargetIsBotBase): +class AuthorIsBot(TargetIsBot): """ Check if the author in context is a bot. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author + type: Literal["author_is_bot"] = "author_is_bot" - -def create_condition(data: JsonObject) -> AutomodCondition: - return AuthorIsBot.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[User]: + if (user := context.event.author) and is_user(user): + return user diff --git a/commanderbot/ext/automod/conditions/author_is_not_bot.py b/commanderbot/ext/automod/conditions/author_is_not_bot.py index dc18b7e..34d4aaa 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/author_is_not_bot.py @@ -1,27 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override -from discord import Member +from discord import User -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_is_not_bot_base import ( - TargetIsNotBotBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetIsNotBot +from commanderbot.lib.predicates import is_user -ST = TypeVar("ST") +__all__ = ("AuthorIsNotBot",) -@dataclass -class AuthorIsNotBot(TargetIsNotBotBase): +class AuthorIsNotBot(TargetIsNotBot): """ - Check if the author in context is *not* a bot. + Check if the author in context is not a bot. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author + type: Literal["author_is_not_bot"] = "author_is_not_bot" - -def create_condition(data: JsonObject) -> AutomodCondition: - return AuthorIsNotBot.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[User]: + if (user := context.event.author) and is_user(user): + return user diff --git a/commanderbot/ext/automod/conditions/author_is_not_self.py b/commanderbot/ext/automod/conditions/author_is_not_self.py index 437b176..0ba3a56 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_self.py +++ b/commanderbot/ext/automod/conditions/author_is_not_self.py @@ -1,27 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override -from discord import Member +from discord import User -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_is_not_self_base import ( - TargetIsNotSelfBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetIsSelf +from commanderbot.lib.predicates import is_user -ST = TypeVar("ST") +__all__ = ("AuthorIsNotSelf",) -@dataclass -class AuthorIsNotSelf(TargetIsNotSelfBase): +class AuthorIsNotSelf(TargetIsSelf): """ - Check if the author in context is *not* the bot itself. + Check if the author in context is not the bot itself. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author + type: Literal["author_is_not_self"] = "author_is_not_self" - -def create_condition(data: JsonObject) -> AutomodCondition: - return AuthorIsNotSelf.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[User]: + if (user := context.event.author) and is_user(user): + return user diff --git a/commanderbot/ext/automod/conditions/author_is_self.py b/commanderbot/ext/automod/conditions/author_is_self.py index 4902264..73174db 100644 --- a/commanderbot/ext/automod/conditions/author_is_self.py +++ b/commanderbot/ext/automod/conditions/author_is_self.py @@ -1,25 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override -from discord import Member +from discord import User -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_is_self_base import TargetIsSelfBase -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetIsSelf +from commanderbot.lib.predicates import is_user -ST = TypeVar("ST") +__all__ = ("AuthorIsSelf",) -@dataclass -class AuthorIsSelf(TargetIsSelfBase): +class AuthorIsSelf(TargetIsSelf): """ Check if the author in context is the bot itself. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author + type: Literal["author_is_self"] = "author_is_self" - -def create_condition(data: JsonObject) -> AutomodCondition: - return AuthorIsSelf.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[User]: + if (user := context.event.author) and is_user(user): + return user diff --git a/commanderbot/ext/automod/conditions/author_member_for.py b/commanderbot/ext/automod/conditions/author_member_for.py index db7c158..93daf36 100644 --- a/commanderbot/ext/automod/conditions/author_member_for.py +++ b/commanderbot/ext/automod/conditions/author_member_for.py @@ -1,34 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_member_for_base import ( - TargetMemberForBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetMemberFor +from commanderbot.lib.predicates import is_member -ST = TypeVar("ST") +__all__ = ("AuthorMemberFor",) -@dataclass -class AuthorMemberFor(TargetMemberForBase): +class AuthorMemberFor(TargetMemberFor): """ Check if the author has been on the server for a certain amount of time. - - Attributes - ---------- - at_least - The lower bound to check against, if any. - at_most - The upper bound to check against, if any. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author - + type: Literal["author_member_for"] = "author_member_for" -def create_condition(data: JsonObject) -> AutomodCondition: - return AuthorMemberFor.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[Member]: + if (member := context.event.author) and is_member(member): + return member diff --git a/commanderbot/ext/automod/conditions/author_roles.py b/commanderbot/ext/automod/conditions/author_roles.py index b3f4368..5313195 100644 --- a/commanderbot/ext/automod/conditions/author_roles.py +++ b/commanderbot/ext/automod/conditions/author_roles.py @@ -1,32 +1,22 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Literal, Optional, override from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_roles_base import ( - TargetRolesBase, -) -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.conditions.abc import TargetRoles +from commanderbot.lib.predicates import is_member -ST = TypeVar("ST") +__all__ = ("AuthorRoles",) -@dataclass -class AuthorRoles(TargetRolesBase): +class AuthorRoles(TargetRoles): """ Check if the author in context has certain roles. - - Attributes - ---------- - roles - The roles to match against. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author - + type: Literal["author_roles"] = "author_roles" -def create_condition(data: JsonObject) -> AutomodCondition: - return AuthorRoles.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[Member]: + if (member := context.event.author) and is_member(member): + return member diff --git a/commanderbot/ext/automod/conditions/inverted.py b/commanderbot/ext/automod/conditions/inverted.py new file mode 100644 index 0000000..216cea0 --- /dev/null +++ b/commanderbot/ext/automod/conditions/inverted.py @@ -0,0 +1,24 @@ +from typing import TYPE_CHECKING, Literal, override + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition + +if TYPE_CHECKING: + from commanderbot.ext.automod.condition import AutomodConditionType + +__all__ = ("Inverted",) + + +class Inverted(AutomodCondition): + """ + Inverts the result of a sub-condition. + """ + + type: Literal["inverted"] = "inverted" + + condition: AutomodConditionType + """The sub-condition to check.""" + + @override + async def check(self, context: AutomodContext) -> bool: + return not self.condition.check(context) diff --git a/commanderbot/ext/automod/conditions/member_for.py b/commanderbot/ext/automod/conditions/member_for.py deleted file mode 100644 index 90e4536..0000000 --- a/commanderbot/ext/automod/conditions/member_for.py +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, TypeVar - -from discord import Member - -from commanderbot.ext.automod.automod_condition import AutomodCondition -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.conditions.abc.target_member_for_base import ( - TargetMemberForBase, -) -from commanderbot.lib import JsonObject - -ST = TypeVar("ST") - - -@dataclass -class MemberFor(TargetMemberForBase): - """ - Check if the member has been on the server for a certain amount of time. - - Attributes - ---------- - at_least - The lower bound to check against, if any. - at_most - The upper bound to check against, if any. - """ - - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.member - - -def create_condition(data: JsonObject) -> AutomodCondition: - return MemberFor.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_content_contains.py b/commanderbot/ext/automod/conditions/message_content_contains.py index c4e90ff..eb711b4 100644 --- a/commanderbot/ext/automod/conditions/message_content_contains.py +++ b/commanderbot/ext/automod/conditions/message_content_contains.py @@ -1,95 +1,72 @@ import unicodedata -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, Self, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from pydantic import Field, PositiveInt, model_validator -ST = TypeVar("ST") +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.lib.types import UnicodeNormalizationForms +__all__ = ("MessageContentContains",) -DEFAULT_NORMALIZATION_FORM = "NFKD" +class MessageContentContains(AutomodCondition): + """ + Check if the message content contains a number of substrings. + """ + + type: Literal["message_content_contains"] = "message_content_contains" + + contains: set[str] = Field(default_factory=set, min_length=1) + """ + The substrings to find. Unless `count` is specified, all substrings must be + found in order to pass. + """ -@dataclass -class MessageContentContains(AutomodConditionBase): + count: Optional[PositiveInt] = None """ - Check if message content contains a number of substrings. - - Attributes - ---------- - contains - The substrings to find. Unless `count` is specified, all substrings must be - found in order to pass. - count - The number of unique substrings to find. For example: a value of 1 requires any - of the substrings to be found, whereas a value of 2 requires at least 2 to be - found. If unspecified, all substrings must be found. - ignore_case - Whether to ignore upper vs lower case. - use_normalization - Whether to use unicode normalization or process the string as-is. - normalization_form - If enabled, the type of normalization to apply. Defaults to NFKD. + The number of unique substrings to find. For example: a value of 1 requires any + of the substrings to be found, whereas a value of 2 requires at least 2 to be + found. If empty, all substrings must be found. """ - contains: tuple[str] - count: Optional[int] = None ignore_case: Optional[bool] = None + """Whether to ignore upper vs lower case.""" + use_normalization: Optional[bool] = None - normalization_form: Optional[str] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - raw_contains = data["contains"] - if isinstance(raw_contains, str): - contains = [raw_contains] - else: - contains = [str(item) for item in raw_contains] - ignore_case = data.get("ignore_case") - if ignore_case: - contains = [item.lower() for item in contains] - return cls( - description=data.get("description"), - contains=contains, - count=data.get("count"), - ignore_case=ignore_case, - use_normalization=data.get("use_normalization"), - normalization_form=data.get("normalization_form"), - ) - - async def check(self, event: AutomodEvent) -> bool: - message = event.message - # Short-circuit if there's no message or the message is empty. - if not (message and message.content): + """Whether to use unicode normalization or process the string as-is.""" + + normalization_form: UnicodeNormalizationForms = Field(default="NFKD") + """If enabled, the type of normalization to apply. Defaults to NFKD.""" + + @model_validator(mode="after") + def _validate_model(self) -> Self: + if self.ignore_case: + self.contains = {s.lower() for s in self.contains} + return self + + @override + async def check(self, context: AutomodContext) -> bool: + # Return if the event has no message + message = context.event.message + if not message: return False - # Grab the message content. - content = str(message.content) - # Normalize the message content, if enabled. + + # Grab the message content and process it + content = message.content if self.use_normalization: - normalization_form = self.normalization_form or DEFAULT_NORMALIZATION_FORM - content = unicodedata.normalize(normalization_form, content) - # Convert the message content to lower-case, if we're ignoring case. + content = unicodedata.normalize(self.normalization_form, content) if self.ignore_case: content = content.lower() - # Check for a sufficient number of substrings. - remainder = self.count or len(self.contains) + + # Check for a sufficient number of substrings + required_matches = self.count or len(self.contains) + matches = 0 for substring in self.contains: - # If the substring is found, adjust the counter. if substring in content: - remainder -= 1 - # If the threshold is hit, return immediately instead of looking for - # further redundant matches. - if remainder <= 0: + matches += 1 + if matches == required_matches: return True - # If we get this far, it means we exhausted all of the potential matches without - # reaching the required number of matches. - return False - -def create_condition(data: JsonObject) -> AutomodCondition: - return MessageContentContains.from_data(data) + # If we got this far, we didn't have enough substring matches + return False diff --git a/commanderbot/ext/automod/conditions/message_content_matches.py b/commanderbot/ext/automod/conditions/message_content_matches.py index deb07f5..68e7ac4 100644 --- a/commanderbot/ext/automod/conditions/message_content_matches.py +++ b/commanderbot/ext/automod/conditions/message_content_matches.py @@ -1,93 +1,70 @@ +import re import unicodedata -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject, PatternWrapper +from pydantic import Field, PositiveInt -ST = TypeVar("ST") +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.lib.types import UnicodeNormalizationForms +__all__ = ("MessageContentMatches",) -DEFAULT_NORMALIZATION_FORM = "NFKD" +class MessageContentMatches(AutomodCondition): + """ + Check if the message content matches a number of regular expressions. + """ + + type: Literal["message_content_matches"] = "message_content_matches" + + matches: set[re.Pattern] = Field(default_factory=set, min_length=1) + """ + The patterns (regular expressions) to match. Unless `count` is specified, all + patterns must be matched in order to pass. + """ -@dataclass -class MessageContentMatches(AutomodConditionBase): + count: Optional[PositiveInt] = None """ - Check if message content matches a number of regular expressions. - - Attributes - ---------- - matches - The patterns (regular expressions) to match. Unless `count` is specified, all - patterns must be matched in order to pass. - count - The number of unique patterns to match. For example: a value of 1 requires any - of the patterns to be matched, whereas a value of requires at least 2 to be - matched. If unspecified, all patterns must be matched. - use_search - Whether to search the entire string instead of using an anchored match. - use_normalization - Whether to use unicode normalization or process the string as-is. - normalization_form - If enabled, the type of normalization to apply. Defaults to NFKD. + The number of unique patterns to match. For example: a value of 1 requires any + of the patterns to be matched, whereas a value of 2 requires at least 2 to be + matched. If unspecified, all patterns must be matched. """ - matches: tuple[PatternWrapper] - count: Optional[int] = None use_search: Optional[bool] = None + """Whether to search the entire string instead of using an anchored match.""" + use_normalization: Optional[bool] = None - normalization_form: Optional[str] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - raw_matches = data["matches"] - if isinstance(raw_matches, (str, dict)): - matches = [PatternWrapper.from_data(raw_matches)] - else: - matches = [PatternWrapper.from_data(item) for item in raw_matches] - return cls( - description=data.get("description"), - matches=matches, - count=data.get("count"), - use_search=data.get("use_search"), - use_normalization=data.get("use_normalization"), - normalization_form=data.get("normalization_form"), - ) - - def is_match(self, pattern: PatternWrapper, content: str) -> bool: + """Whether to use unicode normalization or process the string as-is.""" + + normalization_form: UnicodeNormalizationForms = Field(default="NFKD") + """If enabled, the type of normalization to apply. Defaults to NFKD.""" + + def is_match(self, pattern: re.Pattern, content: str) -> bool: if self.use_search: - match = pattern.search(content) - return bool(match) - match = pattern.match(content) - return bool(match) - - async def check(self, event: AutomodEvent) -> bool: - message = event.message - # Short-circuit if there's no message or the message is empty. - if not (message and message.content): + return pattern.search(content) is not None + return pattern.match(content) is not None + + @override + async def check(self, context: AutomodContext) -> bool: + # Return if the event has no message + message = context.event.message + if not message: return False - # Grab the message content. - content = str(message.content) - # Normalize the message content, if enabled. + + # Grab the message content and process it + content = message.content if self.use_normalization: - normalization_form = self.normalization_form or DEFAULT_NORMALIZATION_FORM - content = unicodedata.normalize(normalization_form, content) - # Check for a sufficient number of matches. - remainder = self.count or len(self.matches) + content = unicodedata.normalize(self.normalization_form, content) + + # Check for a sufficient number of matches + required_matches = self.count or len(self.matches) + matches = 0 for pattern in self.matches: - # If there's a match, adjust the counter and check if we're done. if self.is_match(pattern, content): - remainder -= 1 - if remainder <= 0: + matches += 1 + if matches == required_matches: return True - # If we got this far, there weren't enough matches. - return False - -def create_condition(data: JsonObject) -> AutomodCondition: - return MessageContentMatches.from_data(data) + # If we got this far, we didn't have enough matches + return False diff --git a/commanderbot/ext/automod/conditions/message_has_attachments.py b/commanderbot/ext/automod/conditions/message_has_attachments.py index 7e33f6a..20f8411 100644 --- a/commanderbot/ext/automod/conditions/message_has_attachments.py +++ b/commanderbot/ext/automod/conditions/message_has_attachments.py @@ -1,46 +1,29 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import IntegerRange, JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.guards import IntegerRangeGuard -ST = TypeVar("ST") +__all__ = ("MessageHasAttachments",) -@dataclass -class MessageHasAttachments(AutomodConditionBase): +class MessageHasAttachments(AutomodCondition): """ Check if the message has attachments. - - Attributes - ---------- - count - The number of attachments to check for, if bounded. """ - count: Optional[IntegerRange] = None + type: Literal["message_has_attachments"] = "message_has_attachments" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - count = IntegerRange.from_field_optional(data, "count") - return cls( - description=data.get("description"), - count=count, - ) + count: Optional[IntegerRangeGuard] = None + """The number of attachments to check for. If empty, the message only needs a single attachment.""" - async def check(self, event: AutomodEvent) -> bool: - message = event.message - if message is None: + @override + async def check(self, context: AutomodContext) -> bool: + message = context.event.message + if not message: return False - count_attachments = len(message.attachments or []) - if self.count is not None: - return self.count.includes(count_attachments) - return count_attachments > 0 - -def create_condition(data: JsonObject) -> AutomodCondition: - return MessageHasAttachments.from_data(data) + attachment_count = len(message.attachments) + if self.count: + return self.count.includes(attachment_count) + return attachment_count > 0 diff --git a/commanderbot/ext/automod/conditions/message_has_embeds.py b/commanderbot/ext/automod/conditions/message_has_embeds.py index bc867d8..4eaf5fc 100644 --- a/commanderbot/ext/automod/conditions/message_has_embeds.py +++ b/commanderbot/ext/automod/conditions/message_has_embeds.py @@ -1,46 +1,29 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import IntegerRange, JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.guards import IntegerRangeGuard -ST = TypeVar("ST") +__all__ = ("MessageHasEmbeds",) -@dataclass -class MessageHasEmbeds(AutomodConditionBase): +class MessageHasEmbeds(AutomodCondition): """ Check if the message has embeds. - - Attributes - ---------- - count - The number of embeds to check for, if bounded. """ - count: Optional[IntegerRange] = None + type: Literal["message_has_embeds"] = "message_has_embeds" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - count = IntegerRange.from_field_optional(data, "count") - return cls( - description=data.get("description"), - count=count, - ) + count: Optional[IntegerRangeGuard] = None + """The number of embeds to check for. If empty, the message only needs a single embed.""" - async def check(self, event: AutomodEvent) -> bool: - message = event.message - if message is None: + @override + async def check(self, context: AutomodContext) -> bool: + message = context.event.message + if not message: return False - count_embeds = len(message.embeds or []) - if self.count is not None: - return self.count.includes(count_embeds) - return count_embeds > 0 - -def create_condition(data: JsonObject) -> AutomodCondition: - return MessageHasEmbeds.from_data(data) + embed_count = len(message.embeds) + if self.count: + return self.count.includes(embed_count) + return embed_count > 0 diff --git a/commanderbot/ext/automod/conditions/message_has_links.py b/commanderbot/ext/automod/conditions/message_has_links.py index 071ddd9..0a88ea8 100644 --- a/commanderbot/ext/automod/conditions/message_has_links.py +++ b/commanderbot/ext/automod/conditions/message_has_links.py @@ -1,52 +1,36 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import IntegerRange, JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.guards import IntegerRangeGuard -ST = TypeVar("ST") +__all__ = ("MessageHasLinks",) -@dataclass -class MessageHasLinks(AutomodConditionBase): +class MessageHasLinks(AutomodCondition): """ Check if the message has links. - - Attributes - ---------- - count - The number of links to check for, if bounded. """ - # TODO Implement a configurable set of allowed domains? #enhance - # TODO Implement configurable unicode normalization? #enhance + type: Literal["message_has_links"] = "message_has_links" - count: Optional[IntegerRange] = None + count: Optional[IntegerRangeGuard] = None + """The number of links to check for. If empty, the message only needs a single link.""" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - count = IntegerRange.from_field_optional(data, "count") - return cls( - description=data.get("description"), - count=count, - ) + # TODO Implement a configurable set of allowed domains? #enhance + # TODO Implement configurable unicode normalization? #enhance - async def check(self, event: AutomodEvent) -> bool: - message = event.message - if (message is None) or not message.content: + @override + async def check(self, context: AutomodContext) -> bool: + message = context.event.message + if not message: return False - content = str(message.content) - count_http = content.count("http://") - count_https = content.count("https://") - count_links = count_http + count_https - if self.count is not None: - return self.count.includes(count_links) - return count_links > 0 + content = message.content + http_count = content.count("http://") + https_count = content.count("https://") + link_count = http_count + https_count -def create_condition(data: JsonObject) -> AutomodCondition: - return MessageHasLinks.from_data(data) + if self.count: + return self.count.includes(link_count) + return link_count > 0 diff --git a/commanderbot/ext/automod/conditions/message_has_mentions.py b/commanderbot/ext/automod/conditions/message_has_mentions.py index 57570a1..7af350e 100644 --- a/commanderbot/ext/automod/conditions/message_has_mentions.py +++ b/commanderbot/ext/automod/conditions/message_has_mentions.py @@ -1,51 +1,32 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import IntegerRange, JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.guards import IntegerRangeGuard -ST = TypeVar("ST") +__all__ = ("MessageHasMentions",) -@dataclass -class MessageHasMentions(AutomodConditionBase): +class MessageHasMentions(AutomodCondition): """ Check if the message has mentions. - - Attributes - ---------- - count - The number of mentions to check for, if bounded. """ - # TODO Implement a configurable set of allowed domains? #enhance - # TODO Implement configurable unicode normalization? #enhance - - count: Optional[IntegerRange] = None + type: Literal["message_has_mentions"] = "message_has_mentions" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - count = IntegerRange.from_field_optional(data, "count") - return cls( - description=data.get("description"), - count=count, - ) + count: Optional[IntegerRangeGuard] = None + """The number of mentions to check for. If empty, the message only needs a single mention.""" - async def check(self, event: AutomodEvent) -> bool: - message = event.message - if message is None: + @override + async def check(self, context: AutomodContext) -> bool: + message = context.event.message + if not message: return False - count_user_mentions = len(message.mentions) - count_role_mentions = len(message.role_mentions) - count_mentions = count_user_mentions + count_role_mentions - if self.count is not None: - return self.count.includes(count_mentions) - return count_mentions > 0 + role_mention_count = len(message.role_mentions) + user_mention_count = len(message.mentions) + mentions = role_mention_count + user_mention_count -def create_condition(data: JsonObject) -> AutomodCondition: - return MessageHasMentions.from_data(data) + if self.count: + return self.count.includes(mentions) + return mentions > 0 diff --git a/commanderbot/ext/automod/conditions/message_mentions_roles.py b/commanderbot/ext/automod/conditions/message_mentions_roles.py index 7968086..136a230 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_roles.py +++ b/commanderbot/ext/automod/conditions/message_mentions_roles.py @@ -1,63 +1,55 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject -from commanderbot.lib.guards import RolesGuard +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.guards import IntegerRangeGuard, RolesGuard -ST = TypeVar("ST") +__all__ = ("MessageMentionsRoles",) -@dataclass -class MessageMentionsRoles(AutomodConditionBase): +class MessageMentionsRoles(AutomodCondition): """ Check if the message contains role mentions. - - Attributes - ---------- - roles - The roles to match against. If empty, all roles will match. """ + type: Literal["message_mentions_roles"] = "message_mentions_roles" + roles: Optional[RolesGuard] = None + """The roles to match against. If empty, all roles will match.""" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - roles = RolesGuard.from_data(data.get("roles", {})) - return cls( - description=data.get("description"), - roles=roles, - ) + count: Optional[IntegerRangeGuard] = None + """The number of role mentions to check for. If empty, the message only needs a single role mention.""" - async def check(self, event: AutomodEvent) -> bool: - message = event.message - # Short-circuit if there's no message or the message is empty. - if not (message and message.content): + @override + async def check(self, context: AutomodContext) -> bool: + # Return if the event has no message + message = context.event.message + if not message: return False - # Short-circuit if the message does not mention any roles. + + # Return if the message has no role mentions if not message.role_mentions: return False - # Check if we care about any of the mentioned roles. - if self.roles is not None: + + # Check if we care about any of the mentioned roles + mentioned_roles = message.role_mentions + if self.roles: mentioned_roles = self.roles.filter_roles(message.role_mentions) - else: - mentioned_roles = message.role_mentions + + # Return if we have no role mentions or the number of role mentions is outside + # the range of the guard if not mentioned_roles: return False - event.set_metadata( - "mentioned_roles", - " ".join(role.mention for role in mentioned_roles), + if self.count and self.count.excludes(len(mentioned_roles)): + return False + + # Add mentioned roles metadata to context + context.set_metadata( + "mentioned_roles", " ".join((role.mention for role in mentioned_roles)) ) - event.set_metadata( - "mentioned_role_names", - " ".join(f"`{role}`" for role in mentioned_roles), + context.set_metadata( + "mentioned_role_names", " ".join((f"`{role}`" for role in mentioned_roles)) ) - return True - -def create_condition(data: JsonObject) -> AutomodCondition: - return MessageMentionsRoles.from_data(data) + # If we got this far, the message has role mentions we care about + return True diff --git a/commanderbot/ext/automod/conditions/message_mentions_users.py b/commanderbot/ext/automod/conditions/message_mentions_users.py index f168ece..9cd8f5a 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_users.py +++ b/commanderbot/ext/automod/conditions/message_mentions_users.py @@ -1,35 +1,55 @@ -from dataclasses import dataclass +from typing import Literal, Optional, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.guards import IntegerRangeGuard, RolesGuard +__all__ = ("MessageMentionsUsers",) -@dataclass -class MessageMentionsUsers(AutomodConditionBase): - """Check if the message contains user mentions.""" - async def check(self, event: AutomodEvent) -> bool: - message = event.message - # Short-circuit if there's no message or the message is empty. - if not (message and message.content): +class MessageMentionsUsers(AutomodCondition): + """ + Check if the message contains user mentions. + """ + + type: Literal["message_mentions_users"] = "message_mentions_users" + + roles: Optional[RolesGuard] = None + """The roles of the mentioned users to match against. If empty, all roles will match.""" + + count: Optional[IntegerRangeGuard] = None + """The number of user mentions to check for. If empty, the message only needs a single user mention.""" + + @override + async def check(self, context: AutomodContext) -> bool: + # Return if the event has no message + message = context.event.message + if not message: return False - # Short-circuit if the message does not mention any users. + + # Return if the message has no user mentions if not message.mentions: return False - event.set_metadata( - "mentioned_users", - " ".join(user.mention for user in message.mentions), + + # Check if we care about any of the mentioned users + mentioned_users = message.mentions + if self.roles: + mentioned_users = self.roles.filter_members(mentioned_users) + + # Return if we have no user mentions or the number of user mentions is outside + # the range of the guard + if not mentioned_users: + return False + if self.count and self.count.excludes(len(mentioned_users)): + return False + + # Add mentioned users metadata to context + context.set_metadata( + "mentioned_users", " ".join((user.mention for user in mentioned_users)) ) - event.set_metadata( - "mentioned_user_names", - " ".join(f"`{user}`" for user in message.mentions), + context.set_metadata( + "mentioned_user_names", " ".join((f"`{user}`" for user in mentioned_users)) ) - return True - -def create_condition(data: JsonObject) -> AutomodCondition: - return MessageMentionsUsers.from_data(data) + # If we got this far, the message has user mentions we care about + return True diff --git a/commanderbot/ext/automod/conditions/none_of.py b/commanderbot/ext/automod/conditions/none_of.py index 6e5fffa..16f1546 100644 --- a/commanderbot/ext/automod/conditions/none_of.py +++ b/commanderbot/ext/automod/conditions/none_of.py @@ -1,45 +1,31 @@ -from dataclasses import dataclass -from typing import Type, TypeVar +from typing import TYPE_CHECKING, Literal, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, - deserialize_conditions, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from pydantic import Field -ST = TypeVar("ST") +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +__all__ = ("NoneOf",) + +if TYPE_CHECKING: + from commanderbot.ext.automod.condition import AutomodConditionType -@dataclass -class NoneOf(AutomodConditionBase): - """ - Passes if and only if none of the sub-conditions pass. - Attributes - ---------- - conditions - The sub-conditions to check. +class NoneOf(AutomodCondition): + """ + Check if no sub-conditions pass. """ - conditions: tuple[AutomodCondition] + type: Literal["none_of"] = "none_of" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - raw_conditions = data["conditions"] - conditions = deserialize_conditions(raw_conditions) - return cls( - description=data.get("description"), - conditions=conditions, - ) + conditions: list[AutomodConditionType] = Field(default_factory=list, min_length=1) + """The sub-conditions to check.""" - async def check(self, event: AutomodEvent) -> bool: + @override + async def check(self, context: AutomodContext) -> bool: for condition in self.conditions: - if await condition.check(event): + if condition.disabled: + continue + if await condition.check(context): return False return True - - -def create_condition(data: JsonObject) -> AutomodCondition: - return NoneOf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/not.py b/commanderbot/ext/automod/conditions/not.py deleted file mode 100644 index a208b66..0000000 --- a/commanderbot/ext/automod/conditions/not.py +++ /dev/null @@ -1,45 +0,0 @@ -from dataclasses import dataclass -from typing import Type, TypeVar - -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, - deserialize_conditions, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject - -ST = TypeVar("ST") - - -@dataclass -class Not(AutomodConditionBase): - """ - Passes if any of the sub-conditions fail. - - Attributes - ---------- - conditions - The sub-conditions to check. - """ - - conditions: tuple[AutomodCondition] - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - raw_conditions = data["conditions"] - conditions = deserialize_conditions(raw_conditions) - return cls( - description=data.get("description"), - conditions=conditions, - ) - - async def check(self, event: AutomodEvent) -> bool: - for condition in self.conditions: - if not await condition.check(event): - return True - return False - - -def create_condition(data: JsonObject) -> AutomodCondition: - return Not.from_data(data) diff --git a/commanderbot/ext/automod/conditions/random_chance.py b/commanderbot/ext/automod/conditions/random_chance.py new file mode 100644 index 0000000..8f41a97 --- /dev/null +++ b/commanderbot/ext/automod/conditions/random_chance.py @@ -0,0 +1,24 @@ +import random +from typing import Literal, override + +from pydantic import Field + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition + +__all__ = ("RandomChance",) + + +class RandomChance(AutomodCondition): + """ + Generate a random number between `0.0` and `1.0` and check if it's below `chance`. + """ + + type: Literal["random_chance"] = "random_chance" + + chance: float = Field(ge=0.0, le=1.0) + """A number between `0.0` and `1.0` (inclusive).""" + + @override + async def check(self, context: AutomodContext) -> bool: + return random.random() < self.chance diff --git a/commanderbot/ext/automod/conditions/thread_auto_archive_duration.py b/commanderbot/ext/automod/conditions/thread_auto_archive_duration.py deleted file mode 100644 index 267a3d2..0000000 --- a/commanderbot/ext/automod/conditions/thread_auto_archive_duration.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass -from typing import Type, TypeVar - -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import IntegerRange, JsonObject - -ST = TypeVar("ST") - - -@dataclass -class ThreadAutoArchiveDuration(AutomodConditionBase): - """ - Passes if the thread's auto archive duration is within the given range. - - Attributes - ---------- - auto_archive_duration - The range of the auto archive duration to check. - """ - - auto_archive_duration: IntegerRange - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - auto_archive_duration = IntegerRange.from_field(data, "auto_archive_duration") - return cls( - description=data.get("description"), - auto_archive_duration=auto_archive_duration, - ) - - async def check(self, event: AutomodEvent) -> bool: - if thread := event.thread: - return self.auto_archive_duration.includes(thread.auto_archive_duration) - return False - - -def create_condition(data: JsonObject) -> AutomodCondition: - return ThreadAutoArchiveDuration.from_data(data) diff --git a/commanderbot/ext/automod/conditions/throw_error.py b/commanderbot/ext/automod/conditions/throw_error.py index 2d52a3c..2e2da04 100644 --- a/commanderbot/ext/automod/conditions/throw_error.py +++ b/commanderbot/ext/automod/conditions/throw_error.py @@ -1,31 +1,21 @@ -from dataclasses import dataclass +from typing import Literal, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +__all__ = ("ThrowError",) -@dataclass -class ThrowError(AutomodConditionBase): + +class ThrowError(AutomodCondition): """ Throw an error when checking the condition. - - Intended for testing and debugging. - - Attributes - ---------- - error - A human-readable error message. """ + type: Literal["throw_error"] = "throw_error" + error: str + """A human-readable error message.""" - async def check(self, event: AutomodEvent) -> bool: + @override + async def check(self, context: AutomodContext) -> bool: raise Exception(self.error) - - -def create_condition(data: JsonObject) -> AutomodCondition: - return ThrowError.from_data(data) diff --git a/commanderbot/ext/automod/conditions/wait.py b/commanderbot/ext/automod/conditions/wait.py index 05e6136..e8ea32f 100644 --- a/commanderbot/ext/automod/conditions/wait.py +++ b/commanderbot/ext/automod/conditions/wait.py @@ -1,43 +1,26 @@ import asyncio -from dataclasses import dataclass -from datetime import timedelta -from typing import Type, TypeVar +from typing import Literal, override -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject, utils +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.lib.types import Timedelta -ST = TypeVar("ST") +__all__ = ("Wait",) -@dataclass -class Wait(AutomodConditionBase): +class Wait(AutomodCondition): """ Wait a certain amount of time before continuing. - - Attributes - ---------- - delay - How long to wait for. """ - delay: timedelta + type: Literal["wait"] = "wait" - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - delay = utils.timedelta_from_field_optional(data, "delay") - return cls( - description=data.get("description"), - delay=delay, - ) + delay: Timedelta + """ + How long to wait for. + """ - async def check(self, event: AutomodEvent) -> bool: + @override + async def check(self, context: AutomodContext) -> bool: await asyncio.sleep(self.delay.total_seconds()) return True - - -def create_condition(data: JsonObject) -> AutomodCondition: - return Wait.from_data(data) diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index b455a15..60b43a5 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -1,10 +1,10 @@ from typing import Optional -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field -from commanderbot.ext.automod.event import AutomodEvent from commanderbot.ext.automod.automod_context import AutomodContext -from commanderbot.ext.automod.trigger import AutomodTriggerCollection +from commanderbot.ext.automod.condition import AutomodConditionType +from commanderbot.ext.automod.trigger import AutomodTriggerType __all__ = ("AutomodRule",) @@ -25,26 +25,35 @@ class AutomodRule(BaseModel): disabled: Optional[bool] = None """Is the rule disabled?""" - triggers: AutomodTriggerCollection + triggers: list[AutomodTriggerType] = Field(default_factory=list, min_length=1) """A list of triggers that cause the rule to run.""" + conditions: list[AutomodConditionType] = Field(default_factory=list) + """A list of conditions that must *all* pass for the actions to run.""" + async def _poll_triggers(self, context: AutomodContext) -> bool: for trigger in self.triggers: + if trigger.disabled: + continue if await trigger.poll(context): return True return False async def _check_conditions(self, context: AutomodContext) -> bool: + for condition in self.conditions: + if condition.disabled: + continue + if not await condition.check(context): + return False return True async def _apply_actions(self, context: AutomodContext): return - async def run(self, event: AutomodEvent) -> bool: + async def run(self, context: AutomodContext) -> bool: if self.disabled: return False - context = AutomodContext(self, event) if not await self._poll_triggers(context): return False @@ -53,3 +62,7 @@ async def run(self, event: AutomodEvent) -> bool: await self._apply_actions(context) return True + + +# We need to rebuild the model because the conditions and actions have circular references +AutomodRule.model_rebuild() From c7480837a189671fcea1e58bff672bb3d0e59f43 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 8 Jul 2026 19:03:51 -0700 Subject: [PATCH 038/158] Added missing `await` --- commanderbot/ext/automod/rule/rule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index 60b43a5..a54579b 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -57,7 +57,7 @@ async def run(self, context: AutomodContext) -> bool: if not await self._poll_triggers(context): return False - if not self._check_conditions(context): + if not await self._check_conditions(context): return False await self._apply_actions(context) From 709d27038982f55b2cdafcf4f58c87e85929c88e Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sun, 12 Jul 2026 04:37:03 -0700 Subject: [PATCH 039/158] Updated comment --- commanderbot/ext/automod/guards/roles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/guards/roles.py b/commanderbot/ext/automod/guards/roles.py index 0cec068..30e81ca 100644 --- a/commanderbot/ext/automod/guards/roles.py +++ b/commanderbot/ext/automod/guards/roles.py @@ -11,7 +11,7 @@ class RolesGuard(BaseModel): """ - Checks whether a member/role matches a set of roles. + Checks whether a role or a member's roles matches a set of roles. """ model_config = ConfigDict(use_attribute_docstrings=True) From 8c3d70d5e8b537670fe29d17b382564214c8aff8 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sun, 12 Jul 2026 04:37:31 -0700 Subject: [PATCH 040/158] Updated comments --- commanderbot/ext/automod/conditions/actor_is_bot.py | 2 +- commanderbot/ext/automod/conditions/actor_is_not_bot.py | 2 +- commanderbot/ext/automod/conditions/actor_roles.py | 2 +- commanderbot/ext/automod/conditions/author_is_bot.py | 2 +- commanderbot/ext/automod/conditions/author_is_not_bot.py | 2 +- .../ext/automod/conditions/author_is_not_self.py | 2 +- commanderbot/ext/automod/conditions/author_is_self.py | 2 +- commanderbot/ext/automod/conditions/author_roles.py | 2 +- .../ext/automod/conditions/message_content_contains.py | 9 ++++----- commanderbot/ext/automod/conditions/throw_error.py | 2 ++ 10 files changed, 14 insertions(+), 13 deletions(-) diff --git a/commanderbot/ext/automod/conditions/actor_is_bot.py b/commanderbot/ext/automod/conditions/actor_is_bot.py index 0acb6c9..a7ebbd9 100644 --- a/commanderbot/ext/automod/conditions/actor_is_bot.py +++ b/commanderbot/ext/automod/conditions/actor_is_bot.py @@ -11,7 +11,7 @@ class ActorIsBot(TargetIsBot): """ - Check if the actor in context is a bot. + Check if the actor is a bot. """ type: Literal["actor_is_bot"] = "actor_is_bot" diff --git a/commanderbot/ext/automod/conditions/actor_is_not_bot.py b/commanderbot/ext/automod/conditions/actor_is_not_bot.py index 14d401d..96e78ac 100644 --- a/commanderbot/ext/automod/conditions/actor_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/actor_is_not_bot.py @@ -11,7 +11,7 @@ class ActorIsNotBot(TargetIsNotBot): """ - Check if the actor in context is not a bot. + Check if the actor is not a bot. """ type: Literal["actor_is_not_bot"] = "actor_is_not_bot" diff --git a/commanderbot/ext/automod/conditions/actor_roles.py b/commanderbot/ext/automod/conditions/actor_roles.py index 3caca4a..617fe3a 100644 --- a/commanderbot/ext/automod/conditions/actor_roles.py +++ b/commanderbot/ext/automod/conditions/actor_roles.py @@ -11,7 +11,7 @@ class ActorRoles(TargetRoles): """ - Check if the actor in context has certain roles. + Check if the actor has certain roles. """ type: Literal["actor_roles"] = "actor_roles" diff --git a/commanderbot/ext/automod/conditions/author_is_bot.py b/commanderbot/ext/automod/conditions/author_is_bot.py index 1fa72ab..c8c9c63 100644 --- a/commanderbot/ext/automod/conditions/author_is_bot.py +++ b/commanderbot/ext/automod/conditions/author_is_bot.py @@ -11,7 +11,7 @@ class AuthorIsBot(TargetIsBot): """ - Check if the author in context is a bot. + Check if the author is a bot. """ type: Literal["author_is_bot"] = "author_is_bot" diff --git a/commanderbot/ext/automod/conditions/author_is_not_bot.py b/commanderbot/ext/automod/conditions/author_is_not_bot.py index 34d4aaa..a12e1e7 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/author_is_not_bot.py @@ -11,7 +11,7 @@ class AuthorIsNotBot(TargetIsNotBot): """ - Check if the author in context is not a bot. + Check if the author is not a bot. """ type: Literal["author_is_not_bot"] = "author_is_not_bot" diff --git a/commanderbot/ext/automod/conditions/author_is_not_self.py b/commanderbot/ext/automod/conditions/author_is_not_self.py index 0ba3a56..875c79b 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_self.py +++ b/commanderbot/ext/automod/conditions/author_is_not_self.py @@ -11,7 +11,7 @@ class AuthorIsNotSelf(TargetIsSelf): """ - Check if the author in context is not the bot itself. + Check if the author is not the bot itself. """ type: Literal["author_is_not_self"] = "author_is_not_self" diff --git a/commanderbot/ext/automod/conditions/author_is_self.py b/commanderbot/ext/automod/conditions/author_is_self.py index 73174db..4b1e19d 100644 --- a/commanderbot/ext/automod/conditions/author_is_self.py +++ b/commanderbot/ext/automod/conditions/author_is_self.py @@ -11,7 +11,7 @@ class AuthorIsSelf(TargetIsSelf): """ - Check if the author in context is the bot itself. + Check if the author is the bot itself. """ type: Literal["author_is_self"] = "author_is_self" diff --git a/commanderbot/ext/automod/conditions/author_roles.py b/commanderbot/ext/automod/conditions/author_roles.py index 5313195..73192c2 100644 --- a/commanderbot/ext/automod/conditions/author_roles.py +++ b/commanderbot/ext/automod/conditions/author_roles.py @@ -11,7 +11,7 @@ class AuthorRoles(TargetRoles): """ - Check if the author in context has certain roles. + Check if the author has certain roles. """ type: Literal["author_roles"] = "author_roles" diff --git a/commanderbot/ext/automod/conditions/message_content_contains.py b/commanderbot/ext/automod/conditions/message_content_contains.py index eb711b4..ff444ea 100644 --- a/commanderbot/ext/automod/conditions/message_content_contains.py +++ b/commanderbot/ext/automod/conditions/message_content_contains.py @@ -1,7 +1,7 @@ import unicodedata -from typing import Literal, Optional, Self, override +from typing import Any, Literal, Optional, override -from pydantic import Field, PositiveInt, model_validator +from pydantic import Field, PositiveInt from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.condition import AutomodCondition @@ -39,11 +39,10 @@ class MessageContentContains(AutomodCondition): normalization_form: UnicodeNormalizationForms = Field(default="NFKD") """If enabled, the type of normalization to apply. Defaults to NFKD.""" - @model_validator(mode="after") - def _validate_model(self) -> Self: + @override + def model_post_init(self, context: Any): if self.ignore_case: self.contains = {s.lower() for s in self.contains} - return self @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/throw_error.py b/commanderbot/ext/automod/conditions/throw_error.py index 2e2da04..90274d5 100644 --- a/commanderbot/ext/automod/conditions/throw_error.py +++ b/commanderbot/ext/automod/conditions/throw_error.py @@ -9,6 +9,8 @@ class ThrowError(AutomodCondition): """ Throw an error when checking the condition. + + Intended for testing and debugging. """ type: Literal["throw_error"] = "throw_error" From 47dd0f4e41017b7f7669e854af823122a63dadaf Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sun, 12 Jul 2026 04:38:42 -0700 Subject: [PATCH 041/158] Added a constant that's `True` if OCR is supported --- commanderbot/lib/constants.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/commanderbot/lib/constants.py b/commanderbot/lib/constants.py index 532f559..0ff3654 100644 --- a/commanderbot/lib/constants.py +++ b/commanderbot/lib/constants.py @@ -6,6 +6,7 @@ "COMMANDERBOT_VERSION", "DISCORD_PY_VERSION", "PYTHON_VERSION", + "SUPPORTS_OCR", "USER_AGENT", "MAX_MESSAGE_LENGTH", "MAX_EMBED_TITLE_LENGTH", @@ -23,6 +24,13 @@ f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}" ) +try: + import tesserocr +except: + SUPPORTS_OCR: bool = False +else: + SUPPORTS_OCR: bool = True + USER_AGENT: str = ( f"CommanderBuff/{COMMANDERBOT_VERSION} (By the Minecraft Commands community)" ) From 71bd62ba5dd214d074807062753fa2ea099cb4e7 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sun, 12 Jul 2026 04:40:44 -0700 Subject: [PATCH 042/158] `is_user` now returns `True` if a `Member` is passed to it. Members are basically a "subclass" or users. --- commanderbot/lib/predicates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/lib/predicates.py b/commanderbot/lib/predicates.py index aaa5f61..f4980d2 100644 --- a/commanderbot/lib/predicates.py +++ b/commanderbot/lib/predicates.py @@ -107,7 +107,7 @@ def is_convertable_to(obj: Any, ty: Any) -> bool: def is_user(obj: object) -> TypeIs[User]: - return isinstance(obj, User) + return isinstance(obj, (User, Member)) def is_member(obj: object) -> TypeIs[Member]: From 684646b84809f001067802637396588caf33f2c2 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sun, 12 Jul 2026 04:41:27 -0700 Subject: [PATCH 043/158] `AutomodContext` can now format strings with context data --- commanderbot/ext/automod/automod_context.py | 154 +++++++++++++++++++- commanderbot/ext/automod/types.py | 104 +++++++++++++ 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 commanderbot/ext/automod/types.py diff --git a/commanderbot/ext/automod/automod_context.py b/commanderbot/ext/automod/automod_context.py index 308dc93..49cfd9f 100644 --- a/commanderbot/ext/automod/automod_context.py +++ b/commanderbot/ext/automod/automod_context.py @@ -1,10 +1,17 @@ +import string +from collections import defaultdict from dataclasses import dataclass, field +from itertools import chain from logging import Logger -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Iterable, cast +from discord import Member, User from discord.ext.commands import Bot +from discord.utils import format_dt, utcnow from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.types import ContextFields +from commanderbot.lib.predicates import is_member __all__ = ("AutomodContext",) @@ -12,6 +19,8 @@ from commanderbot.ext.automod.automod_guild_state import AutomodGuildState from commanderbot.ext.automod.rule import AutomodRule +SAFE_TYPES: tuple[type, ...] = (bool, int, float, str) + @dataclass class AutomodContext: @@ -41,3 +50,146 @@ def set_metadata(self, key: str, value: Any): def remove_metadata(self, key: str): """Remove metadata from the execution context""" del self._metadata[key] + + def get_fields(self, *, unsafe: bool = False) -> dict[ContextFields, Any]: + """Get the full context data as key/value pairs.""" + if unsafe: + return dict(chain(self._yield_safe_fields(), self._yield_unsafe_fields())) # type: ignore + return dict(self._yield_safe_fields()) # type: ignore + + def format_content( + self, content: str, *, default="`Unknown`", unsafe: bool = False + ) -> str: + """Format a string using context data.""" + fields: defaultdict[str, Any] = defaultdict(lambda: default) + fields |= self.get_fields(unsafe=unsafe) + + template = string.Template(content) + return template.safe_substitute(fields) + + def _is_value_safe(self, value: Any) -> bool: + return type(value) in SAFE_TYPES + + def _yield_safe_fields(self) -> Iterable[tuple[str, Any]]: + # Yield channel fields + if channel := self.event.channel: + yield ("channel_id", channel.id) + yield ("channel_name", channel.name) + yield ("channel_mention", channel.mention) + + # Yield thread fields + if thread := self.event.thread: + yield ("thread_id", thread.id) + yield ("thread_name", thread.name) + yield ("thread_mention", thread.mention) + yield ("thread_archived", thread.archived) + yield ("thread_locked", thread.locked) + yield ("thread_slowmode_delay", thread.slowmode_delay) + yield ("thread_auto_archive_duration", thread.auto_archive_duration) + if thread_owner := thread.owner: + yield from self._yield_safe_member_fields(thread_owner, "thread_owner") + + # Yield category fields + if category := self.event.category: + yield ("category_id", category.id) + yield ("category_name", category.name) + yield ("category_mention", category.mention) + + # Yield message fields + if message := self.event.message: + yield ("message_id", message.id) + yield ("message_content", message.content) + yield ("message_clean_content", message.clean_content) + yield ("message_jump_url", message.jump_url) + + # Yield reaction fields + if reaction := self.event.reaction: + yield ("reaction_emoji", reaction.emoji) + yield ("reaction_count", reaction.count) + yield ("reaction_normal_count", reaction.normal_count) + yield ("reaction_burst_count", reaction.burst_count) + + # Yield author fields + if author := self.event.author: + if is_member(author): + yield from self._yield_safe_member_fields(author, "author") + else: + yield from self._yield_safe_user_fields(author, "author") + + # Yield actor fields + if actor := self.event.actor: + if is_member(actor): + yield from self._yield_safe_member_fields(actor, "actor") + else: + yield from self._yield_safe_user_fields(actor, "actor") + + # Yield member fields + if member := self.event.member: + yield from self._yield_safe_member_fields(member, "member") + + # Yield user fields + if user := self.event.user: + yield from self._yield_safe_user_fields(user, "user") + + # Yield metadata fields + yield from ((k, v) for k, v in self._metadata.items() if self._is_value_safe(v)) + + def _yield_safe_user_fields( + self, user: User, prefix: str + ) -> Iterable[tuple[str, Any]]: + yield (f"{prefix}_id", user.id) + yield (f"{prefix}_name", user.name) + yield (f"{prefix}_display_name", user.display_name) + yield (f"{prefix}_mention", user.mention) + yield (f"{prefix}_created_at", format_dt(user.created_at, style="R")) + + def _yield_safe_member_fields( + self, member: Member, prefix: str + ) -> Iterable[tuple[str, Any]]: + yield from self._yield_safe_user_fields(cast(User, member), prefix) + + if nick := member.nick: + yield (f"{prefix}_nick", nick) + + if joined_at := member.joined_at: + yield (f"{prefix}_joined_at", format_dt(joined_at, style="R")) + + member_for = utcnow() - joined_at + if member_for.days < 7: + hh = int(member_for.total_seconds() / 3600) + mm = int(member_for.total_seconds() / 60) % 60 + yield (f"{prefix}_member_for", f"{hh} hours, {mm} minutes") + else: + yield (f"{prefix}_member_for", f"{member_for.days} days") + + def _yield_unsafe_fields(self) -> Iterable[tuple[str, Any]]: + if channel := self.event.channel: + yield ("channel", channel) + + if thread := self.event.thread: + yield ("thread", thread) + + if category := self.event.category: + yield ("category", category) + + if message := self.event.message: + yield ("message", message) + + if reaction := self.event.reaction: + yield ("reaction", reaction) + + if author := self.event.author: + yield ("author", author) + + if actor := self.event.actor: + yield ("actor", actor) + + if member := self.event.member: + yield ("member", member) + + if user := self.event.user: + yield ("user", user) + + yield from ( + (k, v) for k, v in self._metadata.items() if not self._is_value_safe(v) + ) diff --git a/commanderbot/ext/automod/types.py b/commanderbot/ext/automod/types.py new file mode 100644 index 0000000..d406406 --- /dev/null +++ b/commanderbot/ext/automod/types.py @@ -0,0 +1,104 @@ +from concurrent.futures import thread +from typing import Literal + +__all__ = ( + "ContextEventFields", + "ContextMetadataFields", + "ContextFields", +) + + +type ContextEventFields = Literal[ + "channel_id", + "channel_name", + "channel_mention", + + "thread_id", + "thread_name", + "thread_mention", + "thread_archived", + "thread_locked", + "thread_slowmode_delay", + "thread_auto_archive_duration", + "thread_owner", + + "category_id", + "category_name", + "category_mention", + + "message_id", + "message_content", + "message_clean_content", + "message_jump_url", + + "reaction_emoji", + "reaction_count", + "reaction_normal_count", + "reaction_burst_count", + + "author_id", + "author_name", + "author_display_name", + "author_mention", + "author_nick", + "author_joined_at", + "author_member_for", + "author_created_at", + + "actor_id", + "actor_name", + "actor_display_name", + "actor_mention", + "actor_nick", + "actor_joined_at", + "actor_member_for", + "actor_created_at", + + "member_id", + "member_name", + "member_display_name", + "member_mention", + "member_nick", + "member_joined_at", + "member_member_for", + "member_created_at", + + "thread_owner_id", + "thread_owner_name", + "thread_owner_display_name", + "thread_owner_mention", + "thread_owner_nick", + "thread_owner_joined_at", + "thread_owner_member_for", + "thread_owner_created_at", + + "user_id", + "user_name", + "user_display_name", + "user_mention", + "user_created_at", + + "channel" + "thread" + "category" + "message" + "reaction" + "author" + "actor" + "member" +] + +type ContextMetadataFields = Literal[ + "mentioned_roles", + "mentioned_role_names", + "mentioned_users", + "mentioned_user_names", + "removed_mentions", + "removed_mention_names", + "removed_user_mention_names", + "removed_user_mentions", + "removed_role_mentions", + "removed_role_mention_names", +] + +type ContextFields = ContextEventFields | ContextMetadataFields From 16283a955dfc4d16c3805f265ee807ffbdcd5963 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 14 Jul 2026 11:17:40 -0700 Subject: [PATCH 044/158] Added more conditions --- commanderbot/ext/automod/condition/types.py | 3 + .../ext/automod/conditions/__init__.py | 3 + .../ext/automod/conditions/randomize.py | 72 +++++++++++++++++++ .../ext/automod/conditions/return_false.py | 18 +++++ .../ext/automod/conditions/return_true.py | 18 +++++ 5 files changed, 114 insertions(+) create mode 100644 commanderbot/ext/automod/conditions/randomize.py create mode 100644 commanderbot/ext/automod/conditions/return_false.py create mode 100644 commanderbot/ext/automod/conditions/return_true.py diff --git a/commanderbot/ext/automod/condition/types.py b/commanderbot/ext/automod/condition/types.py index 45ba520..939ac6e 100644 --- a/commanderbot/ext/automod/condition/types.py +++ b/commanderbot/ext/automod/condition/types.py @@ -36,6 +36,9 @@ | conditions.MessageMentionsUsers | conditions.NoneOf | conditions.RandomChance + | conditions.Randomize + | conditions.ReturnFalse + | conditions.ReturnTrue | conditions.ThrowError | conditions.Wait ) diff --git a/commanderbot/ext/automod/conditions/__init__.py b/commanderbot/ext/automod/conditions/__init__.py index 5f72b75..71fdbfa 100644 --- a/commanderbot/ext/automod/conditions/__init__.py +++ b/commanderbot/ext/automod/conditions/__init__.py @@ -27,5 +27,8 @@ from .message_mentions_users import * from .none_of import * from .random_chance import * +from .randomize import * +from .return_false import * +from .return_true import * from .throw_error import * from .wait import * diff --git a/commanderbot/ext/automod/conditions/randomize.py b/commanderbot/ext/automod/conditions/randomize.py new file mode 100644 index 0000000..6fd2f14 --- /dev/null +++ b/commanderbot/ext/automod/conditions/randomize.py @@ -0,0 +1,72 @@ +import random +from typing import TYPE_CHECKING, Literal, Optional, override + +from pydantic import BaseModel, ConfigDict, Field, PositiveInt + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition + +__all__ = ("Randomize",) + +if TYPE_CHECKING: + from commanderbot.ext.automod.condition import AutomodConditionType + + +class WeightedCondition(BaseModel): + """ + A sub-condition with an optional weight. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + condition: AutomodConditionType + """The sub-condition to check.""" + + weight: PositiveInt = 1 + """The weight of the sub-condition. Defaults to `1`.""" + + +class Randomize(AutomodCondition): + """ + Check a random sub-condition. + """ + + type: Literal["randomize"] = "randomize" + + conditions: list[AutomodConditionType | WeightedCondition] = Field( + default_factory=list, min_length=1 + ) + """The sub-conditions to pick from.""" + + def _roll(self) -> Optional[AutomodConditionType]: + # Sum all the weights + total_weight: int = 0 + for entry in self.conditions: + if isinstance(entry, WeightedCondition): + total_weight += entry.weight + else: + total_weight += 1 + + # Pick a number between 0 and `total_weight` + target_weight = random.randint(0, total_weight) + + # Get the first entry that causes `current_weight` to be + # greater than or equal to `target_weight` + current_weight: int = 0 + for entry in self.conditions: + condition: Optional[AutomodConditionType] = None + if isinstance(entry, WeightedCondition): + condition = entry.condition + current_weight += entry.weight + else: + condition = entry + current_weight += 1 + + if current_weight >= target_weight: + return condition + + @override + async def check(self, context: AutomodContext) -> bool: + if (condition := self._roll()) and not condition.disabled: + return await condition.check(context) + return False diff --git a/commanderbot/ext/automod/conditions/return_false.py b/commanderbot/ext/automod/conditions/return_false.py new file mode 100644 index 0000000..61e6df3 --- /dev/null +++ b/commanderbot/ext/automod/conditions/return_false.py @@ -0,0 +1,18 @@ +from typing import Literal, override + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition + +__all__ = ("ReturnFalse",) + + +class ReturnFalse(AutomodCondition): + """ + A condition that is always false. + """ + + type: Literal["return_false"] = "return_false" + + @override + async def check(self, context: AutomodContext) -> bool: + return False diff --git a/commanderbot/ext/automod/conditions/return_true.py b/commanderbot/ext/automod/conditions/return_true.py new file mode 100644 index 0000000..31c8fc2 --- /dev/null +++ b/commanderbot/ext/automod/conditions/return_true.py @@ -0,0 +1,18 @@ +from typing import Literal, override + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition + +__all__ = ("ReturnTrue",) + + +class ReturnTrue(AutomodCondition): + """ + A condition that is always true. + """ + + type: Literal["return_true"] = "return_true" + + @override + async def check(self, context: AutomodContext) -> bool: + return True From e36d9beb0ec90eb6b3ea843d4b2cb96b2a0baf61 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 14 Jul 2026 14:06:24 -0700 Subject: [PATCH 045/158] Changed some default values --- commanderbot/ext/automod/conditions/actor_account_age.py | 2 +- commanderbot/ext/automod/conditions/actor_has_all_flags.py | 2 +- commanderbot/ext/automod/conditions/actor_has_any_flags.py | 2 +- commanderbot/ext/automod/conditions/actor_is_bot.py | 2 +- commanderbot/ext/automod/conditions/actor_is_not_bot.py | 2 +- commanderbot/ext/automod/conditions/actor_member_for.py | 2 +- commanderbot/ext/automod/conditions/actor_roles.py | 2 +- commanderbot/ext/automod/conditions/all_of.py | 2 +- commanderbot/ext/automod/conditions/any_of.py | 2 +- commanderbot/ext/automod/conditions/author_account_age.py | 2 +- commanderbot/ext/automod/conditions/author_has_all_flags.py | 2 +- commanderbot/ext/automod/conditions/author_has_any_flags.py | 2 +- commanderbot/ext/automod/conditions/author_is_bot.py | 2 +- commanderbot/ext/automod/conditions/author_is_not_bot.py | 2 +- commanderbot/ext/automod/conditions/author_is_not_self.py | 2 +- commanderbot/ext/automod/conditions/author_is_self.py | 2 +- commanderbot/ext/automod/conditions/author_member_for.py | 2 +- commanderbot/ext/automod/conditions/author_roles.py | 2 +- commanderbot/ext/automod/conditions/inverted.py | 2 +- .../ext/automod/conditions/message_content_contains.py | 6 +++--- .../ext/automod/conditions/message_content_matches.py | 6 +++--- .../ext/automod/conditions/message_has_attachments.py | 2 +- commanderbot/ext/automod/conditions/message_has_embeds.py | 2 +- commanderbot/ext/automod/conditions/message_has_links.py | 2 +- commanderbot/ext/automod/conditions/message_has_mentions.py | 2 +- .../ext/automod/conditions/message_mentions_roles.py | 2 +- .../ext/automod/conditions/message_mentions_users.py | 2 +- commanderbot/ext/automod/conditions/none_of.py | 2 +- commanderbot/ext/automod/conditions/random_chance.py | 2 +- commanderbot/ext/automod/conditions/randomize.py | 2 +- commanderbot/ext/automod/conditions/return_false.py | 2 +- commanderbot/ext/automod/conditions/return_true.py | 2 +- commanderbot/ext/automod/conditions/throw_error.py | 2 +- commanderbot/ext/automod/conditions/wait.py | 2 +- .../triggers/discord_automod/blocked_member_interactions.py | 4 +--- .../ext/automod/triggers/discord_automod/blocked_message.py | 2 +- .../ext/automod/triggers/discord_automod/timed_out.py | 2 +- commanderbot/ext/automod/triggers/member_gained_flags.py | 2 +- commanderbot/ext/automod/triggers/member_joined.py | 2 +- commanderbot/ext/automod/triggers/member_left.py | 2 +- commanderbot/ext/automod/triggers/member_lost_flags.py | 2 +- commanderbot/ext/automod/triggers/member_typing.py | 2 +- .../ext/automod/triggers/mentions_removed_from_message.py | 2 +- commanderbot/ext/automod/triggers/message.py | 2 +- commanderbot/ext/automod/triggers/message_deleted.py | 2 +- commanderbot/ext/automod/triggers/message_edited.py | 2 +- commanderbot/ext/automod/triggers/message_sent.py | 2 +- commanderbot/ext/automod/triggers/reaction.py | 2 +- commanderbot/ext/automod/triggers/reaction_added.py | 2 +- commanderbot/ext/automod/triggers/reaction_removed.py | 2 +- commanderbot/ext/automod/triggers/thread_created.py | 2 +- commanderbot/ext/automod/triggers/thread_deleted.py | 2 +- commanderbot/ext/automod/triggers/user_banned.py | 2 +- commanderbot/ext/automod/triggers/user_unbanned.py | 2 +- 54 files changed, 58 insertions(+), 60 deletions(-) diff --git a/commanderbot/ext/automod/conditions/actor_account_age.py b/commanderbot/ext/automod/conditions/actor_account_age.py index 615fe36..91642b2 100644 --- a/commanderbot/ext/automod/conditions/actor_account_age.py +++ b/commanderbot/ext/automod/conditions/actor_account_age.py @@ -14,7 +14,7 @@ class ActorAccountAge(TargetAccountAge): Check if the actor's account is a certain age. """ - type: Literal["actor_account_age"] = "actor_account_age" + type: Literal["actor_account_age"] @override def get_target(self, context: AutomodContext) -> Optional[User]: diff --git a/commanderbot/ext/automod/conditions/actor_has_all_flags.py b/commanderbot/ext/automod/conditions/actor_has_all_flags.py index 5075b35..93c6f14 100644 --- a/commanderbot/ext/automod/conditions/actor_has_all_flags.py +++ b/commanderbot/ext/automod/conditions/actor_has_all_flags.py @@ -13,7 +13,7 @@ class ActorHasAllFlags(TargetHasAllFlags): Check if the actor has all flags from a selection. """ - type: Literal["actor_has_all_flags"] = "actor_has_all_flags" + type: Literal["actor_has_all_flags"] @override def get_target(self, context: AutomodContext) -> Optional[User | Member]: diff --git a/commanderbot/ext/automod/conditions/actor_has_any_flags.py b/commanderbot/ext/automod/conditions/actor_has_any_flags.py index 8017786..d86bfd2 100644 --- a/commanderbot/ext/automod/conditions/actor_has_any_flags.py +++ b/commanderbot/ext/automod/conditions/actor_has_any_flags.py @@ -13,7 +13,7 @@ class ActorHasAnyFlags(TargetHasAnyFlags): Check if the actor has a number of flags from a selection. """ - type: Literal["actor_has_any_flags"] = "actor_has_any_flags" + type: Literal["actor_has_any_flags"] @override def get_target(self, context: AutomodContext) -> Optional[User | Member]: diff --git a/commanderbot/ext/automod/conditions/actor_is_bot.py b/commanderbot/ext/automod/conditions/actor_is_bot.py index a7ebbd9..e8b068b 100644 --- a/commanderbot/ext/automod/conditions/actor_is_bot.py +++ b/commanderbot/ext/automod/conditions/actor_is_bot.py @@ -14,7 +14,7 @@ class ActorIsBot(TargetIsBot): Check if the actor is a bot. """ - type: Literal["actor_is_bot"] = "actor_is_bot" + type: Literal["actor_is_bot"] @override def get_target(self, context: AutomodContext) -> Optional[User]: diff --git a/commanderbot/ext/automod/conditions/actor_is_not_bot.py b/commanderbot/ext/automod/conditions/actor_is_not_bot.py index 96e78ac..5d0b7c7 100644 --- a/commanderbot/ext/automod/conditions/actor_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/actor_is_not_bot.py @@ -14,7 +14,7 @@ class ActorIsNotBot(TargetIsNotBot): Check if the actor is not a bot. """ - type: Literal["actor_is_not_bot"] = "actor_is_not_bot" + type: Literal["actor_is_not_bot"] @override def get_target(self, context: AutomodContext) -> Optional[User]: diff --git a/commanderbot/ext/automod/conditions/actor_member_for.py b/commanderbot/ext/automod/conditions/actor_member_for.py index 09051f1..84d1791 100644 --- a/commanderbot/ext/automod/conditions/actor_member_for.py +++ b/commanderbot/ext/automod/conditions/actor_member_for.py @@ -14,7 +14,7 @@ class ActorMemberFor(TargetMemberFor): Check if the actor has been on the server for a certain amount of time. """ - type: Literal["actor_member_for"] = "actor_member_for" + type: Literal["actor_member_for"] @override def get_target(self, context: AutomodContext) -> Optional[Member]: diff --git a/commanderbot/ext/automod/conditions/actor_roles.py b/commanderbot/ext/automod/conditions/actor_roles.py index 617fe3a..d1d8f3b 100644 --- a/commanderbot/ext/automod/conditions/actor_roles.py +++ b/commanderbot/ext/automod/conditions/actor_roles.py @@ -14,7 +14,7 @@ class ActorRoles(TargetRoles): Check if the actor has certain roles. """ - type: Literal["actor_roles"] = "actor_roles" + type: Literal["actor_roles"] @override def get_target(self, context: AutomodContext) -> Optional[Member]: diff --git a/commanderbot/ext/automod/conditions/all_of.py b/commanderbot/ext/automod/conditions/all_of.py index 3824d7d..a889040 100644 --- a/commanderbot/ext/automod/conditions/all_of.py +++ b/commanderbot/ext/automod/conditions/all_of.py @@ -16,7 +16,7 @@ class AllOf(AutomodCondition): Check if all sub-conditions pass (logical AND). """ - type: Literal["all_of"] = "all_of" + type: Literal["all_of"] conditions: list[AutomodConditionType] = Field(default_factory=list, min_length=1) """The sub-conditions to check.""" diff --git a/commanderbot/ext/automod/conditions/any_of.py b/commanderbot/ext/automod/conditions/any_of.py index d49ef78..adc6c4f 100644 --- a/commanderbot/ext/automod/conditions/any_of.py +++ b/commanderbot/ext/automod/conditions/any_of.py @@ -16,7 +16,7 @@ class AnyOf(AutomodCondition): Check if a number of sub-conditions pass (logical OR). """ - type: Literal["any_of"] = "any_of" + type: Literal["any_of"] conditions: list[AutomodConditionType] = Field(default_factory=list, min_length=1) """The sub-conditions to check.""" diff --git a/commanderbot/ext/automod/conditions/author_account_age.py b/commanderbot/ext/automod/conditions/author_account_age.py index dcfb938..0421cae 100644 --- a/commanderbot/ext/automod/conditions/author_account_age.py +++ b/commanderbot/ext/automod/conditions/author_account_age.py @@ -14,7 +14,7 @@ class AuthorAccountAge(TargetAccountAge): Check if the author's account is a certain age. """ - type: Literal["author_account_age"] = "author_account_age" + type: Literal["author_account_age"] @override def get_target(self, context: AutomodContext) -> Optional[User]: diff --git a/commanderbot/ext/automod/conditions/author_has_all_flags.py b/commanderbot/ext/automod/conditions/author_has_all_flags.py index 98904c1..8cb0c5f 100644 --- a/commanderbot/ext/automod/conditions/author_has_all_flags.py +++ b/commanderbot/ext/automod/conditions/author_has_all_flags.py @@ -13,7 +13,7 @@ class AuthorHasAllFlags(TargetHasAllFlags): Check if the author has all flags from a selection. """ - type: Literal["author_has_all_flags"] = "author_has_all_flags" + type: Literal["author_has_all_flags"] @override def get_target(self, context: AutomodContext) -> Optional[User | Member]: diff --git a/commanderbot/ext/automod/conditions/author_has_any_flags.py b/commanderbot/ext/automod/conditions/author_has_any_flags.py index c5d1a19..1b261a0 100644 --- a/commanderbot/ext/automod/conditions/author_has_any_flags.py +++ b/commanderbot/ext/automod/conditions/author_has_any_flags.py @@ -13,7 +13,7 @@ class AuthorHasAnyFlags(TargetHasAnyFlags): Check if the author has a number of flags from a selection. """ - type: Literal["author_has_any_flags"] = "author_has_any_flags" + type: Literal["author_has_any_flags"] @override def get_target(self, context: AutomodContext) -> Optional[User | Member]: diff --git a/commanderbot/ext/automod/conditions/author_is_bot.py b/commanderbot/ext/automod/conditions/author_is_bot.py index c8c9c63..f53b5fa 100644 --- a/commanderbot/ext/automod/conditions/author_is_bot.py +++ b/commanderbot/ext/automod/conditions/author_is_bot.py @@ -14,7 +14,7 @@ class AuthorIsBot(TargetIsBot): Check if the author is a bot. """ - type: Literal["author_is_bot"] = "author_is_bot" + type: Literal["author_is_bot"] @override def get_target(self, context: AutomodContext) -> Optional[User]: diff --git a/commanderbot/ext/automod/conditions/author_is_not_bot.py b/commanderbot/ext/automod/conditions/author_is_not_bot.py index a12e1e7..fd6bf68 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/author_is_not_bot.py @@ -14,7 +14,7 @@ class AuthorIsNotBot(TargetIsNotBot): Check if the author is not a bot. """ - type: Literal["author_is_not_bot"] = "author_is_not_bot" + type: Literal["author_is_not_bot"] @override def get_target(self, context: AutomodContext) -> Optional[User]: diff --git a/commanderbot/ext/automod/conditions/author_is_not_self.py b/commanderbot/ext/automod/conditions/author_is_not_self.py index 875c79b..ede15e7 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_self.py +++ b/commanderbot/ext/automod/conditions/author_is_not_self.py @@ -14,7 +14,7 @@ class AuthorIsNotSelf(TargetIsSelf): Check if the author is not the bot itself. """ - type: Literal["author_is_not_self"] = "author_is_not_self" + type: Literal["author_is_not_self"] @override def get_target(self, context: AutomodContext) -> Optional[User]: diff --git a/commanderbot/ext/automod/conditions/author_is_self.py b/commanderbot/ext/automod/conditions/author_is_self.py index 4b1e19d..f60105d 100644 --- a/commanderbot/ext/automod/conditions/author_is_self.py +++ b/commanderbot/ext/automod/conditions/author_is_self.py @@ -14,7 +14,7 @@ class AuthorIsSelf(TargetIsSelf): Check if the author is the bot itself. """ - type: Literal["author_is_self"] = "author_is_self" + type: Literal["author_is_self"] @override def get_target(self, context: AutomodContext) -> Optional[User]: diff --git a/commanderbot/ext/automod/conditions/author_member_for.py b/commanderbot/ext/automod/conditions/author_member_for.py index 93daf36..a323cd8 100644 --- a/commanderbot/ext/automod/conditions/author_member_for.py +++ b/commanderbot/ext/automod/conditions/author_member_for.py @@ -14,7 +14,7 @@ class AuthorMemberFor(TargetMemberFor): Check if the author has been on the server for a certain amount of time. """ - type: Literal["author_member_for"] = "author_member_for" + type: Literal["author_member_for"] @override def get_target(self, context: AutomodContext) -> Optional[Member]: diff --git a/commanderbot/ext/automod/conditions/author_roles.py b/commanderbot/ext/automod/conditions/author_roles.py index 73192c2..5ec21ce 100644 --- a/commanderbot/ext/automod/conditions/author_roles.py +++ b/commanderbot/ext/automod/conditions/author_roles.py @@ -14,7 +14,7 @@ class AuthorRoles(TargetRoles): Check if the author has certain roles. """ - type: Literal["author_roles"] = "author_roles" + type: Literal["author_roles"] @override def get_target(self, context: AutomodContext) -> Optional[Member]: diff --git a/commanderbot/ext/automod/conditions/inverted.py b/commanderbot/ext/automod/conditions/inverted.py index 216cea0..db85379 100644 --- a/commanderbot/ext/automod/conditions/inverted.py +++ b/commanderbot/ext/automod/conditions/inverted.py @@ -14,7 +14,7 @@ class Inverted(AutomodCondition): Inverts the result of a sub-condition. """ - type: Literal["inverted"] = "inverted" + type: Literal["inverted"] condition: AutomodConditionType """The sub-condition to check.""" diff --git a/commanderbot/ext/automod/conditions/message_content_contains.py b/commanderbot/ext/automod/conditions/message_content_contains.py index ff444ea..94d424a 100644 --- a/commanderbot/ext/automod/conditions/message_content_contains.py +++ b/commanderbot/ext/automod/conditions/message_content_contains.py @@ -15,7 +15,7 @@ class MessageContentContains(AutomodCondition): Check if the message content contains a number of substrings. """ - type: Literal["message_content_contains"] = "message_content_contains" + type: Literal["message_content_contains"] contains: set[str] = Field(default_factory=set, min_length=1) """ @@ -30,10 +30,10 @@ class MessageContentContains(AutomodCondition): found. If empty, all substrings must be found. """ - ignore_case: Optional[bool] = None + ignore_case: bool = False """Whether to ignore upper vs lower case.""" - use_normalization: Optional[bool] = None + use_normalization: bool = False """Whether to use unicode normalization or process the string as-is.""" normalization_form: UnicodeNormalizationForms = Field(default="NFKD") diff --git a/commanderbot/ext/automod/conditions/message_content_matches.py b/commanderbot/ext/automod/conditions/message_content_matches.py index 68e7ac4..d18a786 100644 --- a/commanderbot/ext/automod/conditions/message_content_matches.py +++ b/commanderbot/ext/automod/conditions/message_content_matches.py @@ -16,7 +16,7 @@ class MessageContentMatches(AutomodCondition): Check if the message content matches a number of regular expressions. """ - type: Literal["message_content_matches"] = "message_content_matches" + type: Literal["message_content_matches"] matches: set[re.Pattern] = Field(default_factory=set, min_length=1) """ @@ -31,10 +31,10 @@ class MessageContentMatches(AutomodCondition): matched. If unspecified, all patterns must be matched. """ - use_search: Optional[bool] = None + use_search: bool = False """Whether to search the entire string instead of using an anchored match.""" - use_normalization: Optional[bool] = None + use_normalization: bool = False """Whether to use unicode normalization or process the string as-is.""" normalization_form: UnicodeNormalizationForms = Field(default="NFKD") diff --git a/commanderbot/ext/automod/conditions/message_has_attachments.py b/commanderbot/ext/automod/conditions/message_has_attachments.py index 20f8411..2031479 100644 --- a/commanderbot/ext/automod/conditions/message_has_attachments.py +++ b/commanderbot/ext/automod/conditions/message_has_attachments.py @@ -12,7 +12,7 @@ class MessageHasAttachments(AutomodCondition): Check if the message has attachments. """ - type: Literal["message_has_attachments"] = "message_has_attachments" + type: Literal["message_has_attachments"] count: Optional[IntegerRangeGuard] = None """The number of attachments to check for. If empty, the message only needs a single attachment.""" diff --git a/commanderbot/ext/automod/conditions/message_has_embeds.py b/commanderbot/ext/automod/conditions/message_has_embeds.py index 4eaf5fc..0efeea6 100644 --- a/commanderbot/ext/automod/conditions/message_has_embeds.py +++ b/commanderbot/ext/automod/conditions/message_has_embeds.py @@ -12,7 +12,7 @@ class MessageHasEmbeds(AutomodCondition): Check if the message has embeds. """ - type: Literal["message_has_embeds"] = "message_has_embeds" + type: Literal["message_has_embeds"] count: Optional[IntegerRangeGuard] = None """The number of embeds to check for. If empty, the message only needs a single embed.""" diff --git a/commanderbot/ext/automod/conditions/message_has_links.py b/commanderbot/ext/automod/conditions/message_has_links.py index 0a88ea8..616bb25 100644 --- a/commanderbot/ext/automod/conditions/message_has_links.py +++ b/commanderbot/ext/automod/conditions/message_has_links.py @@ -12,7 +12,7 @@ class MessageHasLinks(AutomodCondition): Check if the message has links. """ - type: Literal["message_has_links"] = "message_has_links" + type: Literal["message_has_links"] count: Optional[IntegerRangeGuard] = None """The number of links to check for. If empty, the message only needs a single link.""" diff --git a/commanderbot/ext/automod/conditions/message_has_mentions.py b/commanderbot/ext/automod/conditions/message_has_mentions.py index 7af350e..a753c88 100644 --- a/commanderbot/ext/automod/conditions/message_has_mentions.py +++ b/commanderbot/ext/automod/conditions/message_has_mentions.py @@ -12,7 +12,7 @@ class MessageHasMentions(AutomodCondition): Check if the message has mentions. """ - type: Literal["message_has_mentions"] = "message_has_mentions" + type: Literal["message_has_mentions"] count: Optional[IntegerRangeGuard] = None """The number of mentions to check for. If empty, the message only needs a single mention.""" diff --git a/commanderbot/ext/automod/conditions/message_mentions_roles.py b/commanderbot/ext/automod/conditions/message_mentions_roles.py index 136a230..54be25c 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_roles.py +++ b/commanderbot/ext/automod/conditions/message_mentions_roles.py @@ -12,7 +12,7 @@ class MessageMentionsRoles(AutomodCondition): Check if the message contains role mentions. """ - type: Literal["message_mentions_roles"] = "message_mentions_roles" + type: Literal["message_mentions_roles"] roles: Optional[RolesGuard] = None """The roles to match against. If empty, all roles will match.""" diff --git a/commanderbot/ext/automod/conditions/message_mentions_users.py b/commanderbot/ext/automod/conditions/message_mentions_users.py index 9cd8f5a..12bd7a3 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_users.py +++ b/commanderbot/ext/automod/conditions/message_mentions_users.py @@ -12,7 +12,7 @@ class MessageMentionsUsers(AutomodCondition): Check if the message contains user mentions. """ - type: Literal["message_mentions_users"] = "message_mentions_users" + type: Literal["message_mentions_users"] roles: Optional[RolesGuard] = None """The roles of the mentioned users to match against. If empty, all roles will match.""" diff --git a/commanderbot/ext/automod/conditions/none_of.py b/commanderbot/ext/automod/conditions/none_of.py index 16f1546..442afb4 100644 --- a/commanderbot/ext/automod/conditions/none_of.py +++ b/commanderbot/ext/automod/conditions/none_of.py @@ -16,7 +16,7 @@ class NoneOf(AutomodCondition): Check if no sub-conditions pass. """ - type: Literal["none_of"] = "none_of" + type: Literal["none_of"] conditions: list[AutomodConditionType] = Field(default_factory=list, min_length=1) """The sub-conditions to check.""" diff --git a/commanderbot/ext/automod/conditions/random_chance.py b/commanderbot/ext/automod/conditions/random_chance.py index 8f41a97..18339b0 100644 --- a/commanderbot/ext/automod/conditions/random_chance.py +++ b/commanderbot/ext/automod/conditions/random_chance.py @@ -14,7 +14,7 @@ class RandomChance(AutomodCondition): Generate a random number between `0.0` and `1.0` and check if it's below `chance`. """ - type: Literal["random_chance"] = "random_chance" + type: Literal["random_chance"] chance: float = Field(ge=0.0, le=1.0) """A number between `0.0` and `1.0` (inclusive).""" diff --git a/commanderbot/ext/automod/conditions/randomize.py b/commanderbot/ext/automod/conditions/randomize.py index 6fd2f14..6651447 100644 --- a/commanderbot/ext/automod/conditions/randomize.py +++ b/commanderbot/ext/automod/conditions/randomize.py @@ -31,7 +31,7 @@ class Randomize(AutomodCondition): Check a random sub-condition. """ - type: Literal["randomize"] = "randomize" + type: Literal["randomize"] conditions: list[AutomodConditionType | WeightedCondition] = Field( default_factory=list, min_length=1 diff --git a/commanderbot/ext/automod/conditions/return_false.py b/commanderbot/ext/automod/conditions/return_false.py index 61e6df3..5102a98 100644 --- a/commanderbot/ext/automod/conditions/return_false.py +++ b/commanderbot/ext/automod/conditions/return_false.py @@ -11,7 +11,7 @@ class ReturnFalse(AutomodCondition): A condition that is always false. """ - type: Literal["return_false"] = "return_false" + type: Literal["return_false"] @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/return_true.py b/commanderbot/ext/automod/conditions/return_true.py index 31c8fc2..4e9db37 100644 --- a/commanderbot/ext/automod/conditions/return_true.py +++ b/commanderbot/ext/automod/conditions/return_true.py @@ -11,7 +11,7 @@ class ReturnTrue(AutomodCondition): A condition that is always true. """ - type: Literal["return_true"] = "return_true" + type: Literal["return_true"] @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/throw_error.py b/commanderbot/ext/automod/conditions/throw_error.py index 90274d5..a96d238 100644 --- a/commanderbot/ext/automod/conditions/throw_error.py +++ b/commanderbot/ext/automod/conditions/throw_error.py @@ -13,7 +13,7 @@ class ThrowError(AutomodCondition): Intended for testing and debugging. """ - type: Literal["throw_error"] = "throw_error" + type: Literal["throw_error"] error: str """A human-readable error message.""" diff --git a/commanderbot/ext/automod/conditions/wait.py b/commanderbot/ext/automod/conditions/wait.py index e8ea32f..d41a4cb 100644 --- a/commanderbot/ext/automod/conditions/wait.py +++ b/commanderbot/ext/automod/conditions/wait.py @@ -13,7 +13,7 @@ class Wait(AutomodCondition): Wait a certain amount of time before continuing. """ - type: Literal["wait"] = "wait" + type: Literal["wait"] delay: Timedelta """ diff --git a/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py b/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py index 533a6de..7a320ba 100644 --- a/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py +++ b/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py @@ -13,9 +13,7 @@ class DiscordAutomodBlockedMemberInteractions(AutomodTrigger): Triggers when Discord's automod blocks member interactions. """ - type: Literal["discord_automod_blocked_member_interactions"] = ( - "discord_automod_blocked_member_interactions" - ) + type: Literal["discord_automod_blocked_member_interactions"] event_types = (events.DiscordAutomodBlockedMemberInteractions,) rules: Optional[DiscordAutomodRulesGuard] = None diff --git a/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py b/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py index 32c2b2b..1278fb6 100644 --- a/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py +++ b/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py @@ -19,7 +19,7 @@ class DiscordAutomodBlockedMessage(AutomodTrigger): Triggers when Discord's automod blocks a message. """ - type: Literal["discord_automod_blocked_message"] = "discord_automod_blocked_message" + type: Literal["discord_automod_blocked_message"] event_types = (events.DiscordAutomodBlockedMessage,) rules: Optional[DiscordAutomodRulesGuard] = None diff --git a/commanderbot/ext/automod/triggers/discord_automod/timed_out.py b/commanderbot/ext/automod/triggers/discord_automod/timed_out.py index 662aac7..0c27413 100644 --- a/commanderbot/ext/automod/triggers/discord_automod/timed_out.py +++ b/commanderbot/ext/automod/triggers/discord_automod/timed_out.py @@ -13,7 +13,7 @@ class DiscordAutomodTimedOut(AutomodTrigger): Triggers when Discord's automod times out a member. """ - type: Literal["discord_automod_timed_out"] = "discord_automod_timed_out" + type: Literal["discord_automod_timed_out"] event_types = (events.DiscordAutomodTimedOut,) rules: Optional[DiscordAutomodRulesGuard] = None diff --git a/commanderbot/ext/automod/triggers/member_gained_flags.py b/commanderbot/ext/automod/triggers/member_gained_flags.py index 5064d94..36a6770 100644 --- a/commanderbot/ext/automod/triggers/member_gained_flags.py +++ b/commanderbot/ext/automod/triggers/member_gained_flags.py @@ -13,7 +13,7 @@ class MemberGainedFlags(AutomodTrigger): Triggers when a member gains certain flags. """ - type: Literal["member_gained_flags"] = "member_gained_flags" + type: Literal["member_gained_flags"] event_types = (events.MemberUpdated,) flags: Optional[FlagsGuard] = None diff --git a/commanderbot/ext/automod/triggers/member_joined.py b/commanderbot/ext/automod/triggers/member_joined.py index 9dfa6ab..46b92ae 100644 --- a/commanderbot/ext/automod/triggers/member_joined.py +++ b/commanderbot/ext/automod/triggers/member_joined.py @@ -11,5 +11,5 @@ class MemberJoined(AutomodTrigger): Triggers when a member joins the guild. """ - type: Literal["member_joined"] = "member_joined" + type: Literal["member_joined"] event_types = (events.MemberJoined,) diff --git a/commanderbot/ext/automod/triggers/member_left.py b/commanderbot/ext/automod/triggers/member_left.py index 061afd2..71950cc 100644 --- a/commanderbot/ext/automod/triggers/member_left.py +++ b/commanderbot/ext/automod/triggers/member_left.py @@ -11,5 +11,5 @@ class MemberLeft(AutomodTrigger): Triggers when a member leaves the guild. """ - type: Literal["member_left"] = "member_left" + type: Literal["member_left"] event_types = (events.MemberLeft,) diff --git a/commanderbot/ext/automod/triggers/member_lost_flags.py b/commanderbot/ext/automod/triggers/member_lost_flags.py index 66e77d1..c58711d 100644 --- a/commanderbot/ext/automod/triggers/member_lost_flags.py +++ b/commanderbot/ext/automod/triggers/member_lost_flags.py @@ -13,7 +13,7 @@ class MemberLostFlags(AutomodTrigger): Triggers when a member loses certain flags. """ - type: Literal["member_lost_flags"] = "member_lost_flags" + type: Literal["member_lost_flags"] event_types = (events.MemberUpdated,) flags: Optional[FlagsGuard] = None diff --git a/commanderbot/ext/automod/triggers/member_typing.py b/commanderbot/ext/automod/triggers/member_typing.py index 9ac7b6a..f85a058 100644 --- a/commanderbot/ext/automod/triggers/member_typing.py +++ b/commanderbot/ext/automod/triggers/member_typing.py @@ -18,7 +18,7 @@ class MemberTyping(AutomodTrigger): Triggers when a user is typing. """ - type: Literal["member_typing"] = "member_typing" + type: Literal["member_typing"] event_types = (events.MemberTyping,) categories: Optional[CategoriesGuard] = None diff --git a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py index 2a57fc3..92321d4 100644 --- a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py +++ b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py @@ -22,7 +22,7 @@ class MentionsRemovedFromMessage(AutomodTrigger): This can be used for detecting suspected ghost pings. """ - type: Literal["mentions_removed_from_message"] = "mentions_removed_from_message" + type: Literal["mentions_removed_from_message"] event_types = (events.MessageEdited, events.MessageDeleted) categories: Optional[CategoriesGuard] = None diff --git a/commanderbot/ext/automod/triggers/message.py b/commanderbot/ext/automod/triggers/message.py index 22ad5f1..989cb78 100644 --- a/commanderbot/ext/automod/triggers/message.py +++ b/commanderbot/ext/automod/triggers/message.py @@ -18,7 +18,7 @@ class Message(AutomodTrigger): Triggers when a message is sent or edited. """ - type: Literal["message"] = "message" + type: Literal["message"] event_types = (events.MessageSent, events.MessageEdited) categories: Optional[CategoriesGuard] = None diff --git a/commanderbot/ext/automod/triggers/message_deleted.py b/commanderbot/ext/automod/triggers/message_deleted.py index 8db709f..7282148 100644 --- a/commanderbot/ext/automod/triggers/message_deleted.py +++ b/commanderbot/ext/automod/triggers/message_deleted.py @@ -11,5 +11,5 @@ class MessageDeleted(Message): Triggers when a message is deleted. """ - type: Literal["message_deleted"] = "message_deleted" + type: Literal["message_deleted"] event_types = (events.MessageDeleted,) diff --git a/commanderbot/ext/automod/triggers/message_edited.py b/commanderbot/ext/automod/triggers/message_edited.py index c186855..2f611d6 100644 --- a/commanderbot/ext/automod/triggers/message_edited.py +++ b/commanderbot/ext/automod/triggers/message_edited.py @@ -11,5 +11,5 @@ class MessageEdited(Message): Triggers when a message is edited. """ - type: Literal["message_edited"] = "message_edited" + type: Literal["message_edited"] event_types = (events.MessageEdited,) diff --git a/commanderbot/ext/automod/triggers/message_sent.py b/commanderbot/ext/automod/triggers/message_sent.py index 26a8f01..394fbca 100644 --- a/commanderbot/ext/automod/triggers/message_sent.py +++ b/commanderbot/ext/automod/triggers/message_sent.py @@ -11,5 +11,5 @@ class MessageSent(Message): Triggers when a message is sent. """ - type: Literal["message_sent"] = "message_sent" + type: Literal["message_sent"] event_types = (events.MessageSent,) diff --git a/commanderbot/ext/automod/triggers/reaction.py b/commanderbot/ext/automod/triggers/reaction.py index 7258e00..dfb86dd 100644 --- a/commanderbot/ext/automod/triggers/reaction.py +++ b/commanderbot/ext/automod/triggers/reaction.py @@ -19,7 +19,7 @@ class Reaction(AutomodTrigger): Triggers when a member reacts to a message. """ - type: Literal["reaction"] = "reaction" + type: Literal["reaction"] event_types = (events.ReactionAdded, events.ReactionRemoved) reactions: Optional[ReactionsGuard] = None diff --git a/commanderbot/ext/automod/triggers/reaction_added.py b/commanderbot/ext/automod/triggers/reaction_added.py index 0fccd61..1cfc705 100644 --- a/commanderbot/ext/automod/triggers/reaction_added.py +++ b/commanderbot/ext/automod/triggers/reaction_added.py @@ -11,5 +11,5 @@ class ReactionAdded(Reaction): Triggers when a reaction is added. """ - type: Literal["reaction_added"] = "reaction_added" + type: Literal["reaction_added"] event_types = (events.ReactionAdded,) diff --git a/commanderbot/ext/automod/triggers/reaction_removed.py b/commanderbot/ext/automod/triggers/reaction_removed.py index af19ae9..3192a3c 100644 --- a/commanderbot/ext/automod/triggers/reaction_removed.py +++ b/commanderbot/ext/automod/triggers/reaction_removed.py @@ -11,5 +11,5 @@ class ReactionRemoved(Reaction): Triggers when a reaction is removed. """ - type: Literal["reaction_removed"] = "reaction_removed" + type: Literal["reaction_removed"] event_types = (events.ReactionRemoved,) diff --git a/commanderbot/ext/automod/triggers/thread_created.py b/commanderbot/ext/automod/triggers/thread_created.py index a962ed6..16cdd8b 100644 --- a/commanderbot/ext/automod/triggers/thread_created.py +++ b/commanderbot/ext/automod/triggers/thread_created.py @@ -17,7 +17,7 @@ class ThreadCreated(AutomodTrigger): Triggers when a thread is created. """ - type: Literal["thread_created"] = "thread_created" + type: Literal["thread_created"] event_types = (events.ThreadCreated,) parent_categories: Optional[CategoriesGuard] = None diff --git a/commanderbot/ext/automod/triggers/thread_deleted.py b/commanderbot/ext/automod/triggers/thread_deleted.py index 591b764..206961d 100644 --- a/commanderbot/ext/automod/triggers/thread_deleted.py +++ b/commanderbot/ext/automod/triggers/thread_deleted.py @@ -17,7 +17,7 @@ class ThreadDeleted(AutomodTrigger): Triggers when a thread is deleted. """ - type: Literal["thread_deleted"] = "thread_deleted" + type: Literal["thread_deleted"] event_types = (events.ThreadDeleted,) parent_categories: Optional[CategoriesGuard] = None diff --git a/commanderbot/ext/automod/triggers/user_banned.py b/commanderbot/ext/automod/triggers/user_banned.py index 740c54e..40117a6 100644 --- a/commanderbot/ext/automod/triggers/user_banned.py +++ b/commanderbot/ext/automod/triggers/user_banned.py @@ -11,5 +11,5 @@ class UserBanned(AutomodTrigger): Triggers when a user is banned. """ - type: Literal["user_banned"] = "user_banned" + type: Literal["user_banned"] event_types = (events.UserBanned,) diff --git a/commanderbot/ext/automod/triggers/user_unbanned.py b/commanderbot/ext/automod/triggers/user_unbanned.py index 39f48ac..15f4fcd 100644 --- a/commanderbot/ext/automod/triggers/user_unbanned.py +++ b/commanderbot/ext/automod/triggers/user_unbanned.py @@ -11,5 +11,5 @@ class UserUnbanned(AutomodTrigger): Triggers when a user is unbanned. """ - type: Literal["user_unbanned"] = "user_unbanned" + type: Literal["user_unbanned"] event_types = (events.UserUnbanned,) From 5360e58a76f399c46747f58945171713f5d8eff7 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 14 Jul 2026 14:13:51 -0700 Subject: [PATCH 046/158] Added more options to the database config --- commanderbot/lib/databases/json_db/config.py | 2 ++ commanderbot/lib/databases/json_db/db.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/commanderbot/lib/databases/json_db/config.py b/commanderbot/lib/databases/json_db/config.py index 743440f..46d9f95 100644 --- a/commanderbot/lib/databases/json_db/config.py +++ b/commanderbot/lib/databases/json_db/config.py @@ -19,6 +19,8 @@ class JsonFileDatabaseOptions(BaseModel): path: Path no_init: bool = False indent: Optional[int] = None + exclude_defaults: bool = True + exclude_none: bool = False def validate_options(data: Optional[str | dict]) -> dict: diff --git a/commanderbot/lib/databases/json_db/db.py b/commanderbot/lib/databases/json_db/db.py index b7b4dbb..0269785 100644 --- a/commanderbot/lib/databases/json_db/db.py +++ b/commanderbot/lib/databases/json_db/db.py @@ -58,7 +58,7 @@ def _read(self) -> str: path: Path = self.options.path try: # Attempt read the file. - return self.options.path.read_text() + return self.options.path.read_text(encoding="utf-8") except FileNotFoundError as ex: if self.options.no_init: # If the file doesn't exist, and we've been specifically told not to @@ -70,7 +70,7 @@ def _read(self) -> str: f"Initializing database file because it doesn't already exist: {path}" ) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("{}") + path.write_text("{}", encoding="utf-8") return "{}" def _write(self, data: str): @@ -78,7 +78,7 @@ def _write(self, data: str): Write the given data to the database file. """ assert is_json_file_options(self.options) - self.options.path.write_text(data) + self.options.path.write_text(data, encoding="utf-8") async def get_cache(self) -> CacheType: """ @@ -101,5 +101,9 @@ async def commit(self): cache: CacheType = await self.get_cache() async with self.__cache_lock: if is_json_file_options(self.options): - data: str = cache.model_dump_json(indent=self.options.indent) + data: str = cache.model_dump_json( + indent=self.options.indent, + exclude_defaults=self.options.exclude_defaults, + exclude_none=self.options.exclude_none, + ) await asyncio.to_thread(self._write, data) From 6c35c677f7823cc6bf526d25fa9c7377ce7b7259 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 14 Jul 2026 14:14:16 -0700 Subject: [PATCH 047/158] Changed some default values --- commanderbot/ext/automod/condition/condition_base.py | 2 +- commanderbot/ext/automod/trigger/trigger_base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commanderbot/ext/automod/condition/condition_base.py b/commanderbot/ext/automod/condition/condition_base.py index 7943d72..9947ad1 100644 --- a/commanderbot/ext/automod/condition/condition_base.py +++ b/commanderbot/ext/automod/condition/condition_base.py @@ -23,7 +23,7 @@ class AutomodCondition(ABC, BaseModel): description: Optional[str] = None """Describe what the condition does.""" - disabled: Optional[bool] = None + disabled: bool = False """Is the condition disabled?""" @abstractmethod diff --git a/commanderbot/ext/automod/trigger/trigger_base.py b/commanderbot/ext/automod/trigger/trigger_base.py index b1d0a25..2ed208c 100644 --- a/commanderbot/ext/automod/trigger/trigger_base.py +++ b/commanderbot/ext/automod/trigger/trigger_base.py @@ -24,7 +24,7 @@ class AutomodTrigger(ABC, BaseModel): description: Optional[str] = None """Describe what the trigger does.""" - disabled: Optional[bool] = None + disabled: bool = False """Is the trigger disabled?""" event_types: ClassVar[tuple[type[AutomodEvent], ...]] = tuple() From 39f54f469bbb9e28b2d85900ed4bb690a6428b82 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 16 Jul 2026 11:04:37 -0700 Subject: [PATCH 048/158] Reworked actions --- commanderbot/ext/automod/action/__init__.py | 2 + .../ext/automod/action/action_base.py | 32 +++ commanderbot/ext/automod/action/types.py | 34 +++ commanderbot/ext/automod/actions/__init__.py | 22 ++ .../ext/automod/actions/abc/__init__.py | 2 + .../actions/abc/add_roles_to_target.py | 35 +++ .../actions/abc/add_roles_to_target_base.py | 25 -- .../actions/abc/remove_roles_from_target.py | 35 +++ .../abc/remove_roles_from_target_base.py | 24 -- .../ext/automod/actions/add_reactions.py | 33 ++- .../ext/automod/actions/add_roles_to_actor.py | 34 +-- .../automod/actions/add_roles_to_author.py | 34 +-- .../automod/actions/add_users_to_thread.py | 73 +++--- .../ext/automod/actions/check_messages.py | 109 -------- .../ext/automod/actions/delete_message.py | 23 +- commanderbot/ext/automod/actions/dm_member.py | 62 +++-- .../ext/automod/actions/edit_thread.py | 107 +++++--- .../ext/automod/actions/join_thread.py | 30 ++- .../ext/automod/actions/log_message.py | 233 ++++++++++-------- commanderbot/ext/automod/actions/randomize.py | 71 ++++++ .../automod/actions/remove_all_reactions.py | 23 +- .../automod/actions/remove_own_reactions.py | 35 ++- .../ext/automod/actions/remove_reactions.py | 32 ++- .../actions/remove_roles_from_actor.py | 32 +-- .../actions/remove_roles_from_author.py | 32 +-- .../ext/automod/actions/reply_to_message.py | 81 +++--- .../ext/automod/actions/send_message.py | 120 +++++---- commanderbot/ext/automod/actions/sequence.py | 28 +++ .../ext/automod/actions/throw_error.py | 27 +- .../ext/automod/actions/timeout_actor.py | 31 +++ .../ext/automod/actions/timeout_author.py | 31 +++ commanderbot/ext/automod/actions/wait.py | 40 +-- commanderbot/ext/automod/rule/rule.py | 9 +- 33 files changed, 865 insertions(+), 676 deletions(-) create mode 100644 commanderbot/ext/automod/action/__init__.py create mode 100644 commanderbot/ext/automod/action/action_base.py create mode 100644 commanderbot/ext/automod/action/types.py create mode 100644 commanderbot/ext/automod/actions/abc/add_roles_to_target.py delete mode 100644 commanderbot/ext/automod/actions/abc/add_roles_to_target_base.py create mode 100644 commanderbot/ext/automod/actions/abc/remove_roles_from_target.py delete mode 100644 commanderbot/ext/automod/actions/abc/remove_roles_from_target_base.py delete mode 100644 commanderbot/ext/automod/actions/check_messages.py create mode 100644 commanderbot/ext/automod/actions/randomize.py create mode 100644 commanderbot/ext/automod/actions/sequence.py create mode 100644 commanderbot/ext/automod/actions/timeout_actor.py create mode 100644 commanderbot/ext/automod/actions/timeout_author.py diff --git a/commanderbot/ext/automod/action/__init__.py b/commanderbot/ext/automod/action/__init__.py new file mode 100644 index 0000000..a583064 --- /dev/null +++ b/commanderbot/ext/automod/action/__init__.py @@ -0,0 +1,2 @@ +from .action_base import * +from .types import * diff --git a/commanderbot/ext/automod/action/action_base.py b/commanderbot/ext/automod/action/action_base.py new file mode 100644 index 0000000..dee680b --- /dev/null +++ b/commanderbot/ext/automod/action/action_base.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from commanderbot.ext.automod.automod_context import AutomodContext + +__all__ = ("AutomodAction",) + + +class AutomodAction(ABC, BaseModel): + """ + Base class for all automod actions. + + An action defines a task to perform when conditions pass. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + type: str + """The action type.""" + + description: Optional[str] = None + """Describe what the action does.""" + + disabled: bool = False + """Is the action disabled?""" + + @abstractmethod + async def apply(self, context: AutomodContext): + """Apply the action.""" + pass diff --git a/commanderbot/ext/automod/action/types.py b/commanderbot/ext/automod/action/types.py new file mode 100644 index 0000000..7b57360 --- /dev/null +++ b/commanderbot/ext/automod/action/types.py @@ -0,0 +1,34 @@ +from typing import Annotated + +from pydantic import Field + +from commanderbot.ext.automod import actions + +__all__ = ("AutomodActionType",) + +type _AutomodActionType = ( + actions.AddReactions + | actions.AddRolesToActor + | actions.AddRolesToAuthor + | actions.AddUsersToThread + | actions.DeleteMessage + | actions.DMMember + | actions.EditThread + | actions.JoinThread + | actions.LogMessage + | actions.Randomize + | actions.RemoveAllReactions + | actions.RemoveOwnReactions + | actions.RemoveReactions + | actions.RemoveRolesFromActor + | actions.RemoveRolesFromAuthor + | actions.ReplyToMessage + | actions.SendMessage + | actions.Sequence + | actions.ThrowError + | actions.TimeoutActor + | actions.TimeoutAuthor + | actions.Wait +) + +type AutomodActionType = Annotated[_AutomodActionType, Field(discriminator="type")] diff --git a/commanderbot/ext/automod/actions/__init__.py b/commanderbot/ext/automod/actions/__init__.py index e69de29..b4e8997 100644 --- a/commanderbot/ext/automod/actions/__init__.py +++ b/commanderbot/ext/automod/actions/__init__.py @@ -0,0 +1,22 @@ +from .add_reactions import * +from .add_roles_to_actor import * +from .add_roles_to_author import * +from .add_users_to_thread import * +from .delete_message import * +from .dm_member import * +from .edit_thread import * +from .join_thread import * +from .log_message import * +from .randomize import * +from .remove_all_reactions import * +from .remove_own_reactions import * +from .remove_reactions import * +from .remove_roles_from_actor import * +from .remove_roles_from_author import * +from .reply_to_message import * +from .send_message import * +from .sequence import * +from .throw_error import * +from .timeout_actor import * +from .timeout_author import * +from .wait import * diff --git a/commanderbot/ext/automod/actions/abc/__init__.py b/commanderbot/ext/automod/actions/abc/__init__.py index e69de29..897262c 100644 --- a/commanderbot/ext/automod/actions/abc/__init__.py +++ b/commanderbot/ext/automod/actions/abc/__init__.py @@ -0,0 +1,2 @@ +from .add_roles_to_target import * +from .remove_roles_from_target import * diff --git a/commanderbot/ext/automod/actions/abc/add_roles_to_target.py b/commanderbot/ext/automod/actions/abc/add_roles_to_target.py new file mode 100644 index 0000000..6db50d1 --- /dev/null +++ b/commanderbot/ext/automod/actions/abc/add_roles_to_target.py @@ -0,0 +1,35 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import Member, Role +from pydantic import Field + +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.types import RoleID + +__all__ = ("AddRolesToTarget",) + + +class AddRolesToTarget(AutomodAction): + roles: set[RoleID] = Field(default_factory=set, min_length=1) + """The roles to add.""" + + reason: Optional[str] = None + """The reason why the roles were added, if any.""" + + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[Member]: + pass + + @override + async def apply(self, context: AutomodContext): + if member := self.get_target(context): + roles: list[Role] = [] + for role_id in self.roles: + if role := member.guild.get_role(role_id): + roles.append(role) + else: + context.log.exception(f"Failed to resolve role '{role_id}'") + + await member.add_roles(*roles, reason=self.reason) diff --git a/commanderbot/ext/automod/actions/abc/add_roles_to_target_base.py b/commanderbot/ext/automod/actions/abc/add_roles_to_target_base.py deleted file mode 100644 index 20a51ff..0000000 --- a/commanderbot/ext/automod/actions/abc/add_roles_to_target_base.py +++ /dev/null @@ -1,25 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from discord import Guild, Member - -from commanderbot.ext.automod.automod_action import AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import RoleID - - -@dataclass -class AddRolesToTargetBase(AutomodActionBase): - roles: tuple[RoleID] - reason: Optional[str] = None - - def get_target(self, event: AutomodEvent) -> Optional[Member]: - raise NotImplementedError() - - async def apply(self, event: AutomodEvent): - if member := self.get_target(event): - guild: Guild = member.guild - # TODO Warn about unresolved roles. #logging - roles = [guild.get_role(role_id) for role_id in self.roles] - roles = [role for role in roles if role] - await member.add_roles(*roles, reason=self.reason) diff --git a/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py b/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py new file mode 100644 index 0000000..d84d683 --- /dev/null +++ b/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py @@ -0,0 +1,35 @@ +from abc import abstractmethod +from typing import Optional, override + +from discord import Member, Role +from pydantic import Field + +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.types import RoleID + +__all__ = ("RemoveRolesFromTarget",) + + +class RemoveRolesFromTarget(AutomodAction): + roles: set[RoleID] = Field(default_factory=set, min_length=1) + """The roles to remove.""" + + reason: Optional[str] = None + """The reason why the roles were removed, if any.""" + + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[Member]: + pass + + @override + async def apply(self, context: AutomodContext): + if member := self.get_target(context): + roles: list[Role] = [] + for role_id in self.roles: + if role := member.guild.get_role(role_id): + roles.append(role) + else: + context.log.exception(f"Failed to resolve role '{role_id}'") + + await member.remove_roles(*roles, reason=self.reason) diff --git a/commanderbot/ext/automod/actions/abc/remove_roles_from_target_base.py b/commanderbot/ext/automod/actions/abc/remove_roles_from_target_base.py deleted file mode 100644 index d205197..0000000 --- a/commanderbot/ext/automod/actions/abc/remove_roles_from_target_base.py +++ /dev/null @@ -1,24 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from discord import Guild, Member - -from commanderbot.ext.automod.automod_action import AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import RoleID - - -@dataclass -class RemoveRolesFromTargetBase(AutomodActionBase): - roles: tuple[RoleID] - - def get_target(self, event: AutomodEvent) -> Optional[Member]: - raise NotImplementedError() - - async def apply(self, event: AutomodEvent): - if member := self.get_target(event): - guild: Guild = member.guild - # TODO Warn about unresolved roles. #logging - roles = [guild.get_role(role_id) for role_id in self.roles] - roles = [role for role in roles if role] - await member.remove_roles(roles) diff --git a/commanderbot/ext/automod/actions/add_reactions.py b/commanderbot/ext/automod/actions/add_reactions.py index b7ccf44..6e05915 100644 --- a/commanderbot/ext/automod/actions/add_reactions.py +++ b/commanderbot/ext/automod/actions/add_reactions.py @@ -1,28 +1,25 @@ -from dataclasses import dataclass +from typing import Literal, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from pydantic import Field +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext -@dataclass -class AddReactions(AutomodActionBase): +__all__ = ("AddReactions",) + + +class AddReactions(AutomodAction): """ Add reactions to the message in context. - - Attributes - ---------- - reactions - The reactions to add. """ - reactions: tuple[str] + type: Literal["add_reactions"] - async def apply(self, event: AutomodEvent): - if message := event.message: + reactions: set[str] = Field(default_factory=set, min_length=1) + """The reactions to add.""" + + @override + async def apply(self, context: AutomodContext): + if message := context.event.message: for reaction in self.reactions: await message.add_reaction(reaction) - - -def create_action(data: JsonObject) -> AutomodAction: - return AddReactions.from_data(data) diff --git a/commanderbot/ext/automod/actions/add_roles_to_actor.py b/commanderbot/ext/automod/actions/add_roles_to_actor.py index 0e2a800..ea3317f 100644 --- a/commanderbot/ext/automod/actions/add_roles_to_actor.py +++ b/commanderbot/ext/automod/actions/add_roles_to_actor.py @@ -1,32 +1,22 @@ -from dataclasses import dataclass -from typing import Optional +from typing import Literal, Optional, override from discord import Member -from commanderbot.ext.automod.actions.abc.add_roles_to_target_base import ( - AddRolesToTargetBase, -) -from commanderbot.ext.automod.automod_action import AutomodAction -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.actions.abc import AddRolesToTarget +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.predicates import is_member +__all__ = ("AddRolesToActor",) -@dataclass -class AddRolesToActor(AddRolesToTargetBase): + +class AddRolesToActor(AddRolesToTarget): """ Add roles to the actor in context. - - Attributes - ---------- - roles - The roles to add. - reason - The reason why roles were added, if any. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor - + type: Literal["add_roles_to_actor"] -def create_action(data: JsonObject) -> AutomodAction: - return AddRolesToActor.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[Member]: + if (member := context.event.member) and is_member(member): + return member diff --git a/commanderbot/ext/automod/actions/add_roles_to_author.py b/commanderbot/ext/automod/actions/add_roles_to_author.py index 9a7392c..c58be7c 100644 --- a/commanderbot/ext/automod/actions/add_roles_to_author.py +++ b/commanderbot/ext/automod/actions/add_roles_to_author.py @@ -1,32 +1,22 @@ -from dataclasses import dataclass -from typing import Optional +from typing import Literal, Optional, override from discord import Member -from commanderbot.ext.automod.actions.abc.add_roles_to_target_base import ( - AddRolesToTargetBase, -) -from commanderbot.ext.automod.automod_action import AutomodAction -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.actions.abc import AddRolesToTarget +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.predicates import is_member +__all__ = ("AddRolesToAuthor",) -@dataclass -class AddRolesToAuthor(AddRolesToTargetBase): + +class AddRolesToAuthor(AddRolesToTarget): """ Add roles to the author in context. - - Attributes - ---------- - roles - The roles to add. - reason - The reason why roles were added, if any. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author - + type: Literal["add_roles_to_author"] -def create_action(data: JsonObject) -> AutomodAction: - return AddRolesToAuthor.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[Member]: + if (member := context.event.member) and is_member(member): + return member diff --git a/commanderbot/ext/automod/actions/add_users_to_thread.py b/commanderbot/ext/automod/actions/add_users_to_thread.py index 48aaaee..e7c18ac 100644 --- a/commanderbot/ext/automod/actions/add_users_to_thread.py +++ b/commanderbot/ext/automod/actions/add_users_to_thread.py @@ -1,56 +1,61 @@ -from dataclasses import dataclass, field +from typing import Literal, override from discord import Thread +from pydantic import Field -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject, RoleID, UserID +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.types import RoleID, UserID +__all__ = ("AddUsersToThread",) -@dataclass -class AddUsersToThread(AutomodActionBase): + +class AddUsersToThread(AutomodAction): """ Add users to the thread in context. - - Attributes - ---------- - users - The users to add to the thread. - roles - The roles to add to the thread. Any user with any of these roles will be added. """ - users: tuple[UserID] = field(default_factory=tuple) - roles: tuple[RoleID] = field(default_factory=tuple) + type: Literal["add_users_to_thread"] + + users: set[UserID] = Field(default_factory=set) + """The users to add to the thread.""" + + roles: set[RoleID] = Field(default_factory=set) + """The roles to add to the thread. Any user with any of these roles will be added.""" - async def try_add_user(self, event: AutomodEvent, thread: Thread, user_id: UserID): + async def _try_add_user( + self, context: AutomodContext, thread: Thread, user_id: UserID + ): try: guild = thread.guild - assert guild is not None member = guild.get_member(user_id) - assert member is not None + assert member await thread.add_user(member) except: - event.log.exception(f"Failed to add user {user_id} to thread {thread.id}") + context.log.exception( + f"Failed to add user '{user_id}' to thread '{thread.id}'" + ) - async def try_add_role(self, event: AutomodEvent, thread: Thread, role_id: RoleID): + async def _try_add_role( + self, context: AutomodContext, thread: Thread, role_id: RoleID + ): try: guild = thread.guild - assert guild is not None role = guild.get_role(role_id) - assert role is not None + assert role for member in role.members: await thread.add_user(member) except: - event.log.exception(f"Failed to add role {role_id} to thread {thread.id}") - - async def apply(self, event: AutomodEvent): - if thread := event.thread: - for user_id in self.users: - await self.try_add_user(event, thread, user_id) - for role_id in self.roles: - await self.try_add_role(event, thread, role_id) - - -def create_action(data: JsonObject) -> AutomodAction: - return AddUsersToThread.from_data(data) + context.log.exception( + f"Failed to add role '{role_id}' to thread '{thread.id}'" + ) + + @override + async def apply(self, context: AutomodContext): + if thread := context.event.thread: + if self.users: + for user_id in self.users: + await self._try_add_user(context, thread, user_id) + if self.roles: + for role_id in self.roles: + await self._try_add_role(context, thread, role_id) diff --git a/commanderbot/ext/automod/actions/check_messages.py b/commanderbot/ext/automod/actions/check_messages.py deleted file mode 100644 index 7e2090b..0000000 --- a/commanderbot/ext/automod/actions/check_messages.py +++ /dev/null @@ -1,109 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar, cast - -from discord import Member, TextChannel, Thread - -from commanderbot.ext.automod.automod_action import ( - AutomodAction, - AutomodActionBase, - deserialize_actions, -) -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - deserialize_conditions, -) -from commanderbot.ext.automod.automod_event import AutomodEvent, AutomodEventBase -from commanderbot.lib import ChannelID, JsonObject, TextMessage, MessageableChannel - -ST = TypeVar("ST") - - -@dataclass -class CheckingMessage(AutomodEventBase): - _message: TextMessage - - @property - def channel(self) -> TextChannel | Thread: - return self._message.channel - - @property - def message(self) -> TextMessage: - return self._message - - @property - def author(self) -> Member: - return self._message.author # type: ignore - - @property - def actor(self) -> Member: - return self._message.author # type: ignore - - @property - def member(self) -> Member: - return self._message.author # type: ignore - - -@dataclass -class CheckMessages(AutomodActionBase): - """ - Check recent messages and apply actions to those that pass the conditions. - - Attributes - ---------- - conditions - The conditions to check messages against. - actions - The actions to apply to messages that pass the conditions. - lookup_limit - The number of messages to fetch and check. Defaults to 100. - channel - The channel to perform the search in. Defaults to the channel in context. - """ - - conditions: tuple[AutomodCondition] - actions: tuple[AutomodAction] - - lookup_limit: Optional[int] = None - - channel: Optional[ChannelID] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - raw_conditions = data["conditions"] - conditions = deserialize_conditions(raw_conditions) - raw_actions = data["actions"] - actions = deserialize_actions(raw_actions) - return cls( - description=data.get("description"), - conditions=conditions, - actions=actions, - lookup_limit=data.get("lookup_limit"), - ) - - async def resolve_channel( - self, event: AutomodEvent - ) -> Optional[MessageableChannel]: - if self.channel is not None: - return event.bot.get_channel(self.channel) # type: ignore - return event.channel - - async def apply_message(self, dummy_event: CheckingMessage): - for action in self.actions: - await action.apply(dummy_event) - - async def check_message(self, dummy_event: CheckingMessage): - for condition in self.conditions: - if not await condition.check(dummy_event): - return - await self.apply_message(dummy_event) - - async def apply(self, event: AutomodEvent): - if channel := await self.resolve_channel(event): - async for message in channel.history(limit=self.lookup_limit or 100): - message = cast(TextMessage, message) - dummy_event = CheckingMessage(event.bot, event.log, message) - await self.check_message(dummy_event) - - -def create_action(data: JsonObject) -> AutomodAction: - return CheckMessages.from_data(data) diff --git a/commanderbot/ext/automod/actions/delete_message.py b/commanderbot/ext/automod/actions/delete_message.py index 2127b25..c7bbd98 100644 --- a/commanderbot/ext/automod/actions/delete_message.py +++ b/commanderbot/ext/automod/actions/delete_message.py @@ -1,20 +1,19 @@ -from dataclasses import dataclass +from typing import Literal, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +__all__ = ("DeleteMessage",) -@dataclass -class DeleteMessage(AutomodActionBase): + +class DeleteMessage(AutomodAction): """ Delete the message in context. """ - async def apply(self, event: AutomodEvent): - if message := event.message: - await message.delete() - + type: Literal["delete_message"] -def create_action(data: JsonObject) -> AutomodAction: - return DeleteMessage.from_data(data) + @override + async def apply(self, context: AutomodContext): + if message := context.event.message: + await message.delete() diff --git a/commanderbot/ext/automod/actions/dm_member.py b/commanderbot/ext/automod/actions/dm_member.py index a7d42be..b372300 100644 --- a/commanderbot/ext/automod/actions/dm_member.py +++ b/commanderbot/ext/automod/actions/dm_member.py @@ -1,31 +1,51 @@ -from dataclasses import dataclass -from typing import TypeVar +from typing import Literal, Optional, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from pydantic import Field -ST = TypeVar("ST") +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.allowed_mentions import AllowedMentions +from commanderbot.lib.types import Timedelta +from commanderbot.lib.utils import dict_without_nones +__all__ = ("DMMember",) -@dataclass -class DMMember(AutomodActionBase): - """ - Send a direct message to the member in question. - Attributes - ---------- - content - The content of the message to send. +class DMMember(AutomodAction): + """ + Send a direct message to the member in context. """ - content: str + type: Literal["dm_member"] - async def apply(self, event: AutomodEvent): - if member := event.member: - content = event.format_content(self.content) - await member.send(content) + content: str + """The content of the message to send.""" + allowed_mentions: AllowedMentions = Field( + default_factory=AllowedMentions.not_everyone + ) + """ + The types of mentions allowed in the message. Unless otherwise specified, only + "everyone" mentions will be suppressed. + """ -def create_action(data: JsonObject) -> AutomodAction: - return DMMember.from_data(data) + delete_after: Optional[Timedelta] = None + """The amount of time to wait before deleting the message, if at all.""" + + suppress_embeds: Optional[bool] = None + """Suppress any embeds for the message, if at all.""" + + silent: Optional[bool] = None + """Suppress desktop and push notifications for the message, if at all.""" + + @override + async def apply(self, context: AutomodContext): + if member := context.event.member: + content = context.format_content(self.content) + params = dict_without_nones( + allowed_mentions=self.allowed_mentions, + delete_after=td.total_seconds() if (td := self.delete_after) else None, + suppress_embeds=self.suppress_embeds, + silent=self.silent, + ) + await member.send(content, **params) diff --git a/commanderbot/ext/automod/actions/edit_thread.py b/commanderbot/ext/automod/actions/edit_thread.py index e0d215f..09fef5d 100644 --- a/commanderbot/ext/automod/actions/edit_thread.py +++ b/commanderbot/ext/automod/actions/edit_thread.py @@ -1,51 +1,76 @@ -from dataclasses import dataclass -from typing import Optional +from typing import Annotated, Literal, Optional, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject, is_thread, utils +from pydantic import Field +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.types import ForumTagID +from commanderbot.lib.utils import dict_without_nones -@dataclass -class EditThread(AutomodActionBase): +__all__ = ("EditThread",) + +type SlowmodeDelay = Annotated[int, Field(ge=0, le=21600)] +type AutoArchiveDuration = Literal[60, 1440, 4320, 10080] + + +class EditThread(AutomodAction): """ Edit the thread in context. - - Attributes - ---------- - name - The new name of the thread. - archived - Whether to archive the thread or not. - locked - Whether to lock the thread or not. - slowmode_delay - Specifies the slowmode rate limit for user in this thread, in seconds. - A value of ``0`` disables slowmode. The maximum value possible is ``21600``. - auto_archive_duration - The new duration in minutes before a thread is automatically archived for - inactivity. Must be one of ``60``, ``1440``, ``4320``, or ``10080``. """ + type: Literal["edit_thread"] + name: Optional[str] = None + """The new name of the thread.""" + + tags: set[ForumTagID] = Field(default_factory=set) + """ + The new tags to apply to the thread. This only works if the thread is part of a forum. + There can only be up to 5 tags applied to a thread. + """ + + pinned: Optional[bool] = None + """Whether to pin the thread or not. This only works if the thread is part of a forum.""" + archived: Optional[bool] = None + """Whether to archive the thread or not.""" + locked: Optional[bool] = None - slowmode_delay: Optional[int] = None - auto_archive_duration: Optional[int] = None - - async def apply(self, event: AutomodEvent): - thread = event.channel - if not is_thread(thread): - return - params = utils.dict_without_nones( - name=self.name, - archived=self.archived, - locked=self.locked, - slowmode_delay=self.slowmode_delay, - auto_archive_duration=self.auto_archive_duration, - ) - await thread.edit(**params) - - -def create_action(data: JsonObject) -> AutomodAction: - return EditThread.from_data(data) + """Whether to lock the thread or not.""" + + invitable: Optional[bool] = None + """ + Whether non-moderators can add other non-moderators to the thread. + Only available for private threads. + """ + + slowmode_delay: Optional[SlowmodeDelay] = None + """ + Specifies the slowmode rate limit for users in the thread, in seconds. + A value of `0` disables slowmode. The maximum value possible is `21600`. + """ + + auto_hide_duration: Optional[AutoArchiveDuration] = None + """ + The new duration in minutes before a thread is automatically hidden + from the channel list. Must be one of `60`, `1440`, `4320`, or `10080`. + """ + + reason: Optional[str] = None + """The reason why the thread was edited, if any.""" + + @override + async def apply(self, context: AutomodContext): + if thread := context.event.thread: + params = dict_without_nones( + name=self.name, + applied_tags=self.tags, + pinned=self.pinned, + archived=self.archived, + locked=self.locked, + invitable=self.invitable, + slowmode_delay=self.slowmode_delay, + auto_archive_duration=self.auto_hide_duration, + reason=self.reason, + ) + await thread.edit(**params) diff --git a/commanderbot/ext/automod/actions/join_thread.py b/commanderbot/ext/automod/actions/join_thread.py index 3081e46..bf0a67f 100644 --- a/commanderbot/ext/automod/actions/join_thread.py +++ b/commanderbot/ext/automod/actions/join_thread.py @@ -1,24 +1,22 @@ -from dataclasses import dataclass +from typing import Literal, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +__all__ = ("JoinThread",) -@dataclass -class JoinThread(AutomodActionBase): - """ - Join the thread in context. - Even though bots receive events for a thread, they are not listed as a member of the - thread automatically. This can cause issues with sending messages; for example, the - first message may be duplicated if the bot is not listed as a member of it. +class JoinThread(AutomodAction): """ + Make the bot join the thread in context. - async def apply(self, event: AutomodEvent): - if thread := event.thread: - await thread.join() + Even though bots receive events for threads, they are not listed as a member of the + thread automatically. This can cause issues with sending messages. + """ + type: Literal["join_thread"] -def create_action(data: JsonObject) -> AutomodAction: - return JoinThread.from_data(data) + @override + async def apply(self, context: AutomodContext): + if thread := context.event.thread: + await thread.join() diff --git a/commanderbot/ext/automod/actions/log_message.py b/commanderbot/ext/automod/actions/log_message.py index d5b76a7..fa4a735 100644 --- a/commanderbot/ext/automod/actions/log_message.py +++ b/commanderbot/ext/automod/actions/log_message.py @@ -1,108 +1,139 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar - -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import ( - AllowedMentions, - ChannelID, - Color, - JsonObject, - MessageableChannel, - ValueFormatter, - utils, -) - -ST = TypeVar("ST") - - -@dataclass -class LogMessage(AutomodActionBase): +from typing import Literal, Optional, override + +from discord import File, Thread, ui +from pydantic import Field + +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.types import ContextFields +from commanderbot.lib.allowed_mentions import AllowedMentions +from commanderbot.lib.color import Color +from commanderbot.lib.constants import MAX_MESSAGE_LENGTH +from commanderbot.lib.predicates import is_messagable_guild_channel, is_thread +from commanderbot.lib.types import ChannelID, MessageableGuildChannel +from commanderbot.lib.utils import message_to_file + +__all__ = ("LogMessage",) + + +class LogMessage(AutomodAction): """ - Send a log message, with pings disabled by default. - - Attributes - ---------- - content - The content of the message to send. - channel - The channel to send the message in. Defaults to the channel in context. - emoji - The emoji used to represent the type of message. - color - The emoji used to represent the type of message. - attach_message - Whether to attach the message-in-context (as a text file) to the log message. - fields - A custom set of fields to display as part of the message. The key should - correspond to an event field, and the value is the title to use for it. - allowed_mentions - The types of mentions allowed in the message. Unless otherwise specified, all - mentions will be suppressed. + Send a log message. """ - content: Optional[str] = None + type: Literal["log_message"] + + content: str + """The content of the message to send.""" + channel: Optional[ChannelID] = None + """The channel to send the message in. Defaults to the channel in context.""" + emoji: Optional[str] = None + """The emoji used to represent the type of message.""" + color: Optional[Color] = None - attach_message: Optional[bool] = None - fields: Optional[dict[str, str]] = None - allowed_mentions: Optional[AllowedMentions] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - color = Color.from_field_optional(data, "color") - allowed_mentions = AllowedMentions.from_field_optional(data, "allowed_mentions") - return cls( - description=data.get("description"), - content=data.get("content"), - channel=data.get("channel"), - emoji=data.get("emoji"), - color=color, - fields=data.get("fields"), - attach_message=data.get("attach_message"), - allowed_mentions=allowed_mentions, - ) + """The color used to represent the type of message.""" + + attach_message: bool = False + """Whether to attach the message-in-context to the log message or not.""" + + attach_media: bool = False + """Whether to attach image/video attachments to the log message or not.""" - async def resolve_channel( - self, event: AutomodEvent - ) -> Optional[MessageableChannel]: - if self.channel is not None: - return event.bot.get_channel(self.channel) - return event.channel - - async def apply(self, event: AutomodEvent): - if channel := await self.resolve_channel(event): - parts = [] - if self.emoji: - parts.append(self.emoji) - if self.content: - formatted_content = event.format_content(self.content) - parts.append(formatted_content) - if self.fields: - event_fields = event.get_fields() - field_parts = [] - for field_key, field_title in self.fields.items(): - field_value = event_fields.get(field_key) - if isinstance(field_value, ValueFormatter): - field_value_str = str(field_value) - else: - field_value_str = f"`{field_value}`" - field_parts.append(f"{field_title} {field_value_str}") - fields_str = ", ".join(field_parts) - parts.append(fields_str) - content = " ".join(parts) - allowed_mentions = self.allowed_mentions or AllowedMentions.none() - if self.attach_message and (message := event.message): - message_file = utils.message_to_file(message) - await channel.send( - content, - file=message_file, - allowed_mentions=allowed_mentions, - ) - else: - await channel.send(content, allowed_mentions=allowed_mentions) - - -def create_action(data: JsonObject) -> AutomodAction: - return LogMessage.from_data(data) + fields: Optional[dict[ContextFields, str]] = None + """ + A custom set of fields to display as part of the message. The key should + correspond to an event field, and the value is the title to use for it. + """ + + allowed_mentions: AllowedMentions = Field(default_factory=AllowedMentions.none) + """ + The types of mentions allowed in the message. Unless otherwise specified, all + mentions will be suppressed. + """ + + def _resolve_channel( + self, context: AutomodContext + ) -> Optional[MessageableGuildChannel | Thread]: + channel = context.event.channel + if self.channel: + channel = context.bot.get_channel(self.channel) + + if is_messagable_guild_channel(channel) or is_thread(channel): + return channel + + def _build_log_view( + self, context: AutomodContext + ) -> tuple[ui.LayoutView, list[File]]: + log_view = ui.LayoutView() + log_container = ui.Container(accent_color=self.color) + log_view.add_item(log_container) + + # Create the title + title: Optional[str] = None + if self.emoji: + title = f"### {self.emoji} {context.rule.name}" + else: + title = f"### {context.rule.name}" + log_container.add_item(ui.TextDisplay(title)) + + # Add content + content = context.format_content(self.content) + log_container.add_item(ui.Separator()) + log_container.add_item(ui.TextDisplay(content)) + + files: list[File] = [] + if message := context.event.message: + # Add message if it needs to be attached + if self.attach_message and message.content: + log_container.add_item(ui.Separator()) + if len(message.content) > MAX_MESSAGE_LENGTH: + file = message_to_file(message) + files.append(file) + log_container.add_item(ui.File(file)) + else: + formatted_content = f"```\n{message.content}\n```" + log_container.add_item(ui.TextDisplay(formatted_content)) + + file = message_to_file(message) + files.append(file) + log_container.add_item(ui.File(file)) + + # Attach media if it needs to be attached + if self.attach_media: + gallery = ui.MediaGallery() + has_media: bool = False + for attachment in message.attachments: + content_type = attachment.content_type + if content_type and content_type.startswith(("image", "video")): + has_media = True + gallery.add_item(media=attachment.url) + if has_media: + log_container.add_item(ui.Separator()) + log_container.add_item(gallery) + + # Add fields if any were specified + if self.fields: + field_lines = [] + context_fields = context.get_fields() + for field_name, field_title in self.fields.items(): + if field_value := context_fields.get(field_name): + field_lines.append(f"- **{field_title}**: `{field_value}`") + + if field_lines: + log_container.add_item(ui.Separator()) + log_container.add_item(ui.TextDisplay("\n".join(field_lines))) + + return (log_view, files) + + @override + async def apply(self, context: AutomodContext): + channel = self._resolve_channel(context) + if not channel: + return + + view, files = self._build_log_view(context) + await channel.send( + view=view, files=files, allowed_mentions=self.allowed_mentions + ) diff --git a/commanderbot/ext/automod/actions/randomize.py b/commanderbot/ext/automod/actions/randomize.py new file mode 100644 index 0000000..fa09bf2 --- /dev/null +++ b/commanderbot/ext/automod/actions/randomize.py @@ -0,0 +1,71 @@ +import random +from typing import TYPE_CHECKING, Literal, Optional, override + +from pydantic import BaseModel, ConfigDict, Field, PositiveInt + +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext + +__all__ = ("Randomize",) + +if TYPE_CHECKING: + from commanderbot.ext.automod.action import AutomodActionType + + +class WeightedAction(BaseModel): + """ + A sub-action with an optional weight. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + action: Optional[AutomodActionType] = None + """The sub-action to apply.""" + + weight: PositiveInt = 1 + """The weight of the sub-action. Defaults to `1`.""" + + +class Randomize(AutomodAction): + """ + Apply a random sub-action. + """ + + type: Literal["randomize"] + + actions: list[AutomodActionType | WeightedAction] = Field( + default_factory=list, min_length=1 + ) + """The sub-actions to pick from.""" + + def _roll(self) -> Optional[AutomodActionType]: + # Sum all the weights + total_weight: int = 0 + for entry in self.actions: + if isinstance(entry, WeightedAction): + total_weight += entry.weight + else: + total_weight += 1 + + # Pick a number between 0 and `total_weight` + target_weight = random.randint(0, total_weight) + + # Get the first entry that causes `current_weight` to be + # greater than or equal to `target_weight` + current_weight: int = 0 + for entry in self.actions: + action: Optional[AutomodActionType] = None + if isinstance(entry, WeightedAction): + action = entry.action + current_weight += entry.weight + else: + action = entry + current_weight += 1 + + if current_weight >= target_weight: + return action + + @override + async def apply(self, context: AutomodContext): + if (action := self._roll()) and not action.disabled: + await action.apply(context) diff --git a/commanderbot/ext/automod/actions/remove_all_reactions.py b/commanderbot/ext/automod/actions/remove_all_reactions.py index 4121aef..12d5b00 100644 --- a/commanderbot/ext/automod/actions/remove_all_reactions.py +++ b/commanderbot/ext/automod/actions/remove_all_reactions.py @@ -1,20 +1,19 @@ -from dataclasses import dataclass +from typing import Literal, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +__all__ = ("RemoveAllReactions",) -@dataclass -class RemoveAllReactions(AutomodActionBase): + +class RemoveAllReactions(AutomodAction): """ Remove all reactions from the message in context. """ - async def apply(self, event: AutomodEvent): - if message := event.message: - await message.clear_reactions() - + type: Literal["remove_all_reactions"] -def create_action(data: JsonObject) -> AutomodAction: - return RemoveAllReactions.from_data(data) + @override + async def apply(self, context: AutomodContext): + if message := context.event.message: + await message.clear_reactions() diff --git a/commanderbot/ext/automod/actions/remove_own_reactions.py b/commanderbot/ext/automod/actions/remove_own_reactions.py index cdc62be..47d2314 100644 --- a/commanderbot/ext/automod/actions/remove_own_reactions.py +++ b/commanderbot/ext/automod/actions/remove_own_reactions.py @@ -1,28 +1,25 @@ -from dataclasses import dataclass +from typing import Literal, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from pydantic import Field +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext -@dataclass -class RemoveOwnReactions(AutomodActionBase): +__all__ = ("RemoveOwnReactions",) + + +class RemoveOwnReactions(AutomodAction): """ Remove the bot's own reactions from the message in context. - - Attributes - ---------- - reactions - The reactions to remove. """ - reactions: tuple[str] - - async def apply(self, event: AutomodEvent): - if message := event.message: - for reaction in self.reactions: - await message.remove_reaction(reaction, member=event.bot.user) + type: Literal["remove_own_reactions"] + reactions: set[str] = Field(default_factory=set, min_length=1) + """The reactions to remove.""" -def create_action(data: JsonObject) -> AutomodAction: - return RemoveOwnReactions.from_data(data) + @override + async def apply(self, context: AutomodContext): + if (message := context.event.message) and (bot_user := context.bot.user): + for reaction in self.reactions: + await message.remove_reaction(reaction, bot_user) diff --git a/commanderbot/ext/automod/actions/remove_reactions.py b/commanderbot/ext/automod/actions/remove_reactions.py index 310549c..a135ff2 100644 --- a/commanderbot/ext/automod/actions/remove_reactions.py +++ b/commanderbot/ext/automod/actions/remove_reactions.py @@ -1,28 +1,26 @@ -from dataclasses import dataclass +from typing import Literal, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from pydantic import Field +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext -@dataclass -class RemoveReactions(AutomodActionBase): +__all__ = ("RemoveReactions",) + + +class RemoveReactions(AutomodAction): """ Remove certain reactions from the message in context. - - Attributes - ---------- - reactions - The reactions to remove. """ - reactions: tuple[str] + type: Literal["remove_reactions"] - async def apply(self, event: AutomodEvent): - if message := event.message: + reactions: set[str] = Field(default_factory=set, min_length=1) + """The reactions to remove.""" + + @override + async def apply(self, context: AutomodContext): + if message := context.event.message: for reaction in self.reactions: await message.clear_reaction(reaction) - -def create_action(data: JsonObject) -> AutomodAction: - return RemoveReactions.from_data(data) diff --git a/commanderbot/ext/automod/actions/remove_roles_from_actor.py b/commanderbot/ext/automod/actions/remove_roles_from_actor.py index b89b6b7..b52924d 100644 --- a/commanderbot/ext/automod/actions/remove_roles_from_actor.py +++ b/commanderbot/ext/automod/actions/remove_roles_from_actor.py @@ -1,30 +1,22 @@ -from dataclasses import dataclass -from typing import Optional +from typing import Literal, Optional, override from discord import Member -from commanderbot.ext.automod.actions.abc.remove_roles_from_target_base import ( - RemoveRolesFromTargetBase, -) -from commanderbot.ext.automod.automod_action import AutomodAction -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.actions.abc import RemoveRolesFromTarget +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.predicates import is_member +__all__ = ("RemoveRolesFromActor",) -@dataclass -class RemoveRolesFromActor(RemoveRolesFromTargetBase): + +class RemoveRolesFromActor(RemoveRolesFromTarget): """ Remove roles from the actor in context. - - Attributes - ---------- - roles - The roles to remove. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor - + type: Literal["remove_roles_from_actor"] -def create_action(data: JsonObject) -> AutomodAction: - return RemoveRolesFromActor.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[Member]: + if (member := context.event.member) and is_member(member): + return member diff --git a/commanderbot/ext/automod/actions/remove_roles_from_author.py b/commanderbot/ext/automod/actions/remove_roles_from_author.py index b9b0cb1..2aece67 100644 --- a/commanderbot/ext/automod/actions/remove_roles_from_author.py +++ b/commanderbot/ext/automod/actions/remove_roles_from_author.py @@ -1,30 +1,22 @@ -from dataclasses import dataclass -from typing import Optional +from typing import Literal, Optional, override from discord import Member -from commanderbot.ext.automod.actions.abc.remove_roles_from_target_base import ( - RemoveRolesFromTargetBase, -) -from commanderbot.ext.automod.automod_action import AutomodAction -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.actions.abc import RemoveRolesFromTarget +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.predicates import is_member +__all__ = ("RemoveRolesFromAuthor",) -@dataclass -class RemoveRolesFromAuthor(RemoveRolesFromTargetBase): + +class RemoveRolesFromAuthor(RemoveRolesFromTarget): """ Remove roles from the author in context. - - Attributes - ---------- - roles - The roles to remove. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author - + type: Literal["remove_roles_from_author"] -def create_action(data: JsonObject) -> AutomodAction: - return RemoveRolesFromAuthor.from_data(data) + @override + def get_target(self, context: AutomodContext) -> Optional[Member]: + if (member := context.event.member) and is_member(member): + return member diff --git a/commanderbot/ext/automod/actions/reply_to_message.py b/commanderbot/ext/automod/actions/reply_to_message.py index ce9e2d5..a12093d 100644 --- a/commanderbot/ext/automod/actions/reply_to_message.py +++ b/commanderbot/ext/automod/actions/reply_to_message.py @@ -1,48 +1,55 @@ -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import AllowedMentions, JsonObject +from pydantic import Field -ST = TypeVar("ST") +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.allowed_mentions import AllowedMentions +from commanderbot.lib.types import Timedelta +from commanderbot.lib.utils import dict_without_nones +__all__ = ("ReplyToMessage",) -@dataclass -class ReplyToMessage(AutomodActionBase): + +class ReplyToMessage(AutomodAction): """ Reply to the message in context. - - Attributes - ---------- - content - The content of the message to send. - allowed_mentions - The types of mentions allowed in the message. Unless otherwise specified, only - "everyone" mentions will be suppressed. """ + type: Literal["reply_to_message"] + content: str - allowed_mentions: Optional[AllowedMentions] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - allowed_mentions = AllowedMentions.from_field_optional(data, "allowed_mentions") - return cls( - description=data.get("description"), - content=data.get("content"), - allowed_mentions=allowed_mentions, - ) - - async def apply(self, event: AutomodEvent): - if message := event.message: - content = event.format_content(self.content) - allowed_mentions = self.allowed_mentions or AllowedMentions.not_everyone() - await message.reply( - content, - allowed_mentions=allowed_mentions, - ) + """The content of the message to send.""" + mention_author: Optional[bool] = None + """Mention the author of the message being replied to, if at all.""" -def create_action(data: JsonObject) -> AutomodAction: - return ReplyToMessage.from_data(data) + allowed_mentions: AllowedMentions = Field( + default_factory=AllowedMentions.not_everyone + ) + """ + The types of mentions allowed in the message. Unless otherwise specified, only + "everyone" mentions will be suppressed. + """ + + delete_after: Optional[Timedelta] = None + """The amount of time to wait before deleting the message, if at all.""" + + suppress_embeds: Optional[bool] = None + """Suppress any embeds for the message, if at all.""" + + silent: Optional[bool] = None + """Suppress desktop and push notifications for the message, if at all.""" + + @override + async def apply(self, context: AutomodContext): + if message := context.event.message: + content = context.format_content(self.content) + params = dict_without_nones( + mention_author=self.mention_author, + allowed_mentions=self.allowed_mentions, + delete_after=td.total_seconds() if (td := self.delete_after) else None, + suppress_embeds=self.suppress_embeds, + silent=self.silent, + ) + await message.reply(content, **params) diff --git a/commanderbot/ext/automod/actions/send_message.py b/commanderbot/ext/automod/actions/send_message.py index 44e2346..0455635 100644 --- a/commanderbot/ext/automod/actions/send_message.py +++ b/commanderbot/ext/automod/actions/send_message.py @@ -1,73 +1,69 @@ -from dataclasses import dataclass -from datetime import timedelta -from typing import Optional, Type, TypeVar +from typing import Literal, Optional, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import ( - AllowedMentions, - ChannelID, - JsonObject, - MessageableChannel, - utils, -) +from discord import Thread +from pydantic import Field -ST = TypeVar("ST") +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.allowed_mentions import AllowedMentions +from commanderbot.lib.predicates import is_messagable_guild_channel, is_thread +from commanderbot.lib.types import ChannelID, MessageableGuildChannel, Timedelta +from commanderbot.lib.utils import dict_without_nones +__all__ = ("SendMessage",) -@dataclass -class SendMessage(AutomodActionBase): + +class SendMessage(AutomodAction): """ Send a message. - - Attributes - ---------- - content - The content of the message to send. - channel - The channel to send the message in. Defaults to the channel in context. - allowed_mentions - The types of mentions allowed in the message. Unless otherwise specified, only - "everyone" mentions will be suppressed. - delete_after - The amount of time to delete the message after, if at all. """ + type: Literal["send_message"] + content: str + """The content of the message to send.""" + channel: Optional[ChannelID] = None - allowed_mentions: Optional[AllowedMentions] = None - delete_after: Optional[timedelta] = None - - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - allowed_mentions = AllowedMentions.from_field_optional(data, "allowed_mentions") - delete_after = utils.timedelta_from_field_optional(data, "delete_after") - return cls( - description=data.get("description"), - content=data.get("content"), - channel=data.get("channel"), - allowed_mentions=allowed_mentions, - delete_after=delete_after, - ) + """The channel to send the message in. Defaults to the channel in context.""" + + allowed_mentions: AllowedMentions = Field( + default_factory=AllowedMentions.not_everyone + ) + """ + The types of mentions allowed in the message. Unless otherwise specified, only + "everyone" mentions will be suppressed. + """ - async def resolve_channel( - self, event: AutomodEvent - ) -> Optional[MessageableChannel]: - if self.channel is not None: - return event.bot.get_channel(self.channel) - return event.channel - - async def apply(self, event: AutomodEvent): - if channel := await self.resolve_channel(event): - content = event.format_content(self.content) - allowed_mentions = self.allowed_mentions or AllowedMentions.not_everyone() - params = dict( - allowed_mentions=allowed_mentions, - ) - if self.delete_after is not None: - params.update(delete_after=self.delete_after.total_seconds()) - await channel.send(content, **params) - - -def create_action(data: JsonObject) -> AutomodAction: - return SendMessage.from_data(data) + delete_after: Optional[Timedelta] = None + """The amount of time to wait before deleting the message, if at all.""" + + suppress_embeds: Optional[bool] = None + """Suppress any embeds for the message, if at all.""" + + silent: Optional[bool] = None + """Suppress desktop and push notifications for the message, if at all.""" + + def _resolve_channel( + self, context: AutomodContext + ) -> Optional[MessageableGuildChannel | Thread]: + channel = context.event.channel + if self.channel: + channel = context.bot.get_channel(self.channel) + + if is_messagable_guild_channel(channel) or is_thread(channel): + return channel + + @override + async def apply(self, context: AutomodContext): + channel = self._resolve_channel(context) + if not channel: + return + + content = context.format_content(self.content) + params = dict_without_nones( + allowed_mentions=self.allowed_mentions, + delete_after=td.total_seconds() if (td := self.delete_after) else None, + suppress_embeds=self.suppress_embeds, + silent=self.silent, + ) + await channel.send(content, **params) diff --git a/commanderbot/ext/automod/actions/sequence.py b/commanderbot/ext/automod/actions/sequence.py new file mode 100644 index 0000000..383d5f9 --- /dev/null +++ b/commanderbot/ext/automod/actions/sequence.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING, Literal, override + +from pydantic import Field + +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext + +__all__ = ("Sequence",) + +if TYPE_CHECKING: + from commanderbot.ext.automod.action import AutomodActionType + + +class Sequence(AutomodAction): + """ + Apply sub-actions in sequence. + """ + + type: Literal["sequence"] + + actions: list[AutomodActionType] = Field(default_factory=list, min_length=1) + """The sub-actions to apply.""" + + @override + async def apply(self, context: AutomodContext): + for action in self.actions: + if not action.disabled: + await action.apply(context) diff --git a/commanderbot/ext/automod/actions/throw_error.py b/commanderbot/ext/automod/actions/throw_error.py index dde86a7..fe9c3f7 100644 --- a/commanderbot/ext/automod/actions/throw_error.py +++ b/commanderbot/ext/automod/actions/throw_error.py @@ -1,28 +1,23 @@ -from dataclasses import dataclass +from typing import Literal, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +__all__ = ("ThrowError",) -@dataclass -class ThrowError(AutomodActionBase): + +class ThrowError(AutomodAction): """ Throw an error when running the action. Intended for testing and debugging. - - Attributes - ---------- - error - A human-readable error message. """ + type: Literal["throw_error"] + error: str + """A human-readable error message.""" - async def apply(self, event: AutomodEvent): + @override + async def apply(self, context: AutomodContext): raise Exception(self.error) - - -def create_action(data: JsonObject) -> AutomodAction: - return ThrowError.from_data(data) diff --git a/commanderbot/ext/automod/actions/timeout_actor.py b/commanderbot/ext/automod/actions/timeout_actor.py new file mode 100644 index 0000000..3c40dd5 --- /dev/null +++ b/commanderbot/ext/automod/actions/timeout_actor.py @@ -0,0 +1,31 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.predicates import is_member +from commanderbot.lib.types import Timedelta + +__all__ = ("TimeoutActor",) + + +class TimeoutActor(AutomodAction): + """ + Timeout the actor in context. + """ + + type: Literal["timeout_actor"] + + duration: Timedelta + """ + How long the timeout will be. + + Note that the API only allows for timeouts up to 28 days. + """ + + reason: Optional[str] = None + """The reason for the timeout, if any.""" + + @override + async def apply(self, context: AutomodContext): + if (actor := context.event.actor) and is_member(actor): + await actor.timeout(self.duration, reason=self.reason) diff --git a/commanderbot/ext/automod/actions/timeout_author.py b/commanderbot/ext/automod/actions/timeout_author.py new file mode 100644 index 0000000..a9987c6 --- /dev/null +++ b/commanderbot/ext/automod/actions/timeout_author.py @@ -0,0 +1,31 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.predicates import is_member +from commanderbot.lib.types import Timedelta + +__all__ = ("TimeoutAuthor",) + + +class TimeoutAuthor(AutomodAction): + """ + Timeout the author in context. + """ + + type: Literal["timeout_author"] + + duration: Timedelta + """ + How long the timeout will be. + + Note that the API only allows for timeouts up to 28 days. + """ + + reason: Optional[str] = None + """The reason for the timeout, if any.""" + + @override + async def apply(self, context: AutomodContext): + if (author := context.event.author) and is_member(author): + await author.timeout(self.duration, reason=self.reason) diff --git a/commanderbot/ext/automod/actions/wait.py b/commanderbot/ext/automod/actions/wait.py index 6fc56b7..0e21f1a 100644 --- a/commanderbot/ext/automod/actions/wait.py +++ b/commanderbot/ext/automod/actions/wait.py @@ -1,39 +1,25 @@ import asyncio -from dataclasses import dataclass -from datetime import timedelta -from typing import Type, TypeVar +from typing import Literal, override -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject, utils +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.types import Timedelta -ST = TypeVar("ST") +__all__ = ("Wait",) -@dataclass -class Wait(AutomodActionBase): +class Wait(AutomodAction): """ Wait a certain amount of time before continuing. - - Attributes - ---------- - delay - How long to wait for. """ - delay: timedelta + type: Literal["wait"] - @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: - delay = utils.timedelta_from_field_optional(data, "delay") - return cls( - description=data.get("description"), - delay=delay, - ) + delay: Timedelta + """ + How long to wait for. + """ - async def apply(self, event: AutomodEvent): + @override + async def apply(self, context: AutomodContext): await asyncio.sleep(self.delay.total_seconds()) - - -def create_action(data: JsonObject) -> AutomodAction: - return Wait.from_data(data) diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index a54579b..b868782 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -2,6 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field +from commanderbot.ext.automod.action import AutomodActionType from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.condition import AutomodConditionType from commanderbot.ext.automod.trigger import AutomodTriggerType @@ -31,6 +32,9 @@ class AutomodRule(BaseModel): conditions: list[AutomodConditionType] = Field(default_factory=list) """A list of conditions that must *all* pass for the actions to run.""" + actions: list[AutomodActionType] = Field(default_factory=list, min_length=1) + """A list of actions that will all run if the conditions pass.""" + async def _poll_triggers(self, context: AutomodContext) -> bool: for trigger in self.triggers: if trigger.disabled: @@ -48,7 +52,10 @@ async def _check_conditions(self, context: AutomodContext) -> bool: return True async def _apply_actions(self, context: AutomodContext): - return + for action in self.actions: + if action.disabled: + continue + await action.apply(context) async def run(self, context: AutomodContext) -> bool: if self.disabled: From 4cd507b7c3a254a89b22a81fb3cc81f1766d2492 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 16 Jul 2026 11:05:32 -0700 Subject: [PATCH 049/158] Changed some defaults in database config --- commanderbot/lib/databases/json_db/config.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/commanderbot/lib/databases/json_db/config.py b/commanderbot/lib/databases/json_db/config.py index 46d9f95..efc70f0 100644 --- a/commanderbot/lib/databases/json_db/config.py +++ b/commanderbot/lib/databases/json_db/config.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Annotated, Literal, Optional, TypeIs +from typing import Annotated, Any, Literal, Optional, TypeIs from pydantic import BaseModel, BeforeValidator, Field @@ -11,11 +11,11 @@ class InMemoryDatabaseOptions(BaseModel): - type: Literal["in_memory"] = "in_memory" + type: Literal["in_memory"] class JsonFileDatabaseOptions(BaseModel): - type: Literal["json_file"] = "json_file" + type: Literal["json_file"] path: Path no_init: bool = False indent: Optional[int] = None @@ -23,7 +23,7 @@ class JsonFileDatabaseOptions(BaseModel): exclude_none: bool = False -def validate_options(data: Optional[str | dict]) -> dict: +def validate_options(data: Any) -> Any: if data is None: return {"type": "in_memory"} elif isinstance(data, str): @@ -33,7 +33,10 @@ def validate_options(data: Optional[str | dict]) -> dict: JsonDBOptions = Annotated[ InMemoryDatabaseOptions | JsonFileDatabaseOptions, - Field(default_factory=InMemoryDatabaseOptions, discriminator="type"), + Field( + discriminator="type", + default_factory=lambda: InMemoryDatabaseOptions(type="in_memory"), + ), BeforeValidator(validate_options), ] From 11dcafbafaf78a33e6213d61de580d16bd1b94a4 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 16 Jul 2026 11:07:07 -0700 Subject: [PATCH 050/158] `message_to_file`, `str_to_file`, and `send_message_or_file` now uses `io.BytesIO` when creating the file attachment. --- commanderbot/lib/utils/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/commanderbot/lib/utils/utils.py b/commanderbot/lib/utils/utils.py index fa89101..f69a28f 100644 --- a/commanderbot/lib/utils/utils.py +++ b/commanderbot/lib/utils/utils.py @@ -120,13 +120,13 @@ def message_to_file(message: Message, filename: Optional[str] = None) -> File: embed_json = json.dumps(embed.to_dict(), indent=2) file_lines.append(f"\n```json\n{embed_json}\n```") file_content = "\n".join(file_lines) - fp = cast(Any, io.StringIO(file_content)) + fp = io.BytesIO(file_content.encode()) file = File(fp=fp, filename=filename) return file -def str_to_file(contents: str, file_name: str) -> File: - fp = cast(Any, io.StringIO(contents)) +def str_to_file(content: str, file_name: str) -> File: + fp = io.BytesIO(content.encode()) return File(fp=fp, filename=file_name) @@ -159,7 +159,7 @@ async def send_message_or_file( return await destination.send(content, allowed_mentions=allowed_mentions) else: alt_content, file_content, file_name = file_callback() - fp = cast(Any, io.StringIO(file_content)) + fp = io.BytesIO(file_content.encode()) file = File(fp=fp, filename=file_name) return await destination.send( alt_content, From f0654152535317a58aa7390782de1124f01a9981 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 16 Jul 2026 11:08:26 -0700 Subject: [PATCH 051/158] Added optional dependencies for OCR. --- pyproject.toml | 22 ++++++-- uv.lock | 147 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e9dd748..0924a7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ readme = { file = "README.md", content-type = "text/markdown" } keywords = ["discord", "bot"] classifiers = [ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.14" + "Programming Language :: Python :: 3.14", ] dependencies = [ @@ -22,7 +22,7 @@ dependencies = [ "python-dotenv>=1.0.0", "pydantic>=2.13.0", "aiohttp>=3.13.0", - + # These are used for logging. "colorama>=0.4.0", "colorlog>=6.10.0", @@ -36,6 +36,15 @@ dependencies = [ "allay>=1.3.0", "mccq>=1.0.0", "psutil>=7.0.0", + +] + +[project.optional-dependencies] +automod-ocr = [ + "numpy>=2.5.0", + "opencv-python-headless>=5.0.0.0", + "pillow>=12.3.0", + "tesserocr>=2.10.0", ] [project.urls] @@ -55,10 +64,13 @@ profile = "black" [tool.pyright] typeCheckingMode = "basic" -[build-system] -requires = ["uv_build<0.12"] -build-backend = "uv_build" +[tool.uv.sources] +tesserocr = { url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl", marker = "sys_platform == 'win32'", extra = "automod-ocr" } [tool.uv.build-backend] module-name = "commanderbot" module-root = "" + +[build-system] +requires = ["uv_build<0.12"] +build-backend = "uv_build" diff --git a/uv.lock b/uv.lock index ec28d6a..6d9c67b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = "==3.14.*" +resolution-markers = [ + "sys_platform == 'linux'", + "sys_platform == 'win32'", + "sys_platform != 'linux' and sys_platform != 'win32'", +] [[package]] name = "aiohappyeyeballs" @@ -225,6 +230,15 @@ dependencies = [ { name = "pyyaml" }, ] +[package.optional-dependencies] +automod-ocr = [ + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "tesserocr", version = "2.10.0", source = { registry = "https://pypi.org/simple/" }, marker = "sys_platform != 'win32'" }, + { name = "tesserocr", version = "2.10.0", source = { url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl" }, marker = "sys_platform == 'win32'" }, +] + [package.dev-dependencies] dev = [ { name = "black" }, @@ -241,11 +255,17 @@ requires-dist = [ { name = "emoji", specifier = ">=2.0.0" }, { name = "jsonpath-ng", specifier = ">=1.7.0" }, { name = "mccq", specifier = ">=1.0.0" }, + { name = "numpy", marker = "extra == 'automod-ocr'", specifier = ">=2.5.0" }, + { name = "opencv-python-headless", marker = "extra == 'automod-ocr'", specifier = ">=5.0.0.0" }, + { name = "pillow", marker = "extra == 'automod-ocr'", specifier = ">=12.3.0" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pydantic", specifier = ">=2.13.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "tesserocr", marker = "sys_platform == 'win32' and extra == 'automod-ocr'", url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl" }, + { name = "tesserocr", marker = "sys_platform != 'win32' and extra == 'automod-ocr'", specifier = ">=2.10.0" }, ] +provides-extras = ["automod-ocr"] [package.metadata.requires-dev] dev = [ @@ -253,6 +273,20 @@ dev = [ { name = "isort", specifier = ">=5.13.0" }, ] +[[package]] +name = "cysignals" +version = "1.12.6" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/5a/d258fd8d6ee1538b8472f39051a87d3d6aa2ab26ffa2da4ac809fb851b88/cysignals-1.12.6.tar.gz", hash = "sha256:3ef3a37bdb244821b85475a08e2762ca1019570b369e321504995fa9a54675ce", size = 79583, upload-time = "2025-10-30T04:28:44.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/0c/db66ab5e7be5454e39eac13e5a5bf908b28af590cb4e75a5d9da5005ab7b/cysignals-1.12.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0e1b9c1f0a1a6ddc3b550893aa032cb2e865a60b8480d3ec61bf4f24f232cf1", size = 219287, upload-time = "2025-10-30T04:28:29.603Z" }, + { url = "https://files.pythonhosted.org/packages/23/ea/e60bf45dbfb49a349b2ac9812526be40cc17a6b854308301932883dad85b/cysignals-1.12.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:948d9b0fcdb54d6ef0624991fb22b9c57a63467da56d46bc1f8edb618c900584", size = 269488, upload-time = "2025-10-30T04:28:31.005Z" }, + { url = "https://files.pythonhosted.org/packages/71/bb/2f4097bcc7b6de3cceba80d830c653dc893feeef0914066580770aba1cdf/cysignals-1.12.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8eceead50d00487179017eb81b00a7bbf2acfcef6869ba950a13e0e3ee5fef07", size = 272516, upload-time = "2025-10-30T04:28:32.798Z" }, + { url = "https://files.pythonhosted.org/packages/75/f8/25a75c4106eb1ed54b0ab928d8206d3906bcf708ef952a141fff88e2c034/cysignals-1.12.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:83c4f6bb0cd1fc58fc55a3f0dbca0e1229113e3faf06e9a1a7f9cb19a4263f6f", size = 231614, upload-time = "2025-10-30T04:28:36.785Z" }, + { url = "https://files.pythonhosted.org/packages/07/13/b10ef901ded109b6e86fadf123b7d8dc3646f64aefb5633e5b85bcd09ccb/cysignals-1.12.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fd29e7452de0d8c7a929b29e8ba7f8bfa84fca746e80263799db026b56b8a1e", size = 279460, upload-time = "2025-10-30T04:28:38.282Z" }, + { url = "https://files.pythonhosted.org/packages/15/55/ba70d9babff953d1b1730bd685ad47c2a4cee2f384a23d74f7f952d1f4e8/cysignals-1.12.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:576c16e08b4a917c23ca6d586131a53bedc921b9af8e311dbfc145d39dacd9cd", size = 283163, upload-time = "2025-10-30T04:28:40.517Z" }, +] + [[package]] name = "discord-py" version = "2.7.1" @@ -406,6 +440,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -424,6 +506,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, +] + [[package]] name = "platformdirs" version = "4.10.0" @@ -608,6 +719,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "tesserocr" +version = "2.10.0" +source = { registry = "https://pypi.org/simple/" } +resolution-markers = [ + "sys_platform == 'linux'", + "sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "cysignals" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/af/7d4040c7390cd42410628408fbdea2067c8e5ca3a45d2bffd26c1ffddae4/tesserocr-2.10.0.tar.gz", hash = "sha256:439c7a9f0fd0f6a298aaa746d5f2ccd4eba50db3ebccf500c89a6aa0d59d2999", size = 75915, upload-time = "2026-02-12T11:10:35.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/1c/144067dda813f065c90601a74d5b740a0c26878ba4816d3142146fd4cd12/tesserocr-2.10.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:524624a5f3924996c1d2517a1b1ff71ae3c26e5f2fa315a1d3632348e9b8a4ef", size = 3605531, upload-time = "2026-02-12T11:10:16.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/1a/43d1f4d366f294317d5b531408c6ca447e90e07f7d47b14273638b9d06af/tesserocr-2.10.0-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:fb17a2100968c96ef3779d450a5d0b9d36b80ca922b2e5d05c48b31ed7fc5df4", size = 4078787, upload-time = "2026-02-12T11:10:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/af/1de07931f1a023c36ad3161c8f2ae0fb9921886f264f3cf5fbeb901126e6/tesserocr-2.10.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b7a4e1819510b335a2a9ebb95987dcbd5d20f8798f0144d11ad4cce431ff4f", size = 5181932, upload-time = "2026-02-12T11:10:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/a0b0aac4418fceca7cabfc4dbf95dd9fb9b439727d8c01cbcc6544e32334/tesserocr-2.10.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:353b81d8a2e8e3e8172aa48d9bbaa10d7d11fbd02c04d288607d7cfe9a62b842", size = 5469938, upload-time = "2026-02-12T11:10:21.324Z" }, + { url = "https://files.pythonhosted.org/packages/95/07/3f6067cc5ab0d18ff88868187f5f4417600d5aeb93586ebd09024375b063/tesserocr-2.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0bf918ec0f70f87a08ef6580f604b322500791fbcdb1734e4ae96d63fdd01553", size = 6855514, upload-time = "2026-02-12T11:10:23.936Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/0b1bc805260c613267d19a8960b6849a2225e9194635ea561fc3504df030/tesserocr-2.10.0-cp314-cp314t-macosx_15_0_arm64.whl", hash = "sha256:8512f81d6c22f0801a6dcc7d81f382f70a6070313d082f64a4e5cda689c59193", size = 3614221, upload-time = "2026-02-12T11:10:25.603Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/4de538e1a5c4f488e35633f62098e5f0a0e97e63100656458e4b30e775d9/tesserocr-2.10.0-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:336d30f4284297769d51ca04da6634bedb50277dbf8c9eee84651ced279d6950", size = 4086105, upload-time = "2026-02-12T11:10:27.331Z" }, + { url = "https://files.pythonhosted.org/packages/38/a3/c623f019e7ae6aea3e56c79458267d5cea4880debfab73d5563878fdca92/tesserocr-2.10.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c1a28160e457ddd0624f779e4be8b97160765c2ac158aa8ea9af53f524d4cc2", size = 5266321, upload-time = "2026-02-12T11:10:29.021Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/d8aef967f92e36463e4ca7996f21a39736f7495a39ecdebb12593bef5858/tesserocr-2.10.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef7223940b8fd7f0a3481513b3126e3d85047359b740d31491b661c2f8d40a5c", size = 5474838, upload-time = "2026-02-12T11:10:31.174Z" }, + { url = "https://files.pythonhosted.org/packages/35/02/e2c69d0d47f1514dcc5013136af182215c2d4d74ffefa25ad72d16d97afa/tesserocr-2.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:499436eb7199f522594040129327190d22cfe6a1aa42f6c0aed4b9aa11a0ba82", size = 6866434, upload-time = "2026-02-12T11:10:34.06Z" }, +] + +[[package]] +name = "tesserocr" +version = "2.10.0" +source = { url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl" } +resolution-markers = [ + "sys_platform == 'win32'", +] +wheels = [ + { url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7abf40539b852bd25cceadad7a1a3bba9c223556bafa908e2dbf91407080af5" }, +] + [[package]] name = "tokenstream" version = "1.7.0" From 96d7d12748e2218988fe44bd2ac6a27f36978c1a Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 16 Jul 2026 11:12:23 -0700 Subject: [PATCH 052/158] Added a max number of forum tags --- commanderbot/ext/automod/actions/edit_thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/actions/edit_thread.py b/commanderbot/ext/automod/actions/edit_thread.py index 09fef5d..7333c8d 100644 --- a/commanderbot/ext/automod/actions/edit_thread.py +++ b/commanderbot/ext/automod/actions/edit_thread.py @@ -23,7 +23,7 @@ class EditThread(AutomodAction): name: Optional[str] = None """The new name of the thread.""" - tags: set[ForumTagID] = Field(default_factory=set) + tags: set[ForumTagID] = Field(default_factory=set, max_length=5) """ The new tags to apply to the thread. This only works if the thread is part of a forum. There can only be up to 5 tags applied to a thread. From d05c7d7a2bedbac576c9fd4508f7bcad9f812377 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 16 Jul 2026 11:31:20 -0700 Subject: [PATCH 053/158] Removed unused files --- commanderbot/lib/__init__.py | 2 -- commanderbot/lib/shallow_formatter.py | 35 --------------------------- commanderbot/lib/value_formatter.py | 12 --------- 3 files changed, 49 deletions(-) delete mode 100644 commanderbot/lib/shallow_formatter.py delete mode 100644 commanderbot/lib/value_formatter.py diff --git a/commanderbot/lib/__init__.py b/commanderbot/lib/__init__.py index 272cc8b..49aba3a 100644 --- a/commanderbot/lib/__init__.py +++ b/commanderbot/lib/__init__.py @@ -12,6 +12,4 @@ from .log_options import * from .predicates import * from .role_set import * -from .shallow_formatter import * from .types import * -from .value_formatter import * diff --git a/commanderbot/lib/shallow_formatter.py b/commanderbot/lib/shallow_formatter.py deleted file mode 100644 index f92db91..0000000 --- a/commanderbot/lib/shallow_formatter.py +++ /dev/null @@ -1,35 +0,0 @@ -from string import Formatter - -__all__ = ("ShallowFormatter",) - - -class ShallowFormatter(Formatter): - """ - A string template formatter that only accepts flat values as arguments. - - This can be used as a type of "safe" formatter with untrusted input, so long as all - of the arguments passed to it are themselves safe. - - Any field access will result in a `ValueError`. - """ - - @property - def default(self) -> str: - return "`Unknown`" - - def get_value(self, key, args, kwargs): - try: - return super().get_value(key, args, kwargs) - except: - return self.default - - def get_field(self, field_name, args, kwargs): - # Instead of doing any string parsing ourselves, we simply check whether the - # final object returned by the default (superclass) implementation is the same - # as the root object we started with. If this is not the case, then some type of - # field access must have occurred and therefore the argument is invalid. - final_obj, first = super().get_field(field_name, args, kwargs) - root_obj = self.get_value(first, args, kwargs) - if final_obj != root_obj: - raise ValueError(f"Invalid string template argument: {field_name}") - return root_obj, first diff --git a/commanderbot/lib/value_formatter.py b/commanderbot/lib/value_formatter.py deleted file mode 100644 index 350c523..0000000 --- a/commanderbot/lib/value_formatter.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Any, Callable, Optional - -__all__ = ("ValueFormatter",) - - -class ValueFormatter: - def __init__(self, value: Any, formatter: Optional[Callable[[Any], str]] = None): - self.value: Any = value - self.formatter: Callable[[Any], str] = str - - def __str__(self) -> str: - return self.formatter(self.value) From c30b2dcad4cdabc1a9e621cb2ea8b524f529a810 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 16 Jul 2026 11:31:33 -0700 Subject: [PATCH 054/158] Fixed schema generation --- commanderbot/lib/allowed_mentions.py | 8 ++++---- commanderbot/lib/color.py | 18 +++++++++--------- commanderbot/lib/intents.py | 10 +++++----- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/commanderbot/lib/allowed_mentions.py b/commanderbot/lib/allowed_mentions.py index 2f04a8d..6790c50 100644 --- a/commanderbot/lib/allowed_mentions.py +++ b/commanderbot/lib/allowed_mentions.py @@ -22,7 +22,7 @@ class AllowedMentions(discord.AllowedMentions, FromDataMixin, JsonSerializable): # @@ FACTORIES - _FACTORIES: ClassVar[dict[str, Callable[[], Self]]] = {} + _factories: ClassVar[dict[str, Callable[[], Self]]] = {} # @overrides discord.AllowedMentions @override @@ -118,7 +118,7 @@ def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: def from_factory(factory_name: str) -> Self: - if factory := cls._FACTORIES.get(factory_name): + if factory := cls._factories.get(factory_name): try: return factory() except Exception as ex: @@ -141,7 +141,7 @@ def to_dict(instance: Self) -> dict: from_factory_schema = core_schema.chain_schema( [ - core_schema.str_schema(), + core_schema.literal_schema([*cls._factories]), core_schema.no_info_plain_validator_function(from_factory), ] ) @@ -177,7 +177,7 @@ def to_dict(instance: Self) -> dict: signature = inspect.signature(func) doc_str = inspect.getdoc(func) or "" if len(signature.parameters) == 0 and "A factory method that returns a" in doc_str: - AllowedMentions._FACTORIES[name] = func + AllowedMentions._factories[name] = func AllowedMentionsAdapter = TypeAdapter(AllowedMentions) diff --git a/commanderbot/lib/color.py b/commanderbot/lib/color.py index cbb2382..50979e1 100644 --- a/commanderbot/lib/color.py +++ b/commanderbot/lib/color.py @@ -21,7 +21,7 @@ class Color(discord.Colour, FromDataMixin, JsonSerializable): # @@ FACTORIES - _FACTORIES: ClassVar[dict[str, Callable[[], Self]]] = {} + _factories: ClassVar[dict[str, Callable[[], Self]]] = {} # @overrides discord.Colour @override @@ -45,7 +45,7 @@ def presets( color_filter if case_sensitive else color_filter.lower() ) colors: dict[str, Color] = {} - for name, func in cls._FACTORIES.items(): + for name, func in cls._factories.items(): # Skip factory function if its name doesn't contain the color filter. func_name: str = name if case_sensitive else name.lower() if color_filter and color_filter not in func_name: @@ -336,7 +336,7 @@ def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: def from_factory(factory_name: str) -> Self: - if factory := cls._FACTORIES.get(factory_name): + if factory := cls._factories.get(factory_name): try: return factory() except Exception as ex: @@ -359,7 +359,7 @@ def from_factory(factory_name: str) -> Self: from_factory_schema = core_schema.chain_schema( [ - core_schema.str_schema(), + core_schema.literal_schema([*cls._factories]), core_schema.no_info_plain_validator_function(from_factory), ] ) @@ -397,21 +397,21 @@ def from_factory(factory_name: str) -> Self: return core_schema.json_or_python_schema( json_schema=core_schema.union_schema( [ - from_int_schema, - from_str_schema, from_factory_schema, from_rgb_dict_schema, from_hsv_dict_schema, + from_str_schema, + from_int_schema, ] ), python_schema=core_schema.union_schema( [ core_schema.is_instance_schema(cls), - from_int_schema, - from_str_schema, from_factory_schema, from_rgb_dict_schema, from_hsv_dict_schema, + from_str_schema, + from_int_schema, ] ), serialization=core_schema.plain_serializer_function_ser_schema( @@ -426,7 +426,7 @@ def from_factory(factory_name: str) -> Self: signature = inspect.signature(func) doc_str = inspect.getdoc(func) or "" if len(signature.parameters) == 0 and "value of ``0x" in doc_str: - Color._FACTORIES[name] = func + Color._factories[name] = func ColorAdapter = TypeAdapter(Color) """ diff --git a/commanderbot/lib/intents.py b/commanderbot/lib/intents.py index 67347a6..c292581 100644 --- a/commanderbot/lib/intents.py +++ b/commanderbot/lib/intents.py @@ -21,7 +21,7 @@ class Intents(discord.Intents, FromDataMixin, JsonSerializable): # @@ FACTORIES - _FACTORIES: ClassVar[dict[str, Callable[[], Self]]] = {} + _factories: ClassVar[dict[str, Callable[[], Self]]] = {} # @overrides discord.Intents @override @@ -87,7 +87,7 @@ def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: def from_factory(factory_name: str) -> Self: - if factory := cls._FACTORIES.get(factory_name): + if factory := cls._factories.get(factory_name): try: return factory() except Exception as ex: @@ -133,17 +133,17 @@ def to_dict(instance: Self) -> dict: return core_schema.json_or_python_schema( json_schema=core_schema.union_schema( [ - from_int_schema, from_factory_schema, from_dict_schema, + from_int_schema, ] ), python_schema=core_schema.union_schema( [ core_schema.is_instance_schema(cls), - from_int_schema, from_factory_schema, from_dict_schema, + from_int_schema, ] ), serialization=core_schema.plain_serializer_function_ser_schema(to_dict), @@ -156,7 +156,7 @@ def to_dict(instance: Self) -> dict: signature = inspect.signature(func) doc_str = inspect.getdoc(func) or "" if len(signature.parameters) == 0 and "A factory method that creates a" in doc_str: - Intents._FACTORIES[name] = func + Intents._factories[name] = func IntentsAdapter = TypeAdapter(Intents) """ From 75370239e83fd4f36e87dc4c8c37cda1a2d99d15 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 23 Jul 2026 02:44:12 -0700 Subject: [PATCH 055/158] Added a process pool that anything can use --- commanderbot/core/commander_bot.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/commanderbot/core/commander_bot.py b/commanderbot/core/commander_bot.py index c1dc9f7..1c0ee6c 100644 --- a/commanderbot/core/commander_bot.py +++ b/commanderbot/core/commander_bot.py @@ -1,5 +1,6 @@ import importlib.util import sys +from concurrent.futures import ProcessPoolExecutor from datetime import datetime, timedelta from logging import Logger, getLogger from typing import Any, Optional, Type @@ -41,10 +42,6 @@ def __init__(self, config: Config, sync_tree_on_login: bool = False): self.config: Config = config self._sync_tree_on_login: bool = sync_tree_on_login - # Remember when we started and the last time we connected. - self._started_at: datetime = utcnow() - self._connected_since: Optional[datetime] = None - # Create an error handling component. self.error_handling = ErrorHandling(log=self.log) self.tree.on_error = self.on_app_command_error @@ -52,13 +49,12 @@ def __init__(self, config: Config, sync_tree_on_login: bool = False): # Create application emoji manager. self.application_emojis = ApplicationEmojiManager(self) - @property - def started_at(self) -> datetime: - return self._started_at + # Create a process pool that anything can use. + self.pool = ProcessPoolExecutor() - @property - def connected_since(self) -> Optional[datetime]: - return self._connected_since + # Remember when we started and the last time we connected. + self.started_at: datetime = utcnow() + self.connected_since: Optional[datetime] = None @property def uptime(self) -> Optional[timedelta]: From e1e71ae0053e3a67860dce4bd015bb26fd0a16f8 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 23 Jul 2026 02:45:08 -0700 Subject: [PATCH 056/158] Changed some default values --- commanderbot/ext/automod/actions/abc/add_roles_to_target.py | 2 +- .../ext/automod/actions/abc/remove_roles_from_target.py | 2 +- commanderbot/ext/automod/actions/add_reactions.py | 2 +- commanderbot/ext/automod/actions/randomize.py | 4 +--- commanderbot/ext/automod/actions/remove_own_reactions.py | 2 +- commanderbot/ext/automod/actions/remove_reactions.py | 2 +- commanderbot/ext/automod/actions/sequence.py | 2 +- commanderbot/ext/automod/conditions/all_of.py | 2 +- commanderbot/ext/automod/conditions/any_of.py | 2 +- .../ext/automod/conditions/message_content_contains.py | 2 +- .../ext/automod/conditions/message_content_matches.py | 2 +- commanderbot/ext/automod/conditions/none_of.py | 2 +- commanderbot/ext/automod/conditions/randomize.py | 4 +--- commanderbot/ext/automod/rule/rule.py | 4 ++-- 14 files changed, 15 insertions(+), 19 deletions(-) diff --git a/commanderbot/ext/automod/actions/abc/add_roles_to_target.py b/commanderbot/ext/automod/actions/abc/add_roles_to_target.py index 6db50d1..79f9925 100644 --- a/commanderbot/ext/automod/actions/abc/add_roles_to_target.py +++ b/commanderbot/ext/automod/actions/abc/add_roles_to_target.py @@ -12,7 +12,7 @@ class AddRolesToTarget(AutomodAction): - roles: set[RoleID] = Field(default_factory=set, min_length=1) + roles: set[RoleID] = Field(min_length=1) """The roles to add.""" reason: Optional[str] = None diff --git a/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py b/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py index d84d683..b341502 100644 --- a/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py +++ b/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py @@ -12,7 +12,7 @@ class RemoveRolesFromTarget(AutomodAction): - roles: set[RoleID] = Field(default_factory=set, min_length=1) + roles: set[RoleID] = Field(min_length=1) """The roles to remove.""" reason: Optional[str] = None diff --git a/commanderbot/ext/automod/actions/add_reactions.py b/commanderbot/ext/automod/actions/add_reactions.py index 6e05915..4e82387 100644 --- a/commanderbot/ext/automod/actions/add_reactions.py +++ b/commanderbot/ext/automod/actions/add_reactions.py @@ -15,7 +15,7 @@ class AddReactions(AutomodAction): type: Literal["add_reactions"] - reactions: set[str] = Field(default_factory=set, min_length=1) + reactions: set[str] = Field(min_length=1) """The reactions to add.""" @override diff --git a/commanderbot/ext/automod/actions/randomize.py b/commanderbot/ext/automod/actions/randomize.py index fa09bf2..f5046c5 100644 --- a/commanderbot/ext/automod/actions/randomize.py +++ b/commanderbot/ext/automod/actions/randomize.py @@ -33,9 +33,7 @@ class Randomize(AutomodAction): type: Literal["randomize"] - actions: list[AutomodActionType | WeightedAction] = Field( - default_factory=list, min_length=1 - ) + actions: list[AutomodActionType | WeightedAction] = Field(min_length=1) """The sub-actions to pick from.""" def _roll(self) -> Optional[AutomodActionType]: diff --git a/commanderbot/ext/automod/actions/remove_own_reactions.py b/commanderbot/ext/automod/actions/remove_own_reactions.py index 47d2314..6c73b85 100644 --- a/commanderbot/ext/automod/actions/remove_own_reactions.py +++ b/commanderbot/ext/automod/actions/remove_own_reactions.py @@ -15,7 +15,7 @@ class RemoveOwnReactions(AutomodAction): type: Literal["remove_own_reactions"] - reactions: set[str] = Field(default_factory=set, min_length=1) + reactions: set[str] = Field(min_length=1) """The reactions to remove.""" @override diff --git a/commanderbot/ext/automod/actions/remove_reactions.py b/commanderbot/ext/automod/actions/remove_reactions.py index a135ff2..0e30463 100644 --- a/commanderbot/ext/automod/actions/remove_reactions.py +++ b/commanderbot/ext/automod/actions/remove_reactions.py @@ -15,7 +15,7 @@ class RemoveReactions(AutomodAction): type: Literal["remove_reactions"] - reactions: set[str] = Field(default_factory=set, min_length=1) + reactions: set[str] = Field(min_length=1) """The reactions to remove.""" @override diff --git a/commanderbot/ext/automod/actions/sequence.py b/commanderbot/ext/automod/actions/sequence.py index 383d5f9..b6be126 100644 --- a/commanderbot/ext/automod/actions/sequence.py +++ b/commanderbot/ext/automod/actions/sequence.py @@ -18,7 +18,7 @@ class Sequence(AutomodAction): type: Literal["sequence"] - actions: list[AutomodActionType] = Field(default_factory=list, min_length=1) + actions: list[AutomodActionType] = Field(min_length=1) """The sub-actions to apply.""" @override diff --git a/commanderbot/ext/automod/conditions/all_of.py b/commanderbot/ext/automod/conditions/all_of.py index a889040..b1d2f1e 100644 --- a/commanderbot/ext/automod/conditions/all_of.py +++ b/commanderbot/ext/automod/conditions/all_of.py @@ -18,7 +18,7 @@ class AllOf(AutomodCondition): type: Literal["all_of"] - conditions: list[AutomodConditionType] = Field(default_factory=list, min_length=1) + conditions: list[AutomodConditionType] = Field(min_length=1) """The sub-conditions to check.""" @override diff --git a/commanderbot/ext/automod/conditions/any_of.py b/commanderbot/ext/automod/conditions/any_of.py index adc6c4f..29a3994 100644 --- a/commanderbot/ext/automod/conditions/any_of.py +++ b/commanderbot/ext/automod/conditions/any_of.py @@ -18,7 +18,7 @@ class AnyOf(AutomodCondition): type: Literal["any_of"] - conditions: list[AutomodConditionType] = Field(default_factory=list, min_length=1) + conditions: list[AutomodConditionType] = Field(min_length=1) """The sub-conditions to check.""" count: PositiveInt = 1 diff --git a/commanderbot/ext/automod/conditions/message_content_contains.py b/commanderbot/ext/automod/conditions/message_content_contains.py index 94d424a..065e5d1 100644 --- a/commanderbot/ext/automod/conditions/message_content_contains.py +++ b/commanderbot/ext/automod/conditions/message_content_contains.py @@ -17,7 +17,7 @@ class MessageContentContains(AutomodCondition): type: Literal["message_content_contains"] - contains: set[str] = Field(default_factory=set, min_length=1) + contains: set[str] = Field(min_length=1) """ The substrings to find. Unless `count` is specified, all substrings must be found in order to pass. diff --git a/commanderbot/ext/automod/conditions/message_content_matches.py b/commanderbot/ext/automod/conditions/message_content_matches.py index d18a786..5fef2ec 100644 --- a/commanderbot/ext/automod/conditions/message_content_matches.py +++ b/commanderbot/ext/automod/conditions/message_content_matches.py @@ -18,7 +18,7 @@ class MessageContentMatches(AutomodCondition): type: Literal["message_content_matches"] - matches: set[re.Pattern] = Field(default_factory=set, min_length=1) + matches: set[re.Pattern] = Field(min_length=1) """ The patterns (regular expressions) to match. Unless `count` is specified, all patterns must be matched in order to pass. diff --git a/commanderbot/ext/automod/conditions/none_of.py b/commanderbot/ext/automod/conditions/none_of.py index 442afb4..13cf7de 100644 --- a/commanderbot/ext/automod/conditions/none_of.py +++ b/commanderbot/ext/automod/conditions/none_of.py @@ -18,7 +18,7 @@ class NoneOf(AutomodCondition): type: Literal["none_of"] - conditions: list[AutomodConditionType] = Field(default_factory=list, min_length=1) + conditions: list[AutomodConditionType] = Field(min_length=1) """The sub-conditions to check.""" @override diff --git a/commanderbot/ext/automod/conditions/randomize.py b/commanderbot/ext/automod/conditions/randomize.py index 6651447..8df0d8c 100644 --- a/commanderbot/ext/automod/conditions/randomize.py +++ b/commanderbot/ext/automod/conditions/randomize.py @@ -33,9 +33,7 @@ class Randomize(AutomodCondition): type: Literal["randomize"] - conditions: list[AutomodConditionType | WeightedCondition] = Field( - default_factory=list, min_length=1 - ) + conditions: list[AutomodConditionType | WeightedCondition] = Field(min_length=1) """The sub-conditions to pick from.""" def _roll(self) -> Optional[AutomodConditionType]: diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index b868782..e5ab81c 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -26,13 +26,13 @@ class AutomodRule(BaseModel): disabled: Optional[bool] = None """Is the rule disabled?""" - triggers: list[AutomodTriggerType] = Field(default_factory=list, min_length=1) + triggers: list[AutomodTriggerType] = Field(min_length=1) """A list of triggers that cause the rule to run.""" conditions: list[AutomodConditionType] = Field(default_factory=list) """A list of conditions that must *all* pass for the actions to run.""" - actions: list[AutomodActionType] = Field(default_factory=list, min_length=1) + actions: list[AutomodActionType] = Field(min_length=1) """A list of actions that will all run if the conditions pass.""" async def _poll_triggers(self, context: AutomodContext) -> bool: From 1ddec5ebfc94b5e05d0eca3051cbf2c156acdf65 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 23 Jul 2026 02:45:32 -0700 Subject: [PATCH 057/158] Updated dependencies --- pyproject.toml | 12 +++---- uv.lock | 95 ++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0924a7b..173c829 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,17 +36,15 @@ dependencies = [ "allay>=1.3.0", "mccq>=1.0.0", "psutil>=7.0.0", - -] - -[project.optional-dependencies] -automod-ocr = [ "numpy>=2.5.0", "opencv-python-headless>=5.0.0.0", "pillow>=12.3.0", - "tesserocr>=2.10.0", + "imagehash>=4.3.0", ] +[project.optional-dependencies] +ocr = ["tesserocr>=2.10.0"] + [project.urls] Homepage = "https://github.com/MinecraftCommands/commanderbot-py" Repository = "https://github.com/MinecraftCommands/commanderbot-py" @@ -65,7 +63,7 @@ profile = "black" typeCheckingMode = "basic" [tool.uv.sources] -tesserocr = { url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl", marker = "sys_platform == 'win32'", extra = "automod-ocr" } +tesserocr = { url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl", marker = "sys_platform == 'win32'", extra = "ocr" } [tool.uv.build-backend] module-name = "commanderbot" diff --git a/uv.lock b/uv.lock index 6d9c67b..276a6da 100644 --- a/uv.lock +++ b/uv.lock @@ -222,8 +222,12 @@ dependencies = [ { name = "colorlog" }, { name = "discord-py" }, { name = "emoji" }, + { name = "imagehash" }, { name = "jsonpath-ng" }, { name = "mccq" }, + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, { name = "psutil" }, { name = "pydantic" }, { name = "python-dotenv" }, @@ -231,10 +235,7 @@ dependencies = [ ] [package.optional-dependencies] -automod-ocr = [ - { name = "numpy" }, - { name = "opencv-python-headless" }, - { name = "pillow" }, +ocr = [ { name = "tesserocr", version = "2.10.0", source = { registry = "https://pypi.org/simple/" }, marker = "sys_platform != 'win32'" }, { name = "tesserocr", version = "2.10.0", source = { url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl" }, marker = "sys_platform == 'win32'" }, ] @@ -253,19 +254,20 @@ requires-dist = [ { name = "colorlog", specifier = ">=6.10.0" }, { name = "discord-py", specifier = ">=2.7.0" }, { name = "emoji", specifier = ">=2.0.0" }, + { name = "imagehash", specifier = ">=4.3.2" }, { name = "jsonpath-ng", specifier = ">=1.7.0" }, { name = "mccq", specifier = ">=1.0.0" }, - { name = "numpy", marker = "extra == 'automod-ocr'", specifier = ">=2.5.0" }, - { name = "opencv-python-headless", marker = "extra == 'automod-ocr'", specifier = ">=5.0.0.0" }, - { name = "pillow", marker = "extra == 'automod-ocr'", specifier = ">=12.3.0" }, + { name = "numpy", specifier = ">=2.5.0" }, + { name = "opencv-python-headless", specifier = ">=5.0.0.0" }, + { name = "pillow", specifier = ">=12.3.0" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pydantic", specifier = ">=2.13.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "tesserocr", marker = "sys_platform == 'win32' and extra == 'automod-ocr'", url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl" }, - { name = "tesserocr", marker = "sys_platform != 'win32' and extra == 'automod-ocr'", specifier = ">=2.10.0" }, + { name = "tesserocr", marker = "sys_platform == 'win32' and extra == 'ocr'", url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl" }, + { name = "tesserocr", marker = "sys_platform != 'win32' and extra == 'ocr'", specifier = ">=2.10.0" }, ] -provides-extras = ["automod-ocr"] +provides-extras = ["ocr"] [package.metadata.requires-dev] dev = [ @@ -359,6 +361,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "imagehash" +version = "4.3.2" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "pywavelets" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/de/5c0189b0582e21583c2a213081c35a2501c0f9e51f21f6a52f55fbb9a4ff/ImageHash-4.3.2.tar.gz", hash = "sha256:e54a79805afb82a34acde4746a16540503a9636fd1ffb31d8e099b29bbbf8156", size = 303190, upload-time = "2025-02-01T08:45:39.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/2c/5f0903a53a62029875aaa3884c38070cc388248a2c1b9aa935632669e5a7/ImageHash-4.3.2-py2.py3-none-any.whl", hash = "sha256:02b0f965f8c77cd813f61d7d39031ea27d4780e7ebcad56c6cd6a709acc06e5f", size = 296657, upload-time = "2025-02-01T08:45:36.102Z" }, +] + [[package]] name = "isort" version = "8.0.1" @@ -693,6 +710,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] +[[package]] +name = "pywavelets" +version = "1.9.0" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/1f/18c82122547c9eec2232d800b02ada1fbd30ce2136137b5738acca9d653e/pywavelets-1.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:53043d2f3f4e55a576f51ac594fe33181e1d096d958e01524db5070eb3825306", size = 4314440, upload-time = "2025-08-04T16:19:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/1c92ac6b538ef5388caf1a74af61cf6af16ea6d14115bb53357469cb38d6/pywavelets-1.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bc36b42b1b125fd9cb56e7956b22f8d0f83c1093f49c77fc042135e588c799", size = 4290162, upload-time = "2025-08-04T16:19:41.322Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/d856a2cac8069c20144598fa30a43ca40b5df2e633230848a9a942faf04a/pywavelets-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08076eb9a182ddc6054ac86868fb71df6267c341635036dc63d20bdbacd9ad7e", size = 4437162, upload-time = "2025-08-04T16:19:42.556Z" }, + { url = "https://files.pythonhosted.org/packages/c9/54/777e0495acd4fb008791e84889be33d6e7fc8af095b441d939390b7d2491/pywavelets-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ee1ee7d80f88c64b8ec3b5021dd1e94545cc97f0cd479fb51aa7b10f6def08e", size = 4498169, upload-time = "2025-08-04T16:19:43.791Z" }, + { url = "https://files.pythonhosted.org/packages/76/68/81b97f4d18491a18fbe17e06e2eee80a591ce445942f7b6f522de07813c5/pywavelets-1.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3226b6f62838a6ccd7782cb7449ee5d8b9d61999506c1d9b03b2baf41b01b6fd", size = 4443318, upload-time = "2025-08-04T16:19:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/92/74/5147f2f0436f7aa131cb1bc13dba32ef5f3862748ae1c7366b4cde380362/pywavelets-1.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb7f4b11d18e2db6dd8deee7b3ce8343d45f195f3f278c2af6e3724b1b93a24", size = 4503294, upload-time = "2025-08-04T16:19:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d4/af998cc71e869919e0ab45471bd43e91d055ac7bc3ce6f56cc792c9b6bc8/pywavelets-1.9.0-cp314-cp314-win32.whl", hash = "sha256:9902d9fc9812588ab2dce359a1307d8e7f002b53a835640e2c9388fe62a82fd4", size = 4144478, upload-time = "2025-08-04T16:19:47.974Z" }, + { url = "https://files.pythonhosted.org/packages/7d/66/1d071eae5cc3e3ad0e45334462f8ce526a79767ccb759eb851aa5b78a73a/pywavelets-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:7e57792bde40e331d6cc65458e5970fd814dba18cfc4e9add9d051e901a7b7c7", size = 4227186, upload-time = "2025-08-04T16:19:49.57Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1f/da0c03ac99bd9d20409c0acf6417806d4cf333d70621da9f535dd0cf27fa/pywavelets-1.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b47c72fb4b76d665c4c598a5b621b505944e5b761bf03df9d169029aafcb652f", size = 4354391, upload-time = "2025-08-04T16:19:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/de9e225d8cc307fbb4fda88aefa79442775d5e27c58ee4d3c8a8580ceba6/pywavelets-1.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:969e369899e7eab546ea5d77074e4125082e6f9dad71966499bf5dee3758be55", size = 4335810, upload-time = "2025-08-04T16:19:52.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/3b/336761359d07cd44a4233ca854704ff2a9e78d285879ccc82d254b9daa57/pywavelets-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8aeffd4f35036c1fade972a61454de5709a7a8fc9a7d177eefe3ac34d76962e5", size = 4422220, upload-time = "2025-08-04T16:19:54.068Z" }, + { url = "https://files.pythonhosted.org/packages/98/61/76ccc7ada127f14f65eda40e37407b344fd3713acfca7a94d7f0f67fe57d/pywavelets-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f63f400fcd4e7007529bd06a5886009760da35cd7e76bb6adb5a5fbee4ffeb8c", size = 4470156, upload-time = "2025-08-04T16:19:55.379Z" }, + { url = "https://files.pythonhosted.org/packages/e0/de/142ca27ee729cf64113c2560748fcf2bd45b899ff282d6f6f3c0e7f177bb/pywavelets-1.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a63bcb6b5759a7eb187aeb5e8cd316b7adab7de1f4b5a0446c9a6bcebdfc22fb", size = 4430167, upload-time = "2025-08-04T16:19:56.566Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5e/90b39adff710d698c00ba9c3125e2bec99dad7c5f1a3ba37c73a78a6689f/pywavelets-1.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9950eb7c8b942e9bfa53d87c7e45a420dcddbd835c4c5f1aca045a3f775c6113", size = 4477378, upload-time = "2025-08-04T16:19:58.162Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/89f5f4ebcb9d34d9b7b2ac0a868c8b6d8c78d699a36f54407a060cea0566/pywavelets-1.9.0-cp314-cp314t-win32.whl", hash = "sha256:097f157e07858a1eb370e0d9c1bd11185acdece5cca10756d6c3c7b35b52771a", size = 4209132, upload-time = "2025-08-04T16:20:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/68/d2/a8065103f5e2e613b916489e6c85af6402a1ec64f346d1429e2d32cb8d03/pywavelets-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3b6ff6ba4f625d8c955f68c2c39b0a913776d406ab31ee4057f34ad4019fb33b", size = 4306793, upload-time = "2025-08-04T16:20:02.934Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -719,6 +763,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + [[package]] name = "tesserocr" version = "2.10.0" From d638c01fc55e0ba67e1eaf01aac90870565489f6 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 23 Jul 2026 02:45:46 -0700 Subject: [PATCH 058/158] Changed some names --- commanderbot/lib/utils/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commanderbot/lib/utils/utils.py b/commanderbot/lib/utils/utils.py index f69a28f..ec3a4f9 100644 --- a/commanderbot/lib/utils/utils.py +++ b/commanderbot/lib/utils/utils.py @@ -104,8 +104,8 @@ def format_context_cause(ctx: Context | Interaction) -> str: return " ".join(parts) -def message_to_file(message: Message, filename: Optional[str] = None) -> File: - filename = filename or "message.md" +def message_to_file(message: Message, file_name: Optional[str] = None) -> File: + file_name = file_name or "message.md" file_lines = [] if message.content: file_lines.append(message.content) @@ -121,7 +121,7 @@ def message_to_file(message: Message, filename: Optional[str] = None) -> File: file_lines.append(f"\n```json\n{embed_json}\n```") file_content = "\n".join(file_lines) fp = io.BytesIO(file_content.encode()) - file = File(fp=fp, filename=filename) + file = File(fp=fp, filename=file_name) return file From 637fe6edee2b4becbdcf6c6f141f6be585e33ae3 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 23 Jul 2026 02:46:15 -0700 Subject: [PATCH 059/158] Added types for Tesseract language and script names --- commanderbot/lib/types.py | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/commanderbot/lib/types.py b/commanderbot/lib/types.py index 3e341d4..beeb5a6 100644 --- a/commanderbot/lib/types.py +++ b/commanderbot/lib/types.py @@ -28,6 +28,7 @@ "AppCommandID", "EmojiID", "DiscordAutomodRuleID", + "AttachmentID", "RawOptions", "JsonObject", "MemberOrUser", @@ -40,6 +41,9 @@ "ChannelTypeNames", "Timedelta", "UnicodeNormalizationForms", + "MimeTypes", + "TesseractLanguages", + "TesseractScripts", ) @@ -56,6 +60,7 @@ AppCommandID: TypeAlias = IDType EmojiID: TypeAlias = IDType DiscordAutomodRuleID: TypeAlias = IDType +AttachmentID: TypeAlias = IDType RawOptions: TypeAlias = Any @@ -178,3 +183,53 @@ """ UnicodeNormalizationForms: TypeAlias = Literal["NFC", "NFD", "NFKC", "NFKD"] + +MimeTypes: TypeAlias = Literal[ + "application", + "audio", + "example", + "font", + "haptics", + "image", + "message", + "model", + "multipart", + "text", + "video", +] +""" +All registered MIME types (https://en.wikipedia.org/wiki/Media_type#Types). +""" + +# fmt: off +TesseractLanguages: TypeAlias = Literal[ + "afr", "amh", "ara", "asm", "aze", "aze_cyrl", "bel", "ben", "bod", "bos", + "bre", "bul", "cat", "ceb", "ces", "chi_sim", "chi_sim_vert", "chi_tra", "chi_tra_vert", "chr", + "cos", "cym", "dan", "deu", "deu_latf", "div", "dzo", "ell", "eng", "enm", + "epo", "est", "eus", "fao", "fas", "fil", "fin", "fra", "frm", "fry", + "gla", "gle", "glg", "grc", "guj", "hat", "heb", "hin", "hrv", "hun", + "hye", "iku", "ind", "isl", "ita", "ita_old", "jav", "jpn", "jpn_vert", "kan", + "kat", "kat_old", "kaz", "khm", "kir", "kmr", "kor", "kor_vert", "lao", + "lat", "lav", "lit", "ltz", "mal", "mar", "mkd", "mlt", "mon", "mri", + "msa", "mya", "nep", "nld", "nor", "oci", "ori", "pan", "pol", "por", + "pus", "que", "ron", "rus", "san", "sin", "slk", "slv", "snd", "spa", + "spa_old", "sqi", "srp", "srp_latn", "sun", "swa", "swe", "syr", "tam", "tat", + "tel", "tgk", "tha", "tir", "ton", "tur", "uig", "ukr", "urd", "uzb", + "uzb_cyrl", "vie", "yid", "yor", +] +""" +All languages that Tesseract supports (https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html). +""" +# fmt: on + +# fmt: off +TesseractScripts: TypeAlias = Literal[ + "arab", "armn", "beng", "cans", "cher", "cyrl", "deva", "ethi", "frak", "geor", + "grek", "gujr", "guru", "hans", "hans-vert", "hant", "hant-vert", "hang", "hang-vert", "hebr", + "jpan", "jpan-vert", "knda", "khmr", "laoo", "latn", "mlym", "mymr", "orya", "sinh", + "syrc", "taml", "telu", "thaa", "thai", "tibt", "viet", +] +""" +All scripts that Tesseract supports (https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html). +""" +# fmt: on From 110b2063c5ff1986a5093effab4e8615990550c7 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 23 Jul 2026 02:46:47 -0700 Subject: [PATCH 060/158] Added `attachments` to automod events --- commanderbot/ext/automod/event/event_base.py | 8 +++++++- commanderbot/ext/automod/events/message_deleted.py | 7 ++++++- commanderbot/ext/automod/events/message_edited.py | 7 ++++++- commanderbot/ext/automod/events/message_sent.py | 7 ++++++- commanderbot/ext/automod/events/reaction_added.py | 7 ++++++- commanderbot/ext/automod/events/reaction_removed.py | 7 ++++++- 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/commanderbot/ext/automod/event/event_base.py b/commanderbot/ext/automod/event/event_base.py index 80426a9..f3aa7d5 100644 --- a/commanderbot/ext/automod/event/event_base.py +++ b/commanderbot/ext/automod/event/event_base.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing import Optional -from discord import CategoryChannel, Member, Message, Reaction, Thread, User +from discord import Attachment, CategoryChannel, Member, Message, Reaction, Thread, User from commanderbot.lib.predicates import is_thread from commanderbot.lib.types import GuildChannel @@ -38,6 +38,12 @@ def message(self) -> Optional[Message]: """Return the relevant message, if any.""" return None + @property + def attachments(self) -> Optional[list[Attachment]]: + """Return the relevant attachments, if any.""" + if message := self.message: + return message.attachments + @property def reaction(self) -> Optional[Reaction]: """Return the relevant reaction, if any.""" diff --git a/commanderbot/ext/automod/events/message_deleted.py b/commanderbot/ext/automod/events/message_deleted.py index 0987758..6de321f 100644 --- a/commanderbot/ext/automod/events/message_deleted.py +++ b/commanderbot/ext/automod/events/message_deleted.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional, override -from discord import Member, Message, Thread, User +from discord import Attachment, Member, Message, Thread, User from commanderbot.ext.automod.event import AutomodEvent from commanderbot.lib.predicates import ( @@ -32,6 +32,11 @@ def channel(self) -> MessageableGuildChannel | Thread: def message(self) -> Message: return self._message + @property + @override + def attachments(self) -> list[Attachment]: + return self._message.attachments + @property @override def author(self) -> Member | User: diff --git a/commanderbot/ext/automod/events/message_edited.py b/commanderbot/ext/automod/events/message_edited.py index 1ed0c74..244df58 100644 --- a/commanderbot/ext/automod/events/message_edited.py +++ b/commanderbot/ext/automod/events/message_edited.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional, override -from discord import Member, Message, Thread, User +from discord import Attachment, Member, Message, Thread, User from commanderbot.ext.automod.event import AutomodEvent from commanderbot.lib.predicates import ( @@ -36,6 +36,11 @@ def channel(self) -> MessageableGuildChannel | Thread: def message(self) -> Message: return self.after + @property + @override + def attachments(self) -> list[Attachment]: + return self.after.attachments + @property @override def author(self) -> Member: diff --git a/commanderbot/ext/automod/events/message_sent.py b/commanderbot/ext/automod/events/message_sent.py index 92bce0f..3f6241b 100644 --- a/commanderbot/ext/automod/events/message_sent.py +++ b/commanderbot/ext/automod/events/message_sent.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import override -from discord import Member, Message, Thread, User +from discord import Attachment, Member, Message, Thread, User from commanderbot.ext.automod.event import AutomodEvent from commanderbot.lib.predicates import ( @@ -32,6 +32,11 @@ def channel(self) -> MessageableGuildChannel | Thread: def message(self) -> Message: return self._message + @property + @override + def attachments(self) -> list[Attachment]: + return self._message.attachments + @property @override def author(self) -> Member: diff --git a/commanderbot/ext/automod/events/reaction_added.py b/commanderbot/ext/automod/events/reaction_added.py index 3a518f8..eb3345b 100644 --- a/commanderbot/ext/automod/events/reaction_added.py +++ b/commanderbot/ext/automod/events/reaction_added.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import override -from discord import Member, Message, Reaction, Thread, User +from discord import Attachment, Member, Message, Reaction, Thread, User from commanderbot.ext.automod.event import AutomodEvent from commanderbot.lib.predicates import is_messagable_guild_channel, is_thread, is_user @@ -28,6 +28,11 @@ def channel(self) -> MessageableGuildChannel | Thread: def message(self) -> Message: return self.reaction.message + @property + @override + def attachments(self) -> list[Attachment]: + return self.reaction.message.attachments + @property @override def reaction(self) -> Reaction: diff --git a/commanderbot/ext/automod/events/reaction_removed.py b/commanderbot/ext/automod/events/reaction_removed.py index 4c8f7f3..e5c440e 100644 --- a/commanderbot/ext/automod/events/reaction_removed.py +++ b/commanderbot/ext/automod/events/reaction_removed.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import override -from discord import Member, Message, Reaction, Thread, User +from discord import Attachment, Member, Message, Reaction, Thread, User from commanderbot.ext.automod.event import AutomodEvent from commanderbot.lib.predicates import is_messagable_guild_channel, is_thread, is_user @@ -28,6 +28,11 @@ def channel(self) -> MessageableGuildChannel | Thread: def message(self) -> Message: return self.reaction.message + @property + @override + def attachments(self) -> list[Attachment]: + return self.reaction.message.attachments + @property @override def reaction(self) -> Reaction: From fa500ac995ff0c68f4d89441d144e0a742de550e Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 23 Jul 2026 20:35:22 -0700 Subject: [PATCH 061/158] Added a condition that will OCR any attachments in context --- commanderbot/ext/automod/automod_context.py | 119 +++++++- commanderbot/ext/automod/condition/types.py | 1 + .../ext/automod/conditions/__init__.py | 1 + .../conditions/image_attachments_contain.py | 139 ++++++++++ .../automod/conditions/ocr_utils/__init__.py | 1 + .../automod/conditions/ocr_utils/pipeline.py | 258 ++++++++++++++++++ commanderbot/ext/automod/types.py | 47 ++-- 7 files changed, 538 insertions(+), 28 deletions(-) create mode 100644 commanderbot/ext/automod/conditions/image_attachments_contain.py create mode 100644 commanderbot/ext/automod/conditions/ocr_utils/__init__.py create mode 100644 commanderbot/ext/automod/conditions/ocr_utils/pipeline.py diff --git a/commanderbot/ext/automod/automod_context.py b/commanderbot/ext/automod/automod_context.py index 49cfd9f..07d4540 100644 --- a/commanderbot/ext/automod/automod_context.py +++ b/commanderbot/ext/automod/automod_context.py @@ -3,15 +3,19 @@ from dataclasses import dataclass, field from itertools import chain from logging import Logger -from typing import TYPE_CHECKING, Any, Iterable, cast +from typing import TYPE_CHECKING, Any, Iterable, Optional, cast -from discord import Member, User +import numpy as np +from discord import Attachment, Member, User from discord.ext.commands import Bot from discord.utils import format_dt, utcnow +from imagehash import ImageHash, phash +from PIL import Image from commanderbot.ext.automod.event import AutomodEvent -from commanderbot.ext.automod.types import ContextFields +from commanderbot.ext.automod.types import ContextAttachment, ContextMetadataFields from commanderbot.lib.predicates import is_member +from commanderbot.lib.types import MimeTypes __all__ = ("AutomodContext",) @@ -40,22 +44,67 @@ class AutomodContext: event: AutomodEvent """The event that the rule is processing.""" + _cached_attachments: list[AutomodContextAttachment] = field( + init=False, default_factory=list + ) + """A cache for any attachments.""" + _metadata: dict[str, Any] = field(init=False, default_factory=dict) """Any additional data attached to the execution context.""" - def set_metadata(self, key: str, value: Any): + def __post_init__(self): + if attachments := self.event.attachments: + for att in attachments: + self._cached_attachments.append(AutomodContextAttachment(att)) + + def set_metadata(self, key: ContextMetadataFields, value: Any): """Add metadata to the execution context""" self._metadata[key] = value - def remove_metadata(self, key: str): + def get_metadata(self, key: ContextMetadataFields) -> Optional[Any]: + """Get metadata from the execution context.""" + return self._metadata.get(key) + + def remove_metadata(self, key: ContextMetadataFields): """Remove metadata from the execution context""" del self._metadata[key] - def get_fields(self, *, unsafe: bool = False) -> dict[ContextFields, Any]: + async def fetch_attachments(self) -> list[ContextAttachment]: + """ + Fetch all attachments from the execution context. + + This will also cache the attachment's data so it only needs to be + fetched once. + """ + attachments: list[ContextAttachment] = [] + for attachment in self._cached_attachments: + if resolved := attachment.get() or await attachment.fetch(): + attachments.append(resolved) + return attachments + + async def fetch_attachments_with_type( + self, content_type: Optional[MimeTypes] + ) -> list[ContextAttachment]: + """ + Fetch all attachments from the execution context with a certain MIME type + (https://en.wikipedia.org/wiki/Media_type#Types). + + This will also cache the attachment's data so it only needs to be + fetched once. + """ + attachments: list[ContextAttachment] = [] + for attachment in self._cached_attachments: + if not attachment.has_content_type(content_type): + continue + if resolved := attachment.get() or await attachment.fetch(): + attachments.append(resolved) + return attachments + + def get_fields(self, *, unsafe: bool = False) -> dict[str, Any]: """Get the full context data as key/value pairs.""" if unsafe: - return dict(chain(self._yield_safe_fields(), self._yield_unsafe_fields())) # type: ignore - return dict(self._yield_safe_fields()) # type: ignore + return dict(chain(self._yield_safe_fields(), self._yield_unsafe_fields())) + return dict(self._yield_safe_fields()) def format_content( self, content: str, *, default="`Unknown`", unsafe: bool = False @@ -175,6 +224,9 @@ def _yield_unsafe_fields(self) -> Iterable[tuple[str, Any]]: if message := self.event.message: yield ("message", message) + if attachments := self.event.attachments: + yield ("attachments", attachments) + if reaction := self.event.reaction: yield ("reaction", reaction) @@ -193,3 +245,54 @@ def _yield_unsafe_fields(self) -> Iterable[tuple[str, Any]]: yield from ( (k, v) for k, v in self._metadata.items() if not self._is_value_safe(v) ) + + +@dataclass +class AutomodContextAttachment: + """ + A very minimal wrapper around a `discord.Attachment` that caches + attachment data when fetched. Image attachments also have their + perceptual hash calculated. + """ + + _attachment: Attachment + _data: Optional[bytes] = field(init=False, default=None) + _phash: Optional[ImageHash] = field(init=False, default=None) + + @property + def _content_type(self) -> Optional[str]: + return self._attachment.content_type + + def has_content_type(self, content_type: Optional[MimeTypes]) -> bool: + if self._content_type is None or content_type is None: + return self._content_type is content_type + return self._content_type.startswith(content_type) + + def get(self) -> Optional[ContextAttachment]: + if self._data is not None: + return (self._attachment, self._data, self._phash) + + async def fetch(self) -> Optional[ContextAttachment]: + if self._data is not None: + return self.get() + + if data := await self._fetch_data(): + self._data = data + if self.has_content_type("image"): + buffer = np.frombuffer(self._data, np.uint8) + image = Image.fromarray(buffer) + self._phash = phash(image) + return self.get() + + async def _fetch_data(self) -> Optional[bytes]: + try: + # Try fetching from the regular URL + return await self._attachment.read() + except Exception: + pass + + try: + # If that didn't work, try the proxy URL + return await self._attachment.read(use_cached=True) + except Exception: + pass diff --git a/commanderbot/ext/automod/condition/types.py b/commanderbot/ext/automod/condition/types.py index 939ac6e..42f83b8 100644 --- a/commanderbot/ext/automod/condition/types.py +++ b/commanderbot/ext/automod/condition/types.py @@ -25,6 +25,7 @@ | conditions.AuthorIsNotSelf | conditions.AuthorMemberFor | conditions.AuthorRoles + | conditions.ImageAttachmentsContain | conditions.Inverted | conditions.MessageContentContains | conditions.MessageContentMatches diff --git a/commanderbot/ext/automod/conditions/__init__.py b/commanderbot/ext/automod/conditions/__init__.py index 71fdbfa..7ede4eb 100644 --- a/commanderbot/ext/automod/conditions/__init__.py +++ b/commanderbot/ext/automod/conditions/__init__.py @@ -16,6 +16,7 @@ from .author_is_self import * from .author_member_for import * from .author_roles import * +from .image_attachments_contain import * from .inverted import * from .message_content_contains import * from .message_content_matches import * diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py new file mode 100644 index 0000000..ce0dce8 --- /dev/null +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -0,0 +1,139 @@ +import asyncio +import unicodedata +from itertools import chain +from typing import Any, Literal, Optional, override + +from pydantic import Field, PositiveInt + +from commanderbot.core.utils import is_commander_bot +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.automod_exceptions import OCRNotSupported +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.types import ContextAttachment +from commanderbot.lib.constants import SUPPORTS_OCR +from commanderbot.lib.types import ( + TesseractLanguages, + TesseractScripts, + UnicodeNormalizationForms, +) + +__all__ = ("ImageAttachmentsContain",) + +if SUPPORTS_OCR: + from commanderbot.ext.automod.conditions.ocr_utils.pipeline import get_text + + +class ImageAttachmentsContain(AutomodCondition): + """ + OCR image attachments and check if they contain a number of substrings. + """ + + type: Literal["image_attachments_contain"] + + languages: set[TesseractLanguages] = Field(default_factory=lambda: {"eng"}) + """ + The languages to recognize. If not set, `eng` will be recognized by default. + """ + + scripts: set[TesseractScripts] = Field(default_factory=set) + """ + The scripts to recognize. + """ + + contains: set[str] = Field(min_length=1) + """ + The substrings to find. Unless `count` is specified, all substrings must be + found in order to pass. + """ + + count: Optional[PositiveInt] = None + """ + The number of unique substrings to find. For example: a value of 1 requires any + of the substrings to be found, whereas a value of 2 requires at least 2 to be + found. If empty, all substrings must be found. + """ + + ignore_case: bool = False + """Whether to ignore upper vs lower case.""" + + use_normalization: bool = False + """Whether to use unicode normalization or process the string as-is.""" + + normalization_form: UnicodeNormalizationForms = Field(default="NFKD") + """If enabled, the type of normalization to apply. Defaults to NFKD.""" + + timeout: PositiveInt = 10 + """The max amount of time in seconds to wait for OCR results.""" + + @override + def model_post_init(self, context: Any): + if self.ignore_case: + self.contains = {s.lower() for s in self.contains} + + def _check_ocr_result(self, content: str) -> bool: + # Process image content + if self.use_normalization: + content = unicodedata.normalize(self.normalization_form, content) + if self.ignore_case: + content = content.lower() + + # Check for a sufficient number of substrings + required_matches = self.count or len(self.contains) + matches = 0 + for substring in self.contains: + if substring in content: + matches += 1 + if matches == required_matches: + return True + + return False + + async def _ocr( + self, context: AutomodContext, attachments: list[ContextAttachment] + ) -> Optional[ContextAttachment]: + # Create `lang` scring from languages and scripts + lang: str = "+".join((v for v in chain(self.languages, self.scripts))) + + # Submit OCR tasks to the process pool + assert is_commander_bot(context.bot) + pool = context.bot.pool + loop = asyncio.get_running_loop() + tasks = [ + loop.run_in_executor(pool, get_text, data, lang, idx) + for idx, (_, data, _) in enumerate(attachments) + ] + + # Check OCR results as they come in + try: + for task in asyncio.as_completed(tasks, timeout=self.timeout): + if result := await task: + text, idx = result + # Check if the text passes the condition and return the attachment + if self._check_ocr_result(text): + return attachments[idx] + finally: + for task in tasks: + if not task.done(): + task.cancel() + + @override + async def check(self, context: AutomodContext) -> bool: + # Throw an exception if OCR isn't supported + if not SUPPORTS_OCR: + raise OCRNotSupported + + # We need image attachments in context + attachments = await context.fetch_attachments_with_type("image") + if not attachments: + return False + + # OCR image attachments + if attachment := await self._ocr(context, attachments): + # Add attachment to metadata + passed_ocr = context.get_metadata("passed_ocr_condition") or [] + passed_ocr.append(attachment) + context.set_metadata("passed_ocr_condition", passed_ocr) + return True + + # If we got this far, none of the attachments passed the condition + return False diff --git a/commanderbot/ext/automod/conditions/ocr_utils/__init__.py b/commanderbot/ext/automod/conditions/ocr_utils/__init__.py new file mode 100644 index 0000000..821dc6d --- /dev/null +++ b/commanderbot/ext/automod/conditions/ocr_utils/__init__.py @@ -0,0 +1 @@ +from .pipeline import * diff --git a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py new file mode 100644 index 0000000..e3a395d --- /dev/null +++ b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py @@ -0,0 +1,258 @@ +from typing import Optional, overload + +import cv2 +import numpy as np +from cv2.typing import MatLike +from tesserocr import OEM, PSM, PyTessBaseAPI + +__all__ = ("get_text",) + + +@overload +def get_text(image_data: bytes, lang: str) -> Optional[str]: ... + + +@overload +def get_text(image_data: bytes, lang: str, idx: int) -> Optional[tuple[str, int]]: ... + + +def get_text( + image_data: bytes, lang: str, idx: Optional[int] = None +) -> Optional[str | tuple[str, int]]: + # Turn the bytes into a Numpy array + buffer = np.frombuffer(image_data, np.uint8) + image = cv2.imdecode(buffer, cv2.IMREAD_COLOR_RGB) + assert image is not None + + # Process the image to make the text stand out more + processed = preprocess(image) + processed = unskew(processed) + processed = reorient(processed) + + # OCR the processed image + with PyTessBaseAPI(oem=OEM.LSTM_ONLY, lang=lang) as api: + height, width = processed.shape[:2] + bytes_per_pixel = 1 + bytes_per_line = width * bytes_per_pixel + api.SetImageBytes( + imagedata=processed.tobytes(), # type: ignore + width=width, + height=height, + bytes_per_pixel=bytes_per_pixel, + bytes_per_line=bytes_per_line, + ) + if text := api.GetUTF8Text(): + return (text, idx) if idx is not None else text + + +def preprocess(image: MatLike) -> MatLike: + """ + Preprocess an image by making it strictly black and white (binarized) + while trying to keep as much detail in the text as possible. + """ + + # Upscale + processed = cv2.resize(image, None, fx=1.5, fy=1.5, interpolation=cv2.INTER_CUBIC) + + # Make grayscale + processed = cv2.cvtColor(processed, cv2.COLOR_BGR2GRAY) + + # Invert dark images + if processed.mean() < 128: + processed = cv2.bitwise_not(processed) + + # Denoise + processed = cv2.bilateralFilter(processed, 9, 30, 30) + + # Binarize + return cv2.adaptiveThreshold( + processed, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 7 + ) + + +def unskew(image: MatLike) -> MatLike: + """ + Use the lines in the image to straighten it out. It doesn't + have to be perfect since Tesseract can fix it. + """ + + # Merge the text into lines + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (40, 5)) + processed = cv2.morphologyEx(image, cv2.MORPH_OPEN, kernel) + + # Try to find the skew angle + angle = find_skew_angle(processed) + if angle is None: + return image + + # Rotate the image without cutting the corners off + height, width = image.shape[:2] + center = (width / 2, height / 2) + mat = cv2.getRotationMatrix2D(center, angle, 1.0) + + # Get the absolute values of the sine and cosine of the rotation + abs_cos = abs(mat[0, 0]) + abs_sin = abs(mat[0, 1]) + + # Find the new width and height + new_width = int(height * abs_sin + width * abs_cos) + new_height = int(height * abs_cos + width * abs_sin) + + # Update the center + mat[0, 2] += (new_width / 2) - center[0] + mat[1, 2] += (new_height / 2) - center[1] + + return cv2.warpAffine( + image, + mat, + (new_width, new_height), + flags=cv2.INTER_CUBIC, + borderMode=cv2.BORDER_CONSTANT, + borderValue=(255, 255, 255), + ) + + +def reorient(image: MatLike) -> MatLike: + """ + Use Tesseract OSD to make the image right side up. + """ + + orientation: int = 0 + with PyTessBaseAPI(psm=PSM.OSD_ONLY) as api: + height, width = image.shape[:2] + bytes_per_pixel = 1 + bytes_per_line = width * bytes_per_pixel + api.SetImageBytes( + imagedata=image.tobytes(), # type: ignore + width=width, + height=height, + bytes_per_pixel=bytes_per_pixel, + bytes_per_line=bytes_per_line, + ) + if os := api.DetectOrientationScript(): + orientation = os.get("orient_deg", 0) + + match orientation: + case 90: + return cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE) + case 180: + return cv2.rotate(image, cv2.ROTATE_180) + case 270: + return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE) + case _: + return image + + +def find_skew_angle( + image_array: MatLike, + sigma: float = 3.0, + num_peaks: int = 20, + angle_pm_90: bool = False, + min_angle: Optional[float] = None, + max_angle: Optional[float] = None, + min_deviation: float = 1.0, + gradient_percentile: float = 99.0, + canny_threshold_low: Optional[float] = None, + canny_threshold_high: Optional[float] = None, + hough_rho: float = 1.0, + hough_threshold: int = 30, +) -> Optional[np.float64]: + """ + From: https://github.com/HOZHENWAI/fast_deskew + + Estimate the skew angle (in degrees) of a (grayscale) document image. + + The pipeline follows the classical Hough-transform skew-detection method: + blur -> Canny edges -> Hough line transform -> fold each line orientation + into the canonical skew interval -> return the most frequent (modal) angle. + The rho (offset) dimension carries no skew information and is ignored. + + Thresholds are derived relative to the image (rather than fixed constants) so + the same defaults work across document sizes and contrasts: the Canny + thresholds come from a percentile of the gradient magnitude, and the dominant + orientation is taken from the strongest ``num_peaks`` Hough lines. + + image_array: :class:`MatLike` + 8-bit single-channel image. + sigma: :class:`float` + Standard deviation of the Gaussian pre-blur. + num_peaks: :class:`int` + Number of strongest Hough lines to vote with. + angle_pm_90: :class:`bool` + Fold into `[-90, 90)` instead of `[-45, 45)`. + min_angle: :class:`Optional[float]` + Lower bound (degrees) on the reported skew; `None` disables it. + max_angle: :class:`Optional[float]` + Upper bound (degrees) on the reported skew; `None` disables it. + min_deviation: :class:`float` + Strictly positive angular resolution in degrees; also the + voting bin width used to find the modal angle. + gradient_percentile: :class:`float` + Percentile of the gradient magnitude used as the + high Canny threshold when the Canny thresholds are auto-derived. + canny_threshold_low: :class:`Optional[float]` + Explicit low Canny threshold; `None` auto-derives it. + canny_threshold_high: :class:`Optional[float]` + Explicit high Canny threshold; `None` auto-derives it. + hough_rho: :class:`float` + Distance resolution of the accumulator in pixels. + hough_threshold: :class:`int` + Minimum Hough votes for a line to be considered. It acts + as a floor only; the dominant orientation is chosen from the strongest + `num_peaks` lines, so peak selection stays relative to the image. + :return: :class:`Optional[np.float64]` + The skew angle in degrees, or `None` if no dominant line is found. + """ + if min_deviation <= 0: + raise ValueError("min_deviation must be strictly positive") + + theta_resolution = np.deg2rad(min_deviation) + blurred_image = cv2.GaussianBlur(image_array, (0, 0), sigma) + + if canny_threshold_low is None or canny_threshold_high is None: + # Derive Canny thresholds from this image's gradient magnitude instead of + # using fixed constants, so faint or heavily blurred documents still yield + # edges (mirrors scikit-image's gradient-relative Canny). + grad_x = cv2.Sobel(blurred_image, cv2.CV_16S, 1, 0, ksize=3) + grad_y = cv2.Sobel(blurred_image, cv2.CV_16S, 0, 1, ksize=3) + magnitude = cv2.magnitude(grad_x.astype(np.float32), grad_y.astype(np.float32)) + high = max(float(np.percentile(magnitude, gradient_percentile)), 1.0) + edges = cv2.Canny(grad_x, grad_y, 0.5 * high, high) + else: + edges = cv2.Canny(blurred_image, canny_threshold_low, canny_threshold_high) + + lines = cv2.HoughLines(edges, hough_rho, theta_resolution, hough_threshold) + if lines is None: + return None + + # cv2.HoughLines returns lines ordered by descending vote count, so the first + # `num_peaks` are the strongest peaks. theta is the orientation of each line's + # normal in [0, pi). + theta = lines[:num_peaks, 0, 1] + + # Fold every orientation into the canonical skew interval BEFORE counting, so + # that text baselines and vertical strokes (which differ by ~90 deg) reinforce + # the same skew estimate. + if angle_pm_90: + folded = theta % np.pi - np.pi / 2 # [-pi/2, pi/2) + else: + folded = (theta + np.pi / 4) % (np.pi / 2) - np.pi / 4 # [-pi/4, pi/4) + + folded_deg = np.rad2deg(folded) + + # Restrict the reported skew to the requested output range, if any. + if min_angle is not None: + folded_deg = folded_deg[folded_deg >= min_angle] + if max_angle is not None: + folded_deg = folded_deg[folded_deg <= max_angle] + if folded_deg.size == 0: + return None + + # Most frequent angle: quantize to the min_deviation grid to group nearby + # peaks, pick the densest bin, then average its members for sub-bin precision. + bins = np.round(folded_deg / min_deviation) + values, counts = np.unique(bins, return_counts=True) + best_bin = values[np.argmax(counts)] + best_angle = folded_deg[bins == best_bin].mean() + + return np.float64(best_angle) diff --git a/commanderbot/ext/automod/types.py b/commanderbot/ext/automod/types.py index d406406..c8a470c 100644 --- a/commanderbot/ext/automod/types.py +++ b/commanderbot/ext/automod/types.py @@ -1,18 +1,22 @@ -from concurrent.futures import thread -from typing import Literal +from typing import Literal, Optional + +from discord import Attachment +from imagehash import ImageHash __all__ = ( "ContextEventFields", "ContextMetadataFields", "ContextFields", + "ContextAttachment", ) type ContextEventFields = Literal[ + # Channels "channel_id", "channel_name", "channel_mention", - + # Threads "thread_id", "thread_name", "thread_mention", @@ -21,21 +25,21 @@ "thread_slowmode_delay", "thread_auto_archive_duration", "thread_owner", - + # Categories "category_id", "category_name", "category_mention", - + # Messages "message_id", "message_content", "message_clean_content", "message_jump_url", - + # Reactions "reaction_emoji", "reaction_count", "reaction_normal_count", "reaction_burst_count", - + # Authors "author_id", "author_name", "author_display_name", @@ -44,7 +48,7 @@ "author_joined_at", "author_member_for", "author_created_at", - + # Actors "actor_id", "actor_name", "actor_display_name", @@ -53,7 +57,7 @@ "actor_joined_at", "actor_member_for", "actor_created_at", - + # Members "member_id", "member_name", "member_display_name", @@ -62,7 +66,7 @@ "member_joined_at", "member_member_for", "member_created_at", - + # Thread owners "thread_owner_id", "thread_owner_name", "thread_owner_display_name", @@ -71,24 +75,26 @@ "thread_owner_joined_at", "thread_owner_member_for", "thread_owner_created_at", - + # Users "user_id", "user_name", "user_display_name", "user_mention", "user_created_at", - - "channel" - "thread" - "category" - "message" - "reaction" - "author" - "actor" - "member" + # Unsafe fields + "channel", + "thread", + "category", + "message", + "attachments", + "reaction", + "author", + "actor", + "member", ] type ContextMetadataFields = Literal[ + "passed_ocr_condition", "mentioned_roles", "mentioned_role_names", "mentioned_users", @@ -102,3 +108,4 @@ ] type ContextFields = ContextEventFields | ContextMetadataFields +type ContextAttachment = tuple[Attachment, bytes, Optional[ImageHash]] From e419532ff0d8763f5b04f18cbb688d2047e655a7 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 23 Jul 2026 20:46:51 -0700 Subject: [PATCH 062/158] Changed how attachments are accessed --- commanderbot/ext/automod/actions/log_message.py | 4 ++-- .../automod/conditions/message_has_attachments.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/commanderbot/ext/automod/actions/log_message.py b/commanderbot/ext/automod/actions/log_message.py index fa4a735..9571b7b 100644 --- a/commanderbot/ext/automod/actions/log_message.py +++ b/commanderbot/ext/automod/actions/log_message.py @@ -101,10 +101,10 @@ def _build_log_view( log_container.add_item(ui.File(file)) # Attach media if it needs to be attached - if self.attach_media: + if self.attach_media and (attachments := context.event.attachments): gallery = ui.MediaGallery() has_media: bool = False - for attachment in message.attachments: + for attachment in attachments: content_type = attachment.content_type if content_type and content_type.startswith(("image", "video")): has_media = True diff --git a/commanderbot/ext/automod/conditions/message_has_attachments.py b/commanderbot/ext/automod/conditions/message_has_attachments.py index 2031479..7aea54e 100644 --- a/commanderbot/ext/automod/conditions/message_has_attachments.py +++ b/commanderbot/ext/automod/conditions/message_has_attachments.py @@ -19,11 +19,15 @@ class MessageHasAttachments(AutomodCondition): @override async def check(self, context: AutomodContext) -> bool: - message = context.event.message - if not message: + # We need to have attachments in context + attachments = context.event.attachments + if not attachments: return False - attachment_count = len(message.attachments) + # Check if the number of attachments is in the range if self.count: + attachment_count = len(attachments) return self.count.includes(attachment_count) - return attachment_count > 0 + + # Otherwise, return `True` since we have some number of attachments + return True From 4ec47768d672a5c4a36714aa0f93a8a935081041 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 30 Jul 2026 00:19:03 -0700 Subject: [PATCH 063/158] Changed context metadata to a dataclass --- commanderbot/ext/automod/automod_context.py | 178 +++++++++--------- .../conditions/message_mentions_roles.py | 8 +- .../conditions/message_mentions_users.py | 8 +- .../triggers/mentions_removed_from_message.py | 28 ++- commanderbot/ext/automod/types.py | 2 + 5 files changed, 115 insertions(+), 109 deletions(-) diff --git a/commanderbot/ext/automod/automod_context.py b/commanderbot/ext/automod/automod_context.py index 07d4540..f9edbac 100644 --- a/commanderbot/ext/automod/automod_context.py +++ b/commanderbot/ext/automod/automod_context.py @@ -1,6 +1,6 @@ import string from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from itertools import chain from logging import Logger from typing import TYPE_CHECKING, Any, Iterable, Optional, cast @@ -13,9 +13,9 @@ from PIL import Image from commanderbot.ext.automod.event import AutomodEvent -from commanderbot.ext.automod.types import ContextAttachment, ContextMetadataFields +from commanderbot.ext.automod.types import ContextAttachment from commanderbot.lib.predicates import is_member -from commanderbot.lib.types import MimeTypes +from commanderbot.lib.types import AttachmentID, MimeTypes __all__ = ("AutomodContext",) @@ -26,6 +26,23 @@ SAFE_TYPES: tuple[type, ...] = (bool, int, float, str) +@dataclass +class AutomodContextMetadata: + attachment_data: dict[AttachmentID, bytes] = field(default_factory=dict) + image_attachment_hashes: dict[AttachmentID, ImageHash] = field(default_factory=dict) + passed_ocr_condition: list[AttachmentID] = field(default_factory=list) + mentioned_roles: Optional[str] = None + mentioned_role_names: Optional[str] = None + mentioned_users: Optional[str] = None + mentioned_user_names: Optional[str] = None + removed_mentions: Optional[str] = None + removed_mention_names: Optional[str] = None + removed_user_mention_names: Optional[str] = None + removed_user_mentions: Optional[str] = None + removed_role_mentions: Optional[str] = None + removed_role_mention_names: Optional[str] = None + + @dataclass class AutomodContext: """ @@ -44,31 +61,11 @@ class AutomodContext: event: AutomodEvent """The event that the rule is processing.""" - _cached_attachments: list[AutomodContextAttachment] = field( - init=False, default_factory=list + metadata: AutomodContextMetadata = field( + init=False, default_factory=AutomodContextMetadata ) - """A cache for any attachments.""" - - _metadata: dict[str, Any] = field(init=False, default_factory=dict) """Any additional data attached to the execution context.""" - def __post_init__(self): - if attachments := self.event.attachments: - for att in attachments: - self._cached_attachments.append(AutomodContextAttachment(att)) - - def set_metadata(self, key: ContextMetadataFields, value: Any): - """Add metadata to the execution context""" - self._metadata[key] = value - - def get_metadata(self, key: ContextMetadataFields) -> Optional[Any]: - """Get metadata from the execution context.""" - return self._metadata.get(key) - - def remove_metadata(self, key: ContextMetadataFields): - """Remove metadata from the execution context""" - del self._metadata[key] - async def fetch_attachments(self) -> list[ContextAttachment]: """ Fetch all attachments from the execution context. @@ -76,10 +73,19 @@ async def fetch_attachments(self) -> list[ContextAttachment]: This will also cache the attachment's data so it only needs to be fetched once. """ + + # Return if we have no attachments in context + if not self.event.attachments: + return [] + attachments: list[ContextAttachment] = [] - for attachment in self._cached_attachments: - if resolved := attachment.get() or await attachment.fetch(): - attachments.append(resolved) + for attachment in self.event.attachments: + if data := self.metadata.attachment_data.get(attachment.id): + hash = self.metadata.image_attachment_hashes.get(attachment.id) + attachments.append((attachment, data, hash)) + elif result := await self._fetch_attachment(attachment): + data, hash = result + attachments.append((attachment, data, hash)) return attachments async def fetch_attachments_with_type( @@ -92,14 +98,63 @@ async def fetch_attachments_with_type( This will also cache the attachment's data so it only needs to be fetched once. """ + + # Return if we have no attachments in context + if not self.event.attachments: + return [] + attachments: list[ContextAttachment] = [] - for attachment in self._cached_attachments: - if not attachment.has_content_type(content_type): + for attachment in self.event.attachments: + if not self._attachment_has_content_type(attachment, content_type): continue - if resolved := attachment.get() or await attachment.fetch(): - attachments.append(resolved) + elif data := self.metadata.attachment_data.get(attachment.id): + hash = self.metadata.image_attachment_hashes.get(attachment.id) + attachments.append((attachment, data, hash)) + elif result := await self._fetch_attachment(attachment): + data, hash = result + attachments.append((attachment, data, hash)) return attachments + def _attachment_has_content_type( + self, attachment: Attachment, content_type: Optional[MimeTypes] + ) -> bool: + if attachment.content_type is None or content_type is None: + return attachment.content_type is content_type + return attachment.content_type.startswith(content_type) + + async def _fetch_attachment( + self, attachment: Attachment + ) -> Optional[tuple[bytes, Optional[ImageHash]]]: + if data := await self._fetch_attachment_data(attachment): + # Store attachment data + self.metadata.attachment_data[attachment.id] = data + + # Return early if this isn't an image attachment + if not self._attachment_has_content_type(attachment, "image"): + return (data, None) + + # Calculate phash + buffer = np.frombuffer(data, np.uint8) + image = Image.fromarray(buffer) + hash = phash(image) + + # Store image attachment phash + self.metadata.image_attachment_hashes[attachment.id] = hash + return (data, hash) + + async def _fetch_attachment_data(self, attachment: Attachment) -> Optional[bytes]: + try: + # Try fetching from the regular URL + return await attachment.read() + except Exception: + pass + + try: + # If that didn't work, try the proxy URL + return await attachment.read(use_cached=True) + except Exception: + pass + def get_fields(self, *, unsafe: bool = False) -> dict[str, Any]: """Get the full context data as key/value pairs.""" if unsafe: @@ -181,7 +236,9 @@ def _yield_safe_fields(self) -> Iterable[tuple[str, Any]]: yield from self._yield_safe_user_fields(user, "user") # Yield metadata fields - yield from ((k, v) for k, v in self._metadata.items() if self._is_value_safe(v)) + yield from ( + (k, v) for k, v in asdict(self.metadata).items() if self._is_value_safe(v) + ) def _yield_safe_user_fields( self, user: User, prefix: str @@ -243,56 +300,7 @@ def _yield_unsafe_fields(self) -> Iterable[tuple[str, Any]]: yield ("user", user) yield from ( - (k, v) for k, v in self._metadata.items() if not self._is_value_safe(v) + (k, v) + for k, v in asdict(self.metadata).items() + if not self._is_value_safe(v) ) - - -@dataclass -class AutomodContextAttachment: - """ - A very minimal wrapper around a `discord.Attachment` that caches - attachment data when fetched. Image attachments also have their - perceptual hash calculated. - """ - - _attachment: Attachment - _data: Optional[bytes] = field(init=False, default=None) - _phash: Optional[ImageHash] = field(init=False, default=None) - - @property - def _content_type(self) -> Optional[str]: - return self._attachment.content_type - - def has_content_type(self, content_type: Optional[MimeTypes]) -> bool: - if self._content_type is None or content_type is None: - return self._content_type is content_type - return self._content_type.startswith(content_type) - - def get(self) -> Optional[ContextAttachment]: - if self._data is not None: - return (self._attachment, self._data, self._phash) - - async def fetch(self) -> Optional[ContextAttachment]: - if self._data is not None: - return self.get() - - if data := await self._fetch_data(): - self._data = data - if self.has_content_type("image"): - buffer = np.frombuffer(self._data, np.uint8) - image = Image.fromarray(buffer) - self._phash = phash(image) - return self.get() - - async def _fetch_data(self) -> Optional[bytes]: - try: - # Try fetching from the regular URL - return await self._attachment.read() - except Exception: - pass - - try: - # If that didn't work, try the proxy URL - return await self._attachment.read(use_cached=True) - except Exception: - pass diff --git a/commanderbot/ext/automod/conditions/message_mentions_roles.py b/commanderbot/ext/automod/conditions/message_mentions_roles.py index 54be25c..2ed410f 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_roles.py +++ b/commanderbot/ext/automod/conditions/message_mentions_roles.py @@ -44,11 +44,11 @@ async def check(self, context: AutomodContext) -> bool: return False # Add mentioned roles metadata to context - context.set_metadata( - "mentioned_roles", " ".join((role.mention for role in mentioned_roles)) + context.metadata.mentioned_roles = " ".join( + (role.mention for role in mentioned_roles) ) - context.set_metadata( - "mentioned_role_names", " ".join((f"`{role}`" for role in mentioned_roles)) + context.metadata.mentioned_role_names = " ".join( + (f"`{role}`" for role in mentioned_roles) ) # If we got this far, the message has role mentions we care about diff --git a/commanderbot/ext/automod/conditions/message_mentions_users.py b/commanderbot/ext/automod/conditions/message_mentions_users.py index 12bd7a3..c036699 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_users.py +++ b/commanderbot/ext/automod/conditions/message_mentions_users.py @@ -44,11 +44,11 @@ async def check(self, context: AutomodContext) -> bool: return False # Add mentioned users metadata to context - context.set_metadata( - "mentioned_users", " ".join((user.mention for user in mentioned_users)) + context.metadata.mentioned_users = " ".join( + (user.mention for user in mentioned_users) ) - context.set_metadata( - "mentioned_user_names", " ".join((f"`{user}`" for user in mentioned_users)) + context.metadata.mentioned_user_names = " ".join( + (f"`{user}`" for user in mentioned_users) ) # If we got this far, the message has user mentions we care about diff --git a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py index 92321d4..cf78597 100644 --- a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py +++ b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py @@ -115,33 +115,29 @@ async def ignore(self, context: AutomodContext) -> bool: return True # Add removed mentions fields to context - context.set_metadata( - "removed_mentions", " ".join((m.mention for m in removed_mentions)) + context.metadata.removed_mentions = " ".join( + (m.mention for m in removed_mentions) ) - context.set_metadata( - "removed_mention_names", " ".join((f"`{m}`" for m in removed_mentions)) + context.metadata.removed_mention_names = " ".join( + (f"`{m}`" for m in removed_mentions) ) # Add removed user mentions fields to context if removed_user_mentions: - context.set_metadata( - "removed_user_mention_names", - " ".join((m.mention for m in removed_user_mentions)), + context.metadata.removed_mentions = " ".join( + (m.mention for m in removed_user_mentions) ) - context.set_metadata( - "removed_user_mentions", - " ".join((f"`{m}`" for m in removed_user_mentions)), + context.metadata.removed_mention_names = " ".join( + (f"`{m}`" for m in removed_user_mentions) ) # Add removed role mentions fields to context if removed_role_mentions: - context.set_metadata( - "removed_role_mentions", - " ".join((m.mention for m in removed_role_mentions)), + context.metadata.removed_role_mentions = " ".join( + (m.mention for m in removed_role_mentions) ) - context.set_metadata( - "removed_role_mention_names", - " ".join((f"`{m}`" for m in removed_role_mentions)), + context.metadata.removed_user_mention_names = " ".join( + (f"`{m}`" for m in removed_role_mentions) ) # If we got this far, we had removed mentions diff --git a/commanderbot/ext/automod/types.py b/commanderbot/ext/automod/types.py index c8a470c..8d263f5 100644 --- a/commanderbot/ext/automod/types.py +++ b/commanderbot/ext/automod/types.py @@ -94,6 +94,8 @@ ] type ContextMetadataFields = Literal[ + "attachment_data", + "image_attachment_hashes" "passed_ocr_condition", "mentioned_roles", "mentioned_role_names", From 82fd00ae982eadac0880dd53bb2a65e497978aa0 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 30 Jul 2026 00:19:18 -0700 Subject: [PATCH 064/158] Updated OCR condition --- .../conditions/image_attachments_contain.py | 33 ++++++++----------- .../automod/conditions/ocr_utils/pipeline.py | 12 ++++--- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py index ce0dce8..6fe1d53 100644 --- a/commanderbot/ext/automod/conditions/image_attachments_contain.py +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -9,9 +9,9 @@ from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.automod_exceptions import OCRNotSupported from commanderbot.ext.automod.condition import AutomodCondition -from commanderbot.ext.automod.types import ContextAttachment from commanderbot.lib.constants import SUPPORTS_OCR from commanderbot.lib.types import ( + AttachmentID, TesseractLanguages, TesseractScripts, UnicodeNormalizationForms, @@ -88,10 +88,13 @@ def _check_ocr_result(self, content: str) -> bool: return False - async def _ocr( - self, context: AutomodContext, attachments: list[ContextAttachment] - ) -> Optional[ContextAttachment]: - # Create `lang` scring from languages and scripts + async def _ocr(self, context: AutomodContext) -> Optional[AttachmentID]: + # We need image attachments in context + attachments = await context.fetch_attachments_with_type("image") + if not attachments: + return + + # Create `lang` string from languages and scripts lang: str = "+".join((v for v in chain(self.languages, self.scripts))) # Submit OCR tasks to the process pool @@ -99,18 +102,18 @@ async def _ocr( pool = context.bot.pool loop = asyncio.get_running_loop() tasks = [ - loop.run_in_executor(pool, get_text, data, lang, idx) - for idx, (_, data, _) in enumerate(attachments) + loop.run_in_executor(pool, get_text, data, lang, attachment.id) + for (attachment, data, _) in attachments ] # Check OCR results as they come in try: for task in asyncio.as_completed(tasks, timeout=self.timeout): if result := await task: - text, idx = result + text, attachment_id = result # Check if the text passes the condition and return the attachment if self._check_ocr_result(text): - return attachments[idx] + return attachment_id finally: for task in tasks: if not task.done(): @@ -122,17 +125,9 @@ async def check(self, context: AutomodContext) -> bool: if not SUPPORTS_OCR: raise OCRNotSupported - # We need image attachments in context - attachments = await context.fetch_attachments_with_type("image") - if not attachments: - return False - # OCR image attachments - if attachment := await self._ocr(context, attachments): - # Add attachment to metadata - passed_ocr = context.get_metadata("passed_ocr_condition") or [] - passed_ocr.append(attachment) - context.set_metadata("passed_ocr_condition", passed_ocr) + if attachment_id := await self._ocr(context): + context.metadata.passed_ocr_condition.append(attachment_id) return True # If we got this far, none of the attachments passed the condition diff --git a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py index e3a395d..de2ff0e 100644 --- a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py +++ b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py @@ -5,6 +5,8 @@ from cv2.typing import MatLike from tesserocr import OEM, PSM, PyTessBaseAPI +from commanderbot.lib.types import AttachmentID + __all__ = ("get_text",) @@ -13,12 +15,14 @@ def get_text(image_data: bytes, lang: str) -> Optional[str]: ... @overload -def get_text(image_data: bytes, lang: str, idx: int) -> Optional[tuple[str, int]]: ... +def get_text( + image_data: bytes, lang: str, attachment_id: AttachmentID +) -> Optional[tuple[str, AttachmentID]]: ... def get_text( - image_data: bytes, lang: str, idx: Optional[int] = None -) -> Optional[str | tuple[str, int]]: + image_data: bytes, lang: str, attachment_id: Optional[AttachmentID] = None +) -> Optional[str | tuple[str, AttachmentID]]: # Turn the bytes into a Numpy array buffer = np.frombuffer(image_data, np.uint8) image = cv2.imdecode(buffer, cv2.IMREAD_COLOR_RGB) @@ -42,7 +46,7 @@ def get_text( bytes_per_line=bytes_per_line, ) if text := api.GetUTF8Text(): - return (text, idx) if idx is not None else text + return (text, attachment_id) if attachment_id is not None else text def preprocess(image: MatLike) -> MatLike: From 1ec3aaee32779b55c280087f6f4ea2ff0c40ea60 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Mon, 3 Aug 2026 02:43:06 -0700 Subject: [PATCH 065/158] Changed a metadata name --- commanderbot/ext/automod/automod_context.py | 2 +- .../ext/automod/conditions/image_attachments_contain.py | 2 +- commanderbot/ext/automod/types.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/commanderbot/ext/automod/automod_context.py b/commanderbot/ext/automod/automod_context.py index f9edbac..2ed7dfc 100644 --- a/commanderbot/ext/automod/automod_context.py +++ b/commanderbot/ext/automod/automod_context.py @@ -30,7 +30,7 @@ class AutomodContextMetadata: attachment_data: dict[AttachmentID, bytes] = field(default_factory=dict) image_attachment_hashes: dict[AttachmentID, ImageHash] = field(default_factory=dict) - passed_ocr_condition: list[AttachmentID] = field(default_factory=list) + flagged_image_attachments: list[AttachmentID] = field(default_factory=list) mentioned_roles: Optional[str] = None mentioned_role_names: Optional[str] = None mentioned_users: Optional[str] = None diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py index 6fe1d53..a12b41a 100644 --- a/commanderbot/ext/automod/conditions/image_attachments_contain.py +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -127,7 +127,7 @@ async def check(self, context: AutomodContext) -> bool: # OCR image attachments if attachment_id := await self._ocr(context): - context.metadata.passed_ocr_condition.append(attachment_id) + context.metadata.flagged_image_attachments.append(attachment_id) return True # If we got this far, none of the attachments passed the condition diff --git a/commanderbot/ext/automod/types.py b/commanderbot/ext/automod/types.py index 8d263f5..ca03010 100644 --- a/commanderbot/ext/automod/types.py +++ b/commanderbot/ext/automod/types.py @@ -96,7 +96,7 @@ type ContextMetadataFields = Literal[ "attachment_data", "image_attachment_hashes" - "passed_ocr_condition", + "flagged_image_attachments", "mentioned_roles", "mentioned_role_names", "mentioned_users", From c2c41bd90c1f331645448b3227d8b2c700fed592 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Mon, 3 Aug 2026 02:45:37 -0700 Subject: [PATCH 066/158] Changed how OCR results are extracted --- .../automod/conditions/ocr_utils/pipeline.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py index de2ff0e..336fbb5 100644 --- a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py +++ b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py @@ -3,7 +3,7 @@ import cv2 import numpy as np from cv2.typing import MatLike -from tesserocr import OEM, PSM, PyTessBaseAPI +from tesserocr import OEM, PSM, RIL, PyResultIterator, PyTessBaseAPI, iterate_level from commanderbot.lib.types import AttachmentID @@ -45,8 +45,24 @@ def get_text( bytes_per_pixel=bytes_per_pixel, bytes_per_line=bytes_per_line, ) - if text := api.GetUTF8Text(): - return (text, attachment_id) if attachment_id is not None else text + + # Iterate over every word in the image (in order) and add them to an array + words: list[str] = [] + page_iter = api.GetIterator() + for word_iter in iterate_level(page_iter, RIL.WORD): + try: + assert isinstance(word_iter, PyResultIterator) + if word := word_iter.GetUTF8Text(RIL.WORD).strip(): + words.append(word) + except: + pass + + # Return any words we found as a string + if words: + text = " ".join(words) + if attachment_id: + return (text, attachment_id) + return text def preprocess(image: MatLike) -> MatLike: From 758cd4a72845c19d211c274cc466fffd7b2a5048 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Mon, 3 Aug 2026 02:47:17 -0700 Subject: [PATCH 067/158] Started implementing buckets --- commanderbot/ext/automod/action/types.py | 1 + commanderbot/ext/automod/actions/__init__.py | 1 + .../ext/automod/actions/add_to_bucket.py | 26 ++++++++++++ commanderbot/ext/automod/bucket/__init__.py | 2 + .../ext/automod/bucket/bucket_base.py | 40 +++++++++++++++++++ 5 files changed, 70 insertions(+) create mode 100644 commanderbot/ext/automod/actions/add_to_bucket.py create mode 100644 commanderbot/ext/automod/bucket/__init__.py create mode 100644 commanderbot/ext/automod/bucket/bucket_base.py diff --git a/commanderbot/ext/automod/action/types.py b/commanderbot/ext/automod/action/types.py index 7b57360..16b4ebb 100644 --- a/commanderbot/ext/automod/action/types.py +++ b/commanderbot/ext/automod/action/types.py @@ -10,6 +10,7 @@ actions.AddReactions | actions.AddRolesToActor | actions.AddRolesToAuthor + | actions.AddToBucket | actions.AddUsersToThread | actions.DeleteMessage | actions.DMMember diff --git a/commanderbot/ext/automod/actions/__init__.py b/commanderbot/ext/automod/actions/__init__.py index b4e8997..88ec40f 100644 --- a/commanderbot/ext/automod/actions/__init__.py +++ b/commanderbot/ext/automod/actions/__init__.py @@ -1,6 +1,7 @@ from .add_reactions import * from .add_roles_to_actor import * from .add_roles_to_author import * +from .add_to_bucket import * from .add_users_to_thread import * from .delete_message import * from .dm_member import * diff --git a/commanderbot/ext/automod/actions/add_to_bucket.py b/commanderbot/ext/automod/actions/add_to_bucket.py new file mode 100644 index 0000000..12c01f6 --- /dev/null +++ b/commanderbot/ext/automod/actions/add_to_bucket.py @@ -0,0 +1,26 @@ +from typing import Literal, override + +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext + +__all__ = ("AddToBucket",) + + +class AddToBucket(AutomodAction): + """ + Add any relevant data in context to a bucket. + """ + + type: Literal["add_to_bucket"] + + bucket: str + """The bucket to add to.""" + + @override + async def apply(self, context: AutomodContext): + # Get the bucket and add context data to it + guild = context.state.guild + store = context.state.store + bucket = await store.require_bucket(guild, self.bucket) + if not bucket.disabled: + await bucket.add(context) diff --git a/commanderbot/ext/automod/bucket/__init__.py b/commanderbot/ext/automod/bucket/__init__.py new file mode 100644 index 0000000..3cdae7f --- /dev/null +++ b/commanderbot/ext/automod/bucket/__init__.py @@ -0,0 +1,2 @@ +from .bucket_base import * +from .types import * diff --git a/commanderbot/ext/automod/bucket/bucket_base.py b/commanderbot/ext/automod/bucket/bucket_base.py new file mode 100644 index 0000000..f2dc9ad --- /dev/null +++ b/commanderbot/ext/automod/bucket/bucket_base.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from commanderbot.ext.automod.automod_context import AutomodContext + +__all__ = ("AutomodBucket",) + + +class AutomodBucket(ABC, BaseModel): + """ + Base class for all automod buckets. + + A bucket can be used to carry state through multiple events. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + type: str + """The bucket type.""" + + name: str + """The name of the bucket.""" + + description: Optional[str] = None + """Describe what the bucket is for.""" + + disabled: bool = False + """Is the bucket disabled?""" + + @abstractmethod + async def add(self, context: AutomodContext): + """Modify the bucket using context data.""" + pass + + @abstractmethod + async def clear(self): + """Clear all data in the bucket.""" + pass From 370f785579b3fb2c072e9f6c58fcb01f2ddc8ece Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 04:01:05 -0700 Subject: [PATCH 068/158] Added library for mime types --- pyproject.toml | 3 ++- uv.lock | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 173c829..5d760b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "python-dotenv>=1.0.0", "pydantic>=2.13.0", "aiohttp>=3.13.0", - + # These are used for logging. "colorama>=0.4.0", "colorlog>=6.10.0", @@ -40,6 +40,7 @@ dependencies = [ "opencv-python-headless>=5.0.0.0", "pillow>=12.3.0", "imagehash>=4.3.0", + "mime-enum>=0.0.3", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 276a6da..56bb0dc 100644 --- a/uv.lock +++ b/uv.lock @@ -225,6 +225,7 @@ dependencies = [ { name = "imagehash" }, { name = "jsonpath-ng" }, { name = "mccq" }, + { name = "mime-enum" }, { name = "numpy" }, { name = "opencv-python-headless" }, { name = "pillow" }, @@ -254,9 +255,10 @@ requires-dist = [ { name = "colorlog", specifier = ">=6.10.0" }, { name = "discord-py", specifier = ">=2.7.0" }, { name = "emoji", specifier = ">=2.0.0" }, - { name = "imagehash", specifier = ">=4.3.2" }, + { name = "imagehash", specifier = ">=4.3.0" }, { name = "jsonpath-ng", specifier = ">=1.7.0" }, { name = "mccq", specifier = ">=1.0.0" }, + { name = "mime-enum", specifier = ">=0.0.3" }, { name = "numpy", specifier = ">=2.5.0" }, { name = "opencv-python-headless", specifier = ">=5.0.0.0" }, { name = "pillow", specifier = ">=12.3.0" }, @@ -403,6 +405,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/6b/f517a39126424af1e9fea52536b063268afcac07e2d00ff487b4adc8d537/mccq-1.0.2-py3-none-any.whl", hash = "sha256:abfad271f10b3a77149276024de35f82b56732df280f206335a15a539f3f5c54", size = 17400, upload-time = "2018-11-21T15:29:41.966Z" }, ] +[[package]] +name = "mime-enum" +version = "0.0.3" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/b9/8c2e4f48c201143fa6a4fe5c4dd5c588c9ad151a667b8905598c9ffe9488/mime_enum-0.0.3.tar.gz", hash = "sha256:9ba84c74a2edc8d62ac4ac9ab57d18050b6c4620fb9d22b0908a32a10d7fb83a", size = 125421, upload-time = "2026-05-19T12:30:27.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/09/d26ec82740540c40edc097703f241d96bcad8c9925ed5188eec8b88e5bd2/mime_enum-0.0.3-py3-none-any.whl", hash = "sha256:c90186c60ac56aeaaf33dd12f0bfb212881925e30546d787affacf6dbb7f6ef1", size = 31152, upload-time = "2026-05-19T12:30:25.909Z" }, +] + [[package]] name = "multidict" version = "6.7.1" From e199b240684cd55796946dc340ce54747857383d Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 04:04:10 -0700 Subject: [PATCH 069/158] Changed how mime types are used --- commanderbot/ext/automod/automod_context.py | 71 ++++++++++++------- .../conditions/image_attachments_contain.py | 5 +- commanderbot/ext/automod/constants.py | 16 +++++ commanderbot/ext/automod/types.py | 4 +- commanderbot/lib/types.py | 17 ----- 5 files changed, 66 insertions(+), 47 deletions(-) create mode 100644 commanderbot/ext/automod/constants.py diff --git a/commanderbot/ext/automod/automod_context.py b/commanderbot/ext/automod/automod_context.py index 2ed7dfc..432e29c 100644 --- a/commanderbot/ext/automod/automod_context.py +++ b/commanderbot/ext/automod/automod_context.py @@ -5,17 +5,20 @@ from logging import Logger from typing import TYPE_CHECKING, Any, Iterable, Optional, cast +import imagehash import numpy as np from discord import Attachment, Member, User from discord.ext.commands import Bot from discord.utils import format_dt, utcnow -from imagehash import ImageHash, phash +from imagehash import ImageHash +from mime_enum import MimeType, try_parse from PIL import Image +from commanderbot.ext.automod.constants import IMAGE_MIME_TYPES from commanderbot.ext.automod.event import AutomodEvent from commanderbot.ext.automod.types import ContextAttachment from commanderbot.lib.predicates import is_member -from commanderbot.lib.types import AttachmentID, MimeTypes +from commanderbot.lib.types import AttachmentID __all__ = ("AutomodContext",) @@ -29,8 +32,10 @@ @dataclass class AutomodContextMetadata: attachment_data: dict[AttachmentID, bytes] = field(default_factory=dict) - image_attachment_hashes: dict[AttachmentID, ImageHash] = field(default_factory=dict) - flagged_image_attachments: list[AttachmentID] = field(default_factory=list) + image_attachment_phashes: dict[AttachmentID, ImageHash] = field( + default_factory=dict + ) + flagged_attachments: list[AttachmentID] = field(default_factory=list) mentioned_roles: Optional[str] = None mentioned_role_names: Optional[str] = None mentioned_users: Optional[str] = None @@ -81,18 +86,18 @@ async def fetch_attachments(self) -> list[ContextAttachment]: attachments: list[ContextAttachment] = [] for attachment in self.event.attachments: if data := self.metadata.attachment_data.get(attachment.id): - hash = self.metadata.image_attachment_hashes.get(attachment.id) - attachments.append((attachment, data, hash)) + phash = self.metadata.image_attachment_phashes.get(attachment.id) + attachments.append((attachment, data, phash)) elif result := await self._fetch_attachment(attachment): - data, hash = result - attachments.append((attachment, data, hash)) + data, phash = result + attachments.append((attachment, data, phash)) return attachments async def fetch_attachments_with_type( - self, content_type: Optional[MimeTypes] + self, *content_types: Optional[MimeType] ) -> list[ContextAttachment]: """ - Fetch all attachments from the execution context with a certain MIME type + Fetch all attachments from the execution context with a certain MIME types (https://en.wikipedia.org/wiki/Media_type#Types). This will also cache the attachment's data so it only needs to be @@ -105,22 +110,36 @@ async def fetch_attachments_with_type( attachments: list[ContextAttachment] = [] for attachment in self.event.attachments: - if not self._attachment_has_content_type(attachment, content_type): + # Parse content type + content_type: Optional[MimeType] = None + if attachment.content_type is not None: + content_type = try_parse(attachment.content_type) + + # Skip attachment if it has the wrong content type + if content_type not in content_types: continue - elif data := self.metadata.attachment_data.get(attachment.id): - hash = self.metadata.image_attachment_hashes.get(attachment.id) - attachments.append((attachment, data, hash)) + + # Otherwise, get/fetch it + if data := self.metadata.attachment_data.get(attachment.id): + phash = self.metadata.image_attachment_phashes.get(attachment.id) + attachments.append((attachment, data, phash)) elif result := await self._fetch_attachment(attachment): - data, hash = result - attachments.append((attachment, data, hash)) + data, phash = result + attachments.append((attachment, data, phash)) return attachments - def _attachment_has_content_type( - self, attachment: Attachment, content_type: Optional[MimeTypes] - ) -> bool: - if attachment.content_type is None or content_type is None: - return attachment.content_type is content_type - return attachment.content_type.startswith(content_type) + def _is_image_attachment(self, attachment: Attachment) -> bool: + # Return if the attachment doesn't have a content type + if attachment.content_type is None: + return False + + # Parse content type + content_type: Optional[MimeType] = try_parse(attachment.content_type) + if content_type is None: + return False + + # Check if the attachment is an image + return content_type in IMAGE_MIME_TYPES async def _fetch_attachment( self, attachment: Attachment @@ -130,17 +149,17 @@ async def _fetch_attachment( self.metadata.attachment_data[attachment.id] = data # Return early if this isn't an image attachment - if not self._attachment_has_content_type(attachment, "image"): + if not self._is_image_attachment(attachment): return (data, None) # Calculate phash buffer = np.frombuffer(data, np.uint8) image = Image.fromarray(buffer) - hash = phash(image) + phash = imagehash.phash(image) # Store image attachment phash - self.metadata.image_attachment_hashes[attachment.id] = hash - return (data, hash) + self.metadata.image_attachment_phashes[attachment.id] = phash + return (data, phash) async def _fetch_attachment_data(self, attachment: Attachment) -> Optional[bytes]: try: diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py index a12b41a..7297a7a 100644 --- a/commanderbot/ext/automod/conditions/image_attachments_contain.py +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -9,6 +9,7 @@ from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.automod_exceptions import OCRNotSupported from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.constants import IMAGE_MIME_TYPES from commanderbot.lib.constants import SUPPORTS_OCR from commanderbot.lib.types import ( AttachmentID, @@ -90,7 +91,7 @@ def _check_ocr_result(self, content: str) -> bool: async def _ocr(self, context: AutomodContext) -> Optional[AttachmentID]: # We need image attachments in context - attachments = await context.fetch_attachments_with_type("image") + attachments = await context.fetch_attachments_with_type(*IMAGE_MIME_TYPES) if not attachments: return @@ -127,7 +128,7 @@ async def check(self, context: AutomodContext) -> bool: # OCR image attachments if attachment_id := await self._ocr(context): - context.metadata.flagged_image_attachments.append(attachment_id) + context.metadata.flagged_attachments.append(attachment_id) return True # If we got this far, none of the attachments passed the condition diff --git a/commanderbot/ext/automod/constants.py b/commanderbot/ext/automod/constants.py new file mode 100644 index 0000000..566392f --- /dev/null +++ b/commanderbot/ext/automod/constants.py @@ -0,0 +1,16 @@ +from mime_enum import MimeType + +__all__ = ("IMAGE_MIME_TYPES",) + +IMAGE_MIME_TYPES: tuple[MimeType, ...] = ( + MimeType.IMAGE_AVIF, + MimeType.IMAGE_AVIF_SEQUENCE, + MimeType.IMAGE_BMP, + MimeType.IMAGE_GIF, + MimeType.IMAGE_HEIC, + MimeType.IMAGE_JPEG, + MimeType.IMAGE_PJPEG, + MimeType.IMAGE_PNG, + MimeType.IMAGE_TIFF, + MimeType.IMAGE_WEBP, +) diff --git a/commanderbot/ext/automod/types.py b/commanderbot/ext/automod/types.py index ca03010..4ffeea9 100644 --- a/commanderbot/ext/automod/types.py +++ b/commanderbot/ext/automod/types.py @@ -95,8 +95,8 @@ type ContextMetadataFields = Literal[ "attachment_data", - "image_attachment_hashes" - "flagged_image_attachments", + "image_attachment_phashes", + "flagged_attachments", "mentioned_roles", "mentioned_role_names", "mentioned_users", diff --git a/commanderbot/lib/types.py b/commanderbot/lib/types.py index beeb5a6..c2e105a 100644 --- a/commanderbot/lib/types.py +++ b/commanderbot/lib/types.py @@ -41,7 +41,6 @@ "ChannelTypeNames", "Timedelta", "UnicodeNormalizationForms", - "MimeTypes", "TesseractLanguages", "TesseractScripts", ) @@ -184,22 +183,6 @@ UnicodeNormalizationForms: TypeAlias = Literal["NFC", "NFD", "NFKC", "NFKD"] -MimeTypes: TypeAlias = Literal[ - "application", - "audio", - "example", - "font", - "haptics", - "image", - "message", - "model", - "multipart", - "text", - "video", -] -""" -All registered MIME types (https://en.wikipedia.org/wiki/Media_type#Types). -""" # fmt: off TesseractLanguages: TypeAlias = Literal[ From 5794a3289e88db21e0a250850d901df9d295d99e Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 04:06:01 -0700 Subject: [PATCH 070/158] Added missing function call --- commanderbot/ext/automod/conditions/ocr_utils/pipeline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py index 336fbb5..ada652b 100644 --- a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py +++ b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py @@ -45,6 +45,7 @@ def get_text( bytes_per_pixel=bytes_per_pixel, bytes_per_line=bytes_per_line, ) + api.Recognize() # Iterate over every word in the image (in order) and add them to an array words: list[str] = [] From 79375d8e3a1547525e3769bc27071888bc786481 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 04:35:26 -0700 Subject: [PATCH 071/158] Added a bucket for flagged image attachments --- .../buckets/flagged_image_attachments.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 commanderbot/ext/automod/buckets/flagged_image_attachments.py diff --git a/commanderbot/ext/automod/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/buckets/flagged_image_attachments.py new file mode 100644 index 0000000..9dc0d6f --- /dev/null +++ b/commanderbot/ext/automod/buckets/flagged_image_attachments.py @@ -0,0 +1,88 @@ +from datetime import datetime +from typing import Literal, override + +from discord import Attachment +from discord.utils import utcnow +from imagehash import ImageHash +from pydantic import BaseModel, Field + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.bucket import AutomodBucket +from commanderbot.ext.automod.constants import IMAGE_MIME_TYPES +from commanderbot.lib.types import AttachmentID, Timedelta + +__all__ = ("FlaggedImageAttachments",) + + +class FlaggedImageAttachment(BaseModel): + attachment_id: AttachmentID + phash: ImageHash + time: datetime + + +class FlaggedImageAttachments(AutomodBucket): + """ + Track image attachments that were flagged by various conditions. + """ + + type: Literal["flagged_image_attachments"] + + lifetime: Timedelta + """ + How long to store flagged image attachments. Longer durations are able to store more + image attachments for potential queries, but take more memory on average. + """ + + attachments: dict[AttachmentID, FlaggedImageAttachment] = Field( + default_factory=dict + ) + + def _clean_up(self): + cutoff = utcnow() - self.lifetime + for attachment_id in list(self.attachments.keys()): + if self.attachments[attachment_id].time < cutoff: + del self.attachments[attachment_id] + + def is_flagged(self, image_attachment: Attachment) -> bool: + return image_attachment.id in self.attachments + + def is_flagged_phash(self, phash: ImageHash, max_hamming_distance: int = 0) -> bool: + for flagged_attachment in self.attachments.values(): + if flagged_attachment.phash - phash <= max_hamming_distance: + return True + return False + + @override + async def add(self, context: AutomodContext): + # We need image attachments in context + image_attachments = await context.fetch_attachments_with_type(*IMAGE_MIME_TYPES) + if not image_attachments: + return + + # Clean up expired image attachments + self._clean_up() + + # Add flagged image attachments to bucket + flagged_at = utcnow() + for image_attachment, _, phash in image_attachments: + # The image attachment needs to have a phash + assert phash is not None + + # Skip image attachments that weren't flagged + if image_attachment.id not in context.metadata.flagged_attachments: + continue + + # If the image attachment was already stored, update its time + if flagged := self.attachments.get(image_attachment.id): + flagged.time = flagged_at + continue + + # Otherwise, add a new entry + flagged = FlaggedImageAttachment( + attachment_id=image_attachment.id, phash=phash, time=flagged_at + ) + self.attachments[image_attachment.id] = flagged + + @override + async def clear(self): + self.attachments.clear() From 3129c2a26012a3bfb0fcacd24542cc581b919695 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 05:38:56 -0700 Subject: [PATCH 072/158] Added a bucket for message history along with a trigger for message frequency --- commanderbot/ext/automod/bucket/types.py | 11 ++ commanderbot/ext/automod/buckets/__init__.py | 2 + .../ext/automod/buckets/message_history.py | 181 ++++++++++++++++++ commanderbot/ext/automod/events/__init__.py | 1 + .../ext/automod/events/message_frequency.py | 66 +++++++ .../ext/automod/triggers/message_frequency.py | 52 +++++ 6 files changed, 313 insertions(+) create mode 100644 commanderbot/ext/automod/bucket/types.py create mode 100644 commanderbot/ext/automod/buckets/__init__.py create mode 100644 commanderbot/ext/automod/buckets/message_history.py create mode 100644 commanderbot/ext/automod/events/message_frequency.py create mode 100644 commanderbot/ext/automod/triggers/message_frequency.py diff --git a/commanderbot/ext/automod/bucket/types.py b/commanderbot/ext/automod/bucket/types.py new file mode 100644 index 0000000..e309d59 --- /dev/null +++ b/commanderbot/ext/automod/bucket/types.py @@ -0,0 +1,11 @@ +from typing import Annotated + +from pydantic import Field + +from commanderbot.ext.automod import buckets + +__all__ = ("AutomodBucketType",) + +type _AutomodBucketType = (buckets.FlaggedImageAttachments | buckets.MessageHistory) + +type AutomodBucketType = Annotated[_AutomodBucketType, Field(discriminator="type")] diff --git a/commanderbot/ext/automod/buckets/__init__.py b/commanderbot/ext/automod/buckets/__init__.py new file mode 100644 index 0000000..9cc456c --- /dev/null +++ b/commanderbot/ext/automod/buckets/__init__.py @@ -0,0 +1,2 @@ +from .flagged_image_attachments import * +from .message_history import * diff --git a/commanderbot/ext/automod/buckets/message_history.py b/commanderbot/ext/automod/buckets/message_history.py new file mode 100644 index 0000000..01693d2 --- /dev/null +++ b/commanderbot/ext/automod/buckets/message_history.py @@ -0,0 +1,181 @@ +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Iterable, Literal, override + +from discord import Member, Message, User +from discord.utils import utcnow +from pydantic import PrivateAttr + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.bucket import AutomodBucket +from commanderbot.lib.types import ChannelID, MessageID, Timedelta, UserID + +__all__ = ("MessageHistory",) + + +@dataclass +class MessageRecord: + message_id: MessageID + channel_id: ChannelID + time: datetime + + +@dataclass +class ChannelRecord: + channel_id: ChannelID + messages: dict[MessageID, MessageRecord] = field(default_factory=dict) + + @property + def message_count(self) -> int: + return len(self.messages) + + def yield_messages(self) -> Iterable[MessageRecord]: + yield from self.messages.values() + + +@dataclass +class UserRecord: + user_id: UserID + channels: dict[ChannelID, ChannelRecord] = field(default_factory=dict) + + @property + def channel_count(self) -> int: + return len(self.channels) + + @property + def message_count(self) -> int: + count: int = 0 + for channel_record in self.yield_channels(): + count += channel_record.message_count + return count + + def yield_channels(self) -> Iterable[ChannelRecord]: + yield from self.channels.values() + + def yield_messages(self) -> Iterable[MessageRecord]: + for channel_record in self.yield_channels(): + yield from channel_record.yield_messages() + + def add_message_record(self, message_record: MessageRecord): + """Add a message record to this user record.""" + channel_id = message_record.channel_id + channel_record = self.channels.get(channel_id) + if channel_record is None: + channel_record = ChannelRecord(channel_id=channel_id) + self.channels[channel_id] = channel_record + message_id = message_record.message_id + channel_record.messages[message_id] = message_record + + def add_message(self, message: Message): + """Add a message to this user record.""" + message_record = MessageRecord( + message_id=message.id, + channel_id=message.channel.id, + time=message.edited_at or message.created_at, + ) + self.add_message_record(message_record) + + def add_from(self, other: UserRecord, since: datetime): + """Add the message records of another user record to this user record.""" + for message_record in other.yield_messages(): + if message_record.time >= since: + self.add_message_record(message_record) + + +type Partition = dict[UserID, UserRecord] +type History = defaultdict[datetime, Partition] + + +class MessageHistory(AutomodBucket): + """ + Track message history across channels. + """ + + type: Literal["message_history"] + + lifetime: Timedelta + """ + How long to record message history. Longer durations are able to record more + message history for potential queries, but take more memory on average. + """ + + interval: Timedelta + """ + The interval by which to partition message history. This is used to decide when to + release old, unused message history and free memory. + """ + + _history: History = PrivateAttr(default_factory=lambda: defaultdict(dict)) + + @property + def lifetime_seconds(self) -> int: + return int(self.lifetime.total_seconds()) + + @property + def interval_seconds(self) -> int: + return int(self.lifetime.total_seconds()) + + def _clean_up(self): + cutoff = utcnow() - self.lifetime + for interval in list(self._history.keys()): + if interval < cutoff: + del self._history[interval] + + def _to_interval(self, dt: datetime) -> datetime: + ts = int(dt.timestamp()) + interval_ts = (ts // self.interval_seconds) * self.interval_seconds + interval_dt = datetime.fromtimestamp(interval_ts, timezone.utc) + return interval_dt + + def yield_partitions_since(self, since_interval: datetime) -> Iterable[Partition]: + for interval, partition in self._history.items(): + if interval >= since_interval: + yield partition + + def yield_user_records_since( + self, user: User | Member, since_interval: datetime + ) -> Iterable[UserRecord]: + for partition in self.yield_partitions_since(since_interval): + if user_record := partition.get(user.id): + yield user_record + + def build_message_history_since( + self, user: User | Member, since: datetime + ) -> UserRecord: + message_history = UserRecord(user_id=user.id) + since_interval = self._to_interval(since) + for user_record in self.yield_user_records_since(user, since_interval): + message_history.add_from(user_record, since) + return message_history + + @override + async def add(self, context: AutomodContext): + # We need a message in context + message = context.event.message + if not message: + return + + # Clean up expired message history + self._clean_up() + + # Calculate the interval based on the most recent message timestamp, and use it + # to get/create the corresponding partition. + interval = self._to_interval(message.edited_at or message.created_at) + partition = self._history[interval] + + # Within this partition, get/create the user's record + user_id = message.author.id + user_record = partition.get(user_id) + if user_record is None: + user_record = UserRecord(user_id=user_id) + partition[user_id] = user_record + user_record.add_message(message) + + # Dispatch events + await context.state.dispatch_event(events.MessageFrequency(message, self)) + + @override + async def clear(self): + self._history.clear() diff --git a/commanderbot/ext/automod/events/__init__.py b/commanderbot/ext/automod/events/__init__.py index e1e4026..e235f97 100644 --- a/commanderbot/ext/automod/events/__init__.py +++ b/commanderbot/ext/automod/events/__init__.py @@ -11,6 +11,7 @@ from .member_updated import * from .message_deleted import * from .message_edited import * +from .message_frequency import * from .message_sent import * from .reaction_added import * from .reaction_removed import * diff --git a/commanderbot/ext/automod/events/message_frequency.py b/commanderbot/ext/automod/events/message_frequency.py new file mode 100644 index 0000000..18e15a6 --- /dev/null +++ b/commanderbot/ext/automod/events/message_frequency.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass +from typing import TYPE_CHECKING, override + +from discord import Attachment, Member, Message, Thread, User + +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.lib.predicates import ( + is_member, + is_messagable_guild_channel, + is_thread, + is_user, +) +from commanderbot.lib.types import MessageableGuildChannel + +__all__ = ("MessageFrequency",) + +if TYPE_CHECKING: + from commanderbot.ext.automod import buckets + + +@dataclass +class MessageFrequency(AutomodEvent): + _message: Message + bucket: buckets.MessageHistory + + @property + @override + def channel(self) -> MessageableGuildChannel | Thread: + assert is_messagable_guild_channel(self._message.channel) or is_thread( + self._message.channel + ) + return self._message.channel + + @property + @override + def message(self) -> Message: + return self._message + + @property + @override + def attachments(self) -> list[Attachment]: + return self._message.attachments + + @property + @override + def author(self) -> Member: + assert is_member(self._message.author) + return self._message.author + + @property + @override + def actor(self) -> Member: + assert is_member(self._message.author) + return self._message.author + + @property + @override + def member(self) -> Member: + assert is_member(self._message.author) + return self._message.author + + @property + @override + def user(self) -> User: + assert is_user(self._message.author) + return self._message.author diff --git a/commanderbot/ext/automod/triggers/message_frequency.py b/commanderbot/ext/automod/triggers/message_frequency.py new file mode 100644 index 0000000..9ca73a3 --- /dev/null +++ b/commanderbot/ext/automod/triggers/message_frequency.py @@ -0,0 +1,52 @@ +from typing import Literal, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.guards import IntegerRangeGuard +from commanderbot.ext.automod.trigger import AutomodTrigger +from commanderbot.lib.types import Timedelta + +__all__ = ("MessageFrequency",) + + +class MessageFrequency(AutomodTrigger): + """ + Triggers when a message author is a suspect of spamming. + """ + + type: Literal["message_frequency"] + event_types = (events.MessageFrequency,) + + bucket: str + """The bucket being used to track message frequency.""" + + timeframe: Timedelta + """How far back to consider a user's activity for spam.""" + + message_count: IntegerRangeGuard + """The number of messages a user must send before being suspected of spam.""" + + channel_count: IntegerRangeGuard + """ + The number of channels in which the user must send messages in before being suspected + of spam. + """ + + @override + async def ignore(self, context: AutomodContext) -> bool: + # Ignore events dispatched by other buckets + assert isinstance(context.event, events.MessageFrequency) + bucket = context.event.bucket + if bucket.name != self.bucket: + return True + + # Use the bucket to build a record out of our timeframe + + message = context.event.message + since = message.created_at - self.timeframe + record = bucket.build_message_history_since(message.author, since) + + # Ignore if the record does not meet our thresholds + enough_messages = self.message_count.includes(record.message_count) + enough_channels = self.channel_count.includes(record.channel_count) + return not (enough_messages and enough_channels) From d81beca47afc90d9082bd4b291a3a51b14c7779d Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 07:48:50 -0700 Subject: [PATCH 073/158] Added an `ImageHash` alias that works with Pydantic --- commanderbot/lib/__init__.py | 1 + commanderbot/lib/image_hash.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 commanderbot/lib/image_hash.py diff --git a/commanderbot/lib/__init__.py b/commanderbot/lib/__init__.py index 49aba3a..1e20a29 100644 --- a/commanderbot/lib/__init__.py +++ b/commanderbot/lib/__init__.py @@ -6,6 +6,7 @@ from .event_data import * from .exceptions import * from .from_data_mixin import * +from .image_hash import * from .intents import * from .json import * from .json_serializable import * diff --git a/commanderbot/lib/image_hash.py b/commanderbot/lib/image_hash.py new file mode 100644 index 0000000..290af83 --- /dev/null +++ b/commanderbot/lib/image_hash.py @@ -0,0 +1,21 @@ +from typing import Annotated, Any + +import imagehash +from pydantic import PlainSerializer, PlainValidator + +__all__ = ("ImageHash",) + + +def _validate_image_hash(data: Any) -> imagehash.ImageHash: + if isinstance(data, imagehash.ImageHash): + return data + elif isinstance(data, str): + return imagehash.hex_to_hash(data) + raise ValueError(f"'{data}' is not a valid 'imagehash.ImageHash'") + + +ImageHash = Annotated[ + imagehash.ImageHash, + PlainValidator(_validate_image_hash), + PlainSerializer(lambda value: str(value), return_type=str), +] From a82050c472635396e0b57b0956af4e104b414540 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 09:39:26 -0700 Subject: [PATCH 074/158] Changed some variable names --- .../ext/automod/conditions/image_attachments_contain.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py index 7297a7a..71087b9 100644 --- a/commanderbot/ext/automod/conditions/image_attachments_contain.py +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -91,8 +91,8 @@ def _check_ocr_result(self, content: str) -> bool: async def _ocr(self, context: AutomodContext) -> Optional[AttachmentID]: # We need image attachments in context - attachments = await context.fetch_attachments_with_type(*IMAGE_MIME_TYPES) - if not attachments: + image_attachments = await context.fetch_attachments_with_type(*IMAGE_MIME_TYPES) + if not image_attachments: return # Create `lang` string from languages and scripts @@ -104,7 +104,7 @@ async def _ocr(self, context: AutomodContext) -> Optional[AttachmentID]: loop = asyncio.get_running_loop() tasks = [ loop.run_in_executor(pool, get_text, data, lang, attachment.id) - for (attachment, data, _) in attachments + for (attachment, data, _) in image_attachments ] # Check OCR results as they come in From ce69d56dfba541026900a7dcbf74ec790fb2cc6c Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 09:39:49 -0700 Subject: [PATCH 075/158] Fixed incorrect import --- commanderbot/ext/automod/buckets/flagged_image_attachments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/buckets/flagged_image_attachments.py index 9dc0d6f..7ebfeee 100644 --- a/commanderbot/ext/automod/buckets/flagged_image_attachments.py +++ b/commanderbot/ext/automod/buckets/flagged_image_attachments.py @@ -3,12 +3,12 @@ from discord import Attachment from discord.utils import utcnow -from imagehash import ImageHash from pydantic import BaseModel, Field from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.bucket import AutomodBucket from commanderbot.ext.automod.constants import IMAGE_MIME_TYPES +from commanderbot.lib.image_hash import ImageHash from commanderbot.lib.types import AttachmentID, Timedelta __all__ = ("FlaggedImageAttachments",) From 01039a3b25cd3d72fca92db8f815934e11d2a61f Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 09:40:14 -0700 Subject: [PATCH 076/158] Made rules hashable --- commanderbot/ext/automod/rule/rule.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index e5ab81c..6969c71 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -70,6 +70,14 @@ async def run(self, context: AutomodContext) -> bool: await self._apply_actions(context) return True + def __hash__(self) -> int: + return hash((self.__class__, self.name)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, AutomodRule): + return False + return self.name == other.name + # We need to rebuild the model because the conditions and actions have circular references AutomodRule.model_rebuild() From 60a648fb0c31a775c16b134b71a914150f354fe8 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 09:40:44 -0700 Subject: [PATCH 077/158] Fixed missing `MessageFrequency` trigger --- commanderbot/ext/automod/trigger/types.py | 1 + commanderbot/ext/automod/triggers/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/commanderbot/ext/automod/trigger/types.py b/commanderbot/ext/automod/trigger/types.py index d469975..d4fad3e 100644 --- a/commanderbot/ext/automod/trigger/types.py +++ b/commanderbot/ext/automod/trigger/types.py @@ -18,6 +18,7 @@ | triggers.MemberLostFlags | triggers.MessageDeleted | triggers.MessageEdited + | triggers.MessageFrequency | triggers.MessageSent | triggers.Message | triggers.Reaction diff --git a/commanderbot/ext/automod/triggers/__init__.py b/commanderbot/ext/automod/triggers/__init__.py index 9b201e4..7562394 100644 --- a/commanderbot/ext/automod/triggers/__init__.py +++ b/commanderbot/ext/automod/triggers/__init__.py @@ -10,6 +10,7 @@ from .message import * from .message_deleted import * from .message_edited import * +from .message_frequency import * from .message_sent import * from .reaction import * from .reaction_added import * From 982487ba6af68b5628034e45fba234e3fc3463e4 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 10:12:25 -0700 Subject: [PATCH 078/158] Fixed return type --- commanderbot/ext/automod/events/message_edited.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/events/message_edited.py b/commanderbot/ext/automod/events/message_edited.py index 244df58..960f2d9 100644 --- a/commanderbot/ext/automod/events/message_edited.py +++ b/commanderbot/ext/automod/events/message_edited.py @@ -61,6 +61,6 @@ def member(self) -> Member: @property @override - def user(self) -> Optional[User]: + def user(self) -> User: assert is_user(self.after.author) return self.after.author From cd124d580e1e78068fb5fa081956736f69d571a7 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 10:12:46 -0700 Subject: [PATCH 079/158] Removed `async` from a few functions --- .../ext/help_forum/help_forum_data.py | 20 +++++++++---------- .../ext/help_forum/help_forum_store.py | 20 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/commanderbot/ext/help_forum/help_forum_data.py b/commanderbot/ext/help_forum/help_forum_data.py index 40abc0a..c52ae87 100644 --- a/commanderbot/ext/help_forum/help_forum_data.py +++ b/commanderbot/ext/help_forum/help_forum_data.py @@ -149,15 +149,15 @@ class HelpForumData(BaseModel): exclude_if=lambda v: not v, ) - async def require_help_forum(self, guild: Guild, forum: ForumChannel) -> HelpForum: + def require_help_forum(self, guild: Guild, forum: ForumChannel) -> HelpForum: return self.guilds[guild.id].require_help_forum(forum) - async def get_help_forum( + def get_help_forum( self, guild: Guild, forum: ForumChannel ) -> Optional[HelpForum]: return self.guilds[guild.id].get_help_forum(forum) - async def register_forum_channel( + def register_forum_channel( self, guild: Guild, forum: ForumChannel, @@ -170,33 +170,33 @@ async def register_forum_channel( forum, unresolved_emoji, resolved_emoji, unresolved_tag, resolved_tag ) - async def deregister_forum_channel( + def deregister_forum_channel( self, guild: Guild, forum: ForumChannel ) -> HelpForum: return self.guilds[guild.id].deregister_forum_channel(forum) - async def increment_threads_created(self, help_forum: HelpForum): + def increment_threads_created(self, help_forum: HelpForum): help_forum.threads_created += 1 - async def increment_resolutions(self, help_forum: HelpForum): + def increment_resolutions(self, help_forum: HelpForum): help_forum.resolutions += 1 - async def modify_unresolved_emoji( + def modify_unresolved_emoji( self, guild: Guild, forum: ForumChannel, emoji: str ) -> HelpForum: return self.guilds[guild.id].modify_unresolved_emoji(forum, emoji) - async def modify_resolved_emoji( + def modify_resolved_emoji( self, guild: Guild, forum: ForumChannel, emoji: str ) -> HelpForum: return self.guilds[guild.id].modify_resolved_emoji(forum, emoji) - async def modify_unresolved_tag( + def modify_unresolved_tag( self, guild: Guild, forum: ForumChannel, tag: str ) -> tuple[HelpForum, ForumTag]: return self.guilds[guild.id].modify_unresolved_tag(forum, tag) - async def modify_resolved_tag( + def modify_resolved_tag( self, guild: Guild, forum: ForumChannel, tag: str ) -> tuple[HelpForum, ForumTag]: return self.guilds[guild.id].modify_resolved_tag(forum, tag) diff --git a/commanderbot/ext/help_forum/help_forum_store.py b/commanderbot/ext/help_forum/help_forum_store.py index 04850ea..e69baea 100644 --- a/commanderbot/ext/help_forum/help_forum_store.py +++ b/commanderbot/ext/help_forum/help_forum_store.py @@ -14,13 +14,13 @@ class HelpForumStore(CogStore): async def require_help_forum(self, guild: Guild, forum: ForumChannel) -> HelpForum: cache = await self.db.get_cache() - return await cache.require_help_forum(guild, forum) + return cache.require_help_forum(guild, forum) async def get_help_forum( self, guild: Guild, forum: ForumChannel ) -> Optional[HelpForum]: cache = await self.db.get_cache() - return await cache.get_help_forum(guild, forum) + return cache.get_help_forum(guild, forum) async def register_forum_channel( self, @@ -32,7 +32,7 @@ async def register_forum_channel( resolved_tag: str, ) -> HelpForum: cache = await self.db.get_cache() - help_forum = await cache.register_forum_channel( + help_forum = cache.register_forum_channel( guild, forum, unresolved_emoji, resolved_emoji, unresolved_tag, resolved_tag ) await self.db.commit() @@ -42,25 +42,25 @@ async def deregister_forum_channel( self, guild: Guild, forum: ForumChannel ) -> HelpForum: cache = await self.db.get_cache() - help_forum = await cache.deregister_forum_channel(guild, forum) + help_forum = cache.deregister_forum_channel(guild, forum) await self.db.commit() return help_forum async def increment_threads_created(self, help_forum: HelpForum): cache = await self.db.get_cache() - await cache.increment_threads_created(help_forum) + cache.increment_threads_created(help_forum) await self.db.commit() async def increment_resolutions(self, help_forum: HelpForum): cache = await self.db.get_cache() - await cache.increment_resolutions(help_forum) + cache.increment_resolutions(help_forum) await self.db.commit() async def modify_unresolved_emoji( self, guild: Guild, forum: ForumChannel, emoji: str ) -> HelpForum: cache = await self.db.get_cache() - help_forum = await cache.modify_unresolved_emoji(guild, forum, emoji) + help_forum = cache.modify_unresolved_emoji(guild, forum, emoji) await self.db.commit() return help_forum @@ -68,7 +68,7 @@ async def modify_resolved_emoji( self, guild: Guild, forum: ForumChannel, emoji: str ) -> HelpForum: cache = await self.db.get_cache() - help_forum = await cache.modify_resolved_emoji(guild, forum, emoji) + help_forum = cache.modify_resolved_emoji(guild, forum, emoji) await self.db.commit() return help_forum @@ -76,7 +76,7 @@ async def modify_unresolved_tag( self, guild: Guild, forum: ForumChannel, tag: str ) -> tuple[HelpForum, ForumTag]: cache = await self.db.get_cache() - result = await cache.modify_unresolved_tag(guild, forum, tag) + result = cache.modify_unresolved_tag(guild, forum, tag) await self.db.commit() return result @@ -84,6 +84,6 @@ async def modify_resolved_tag( self, guild: Guild, forum: ForumChannel, tag: str ) -> tuple[HelpForum, ForumTag]: cache = await self.db.get_cache() - result = await cache.modify_resolved_tag(guild, forum, tag) + result = cache.modify_resolved_tag(guild, forum, tag) await self.db.commit() return result From 8d9235eadb8afb955e7ada09abdda2f512d9f035 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 10:22:30 -0700 Subject: [PATCH 080/158] Removed `async` from a few functions --- commanderbot/ext/automod/bucket/bucket_base.py | 2 +- commanderbot/ext/automod/buckets/flagged_image_attachments.py | 4 ++-- commanderbot/ext/automod/buckets/message_history.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/commanderbot/ext/automod/bucket/bucket_base.py b/commanderbot/ext/automod/bucket/bucket_base.py index f2dc9ad..e21c321 100644 --- a/commanderbot/ext/automod/bucket/bucket_base.py +++ b/commanderbot/ext/automod/bucket/bucket_base.py @@ -35,6 +35,6 @@ async def add(self, context: AutomodContext): pass @abstractmethod - async def clear(self): + def clear(self): """Clear all data in the bucket.""" pass diff --git a/commanderbot/ext/automod/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/buckets/flagged_image_attachments.py index 7ebfeee..f0af431 100644 --- a/commanderbot/ext/automod/buckets/flagged_image_attachments.py +++ b/commanderbot/ext/automod/buckets/flagged_image_attachments.py @@ -34,7 +34,7 @@ class FlaggedImageAttachments(AutomodBucket): """ attachments: dict[AttachmentID, FlaggedImageAttachment] = Field( - default_factory=dict + default_factory=dict, exclude_if=lambda v: not v ) def _clean_up(self): @@ -84,5 +84,5 @@ async def add(self, context: AutomodContext): self.attachments[image_attachment.id] = flagged @override - async def clear(self): + def clear(self): self.attachments.clear() diff --git a/commanderbot/ext/automod/buckets/message_history.py b/commanderbot/ext/automod/buckets/message_history.py index 01693d2..cf255c4 100644 --- a/commanderbot/ext/automod/buckets/message_history.py +++ b/commanderbot/ext/automod/buckets/message_history.py @@ -177,5 +177,5 @@ async def add(self, context: AutomodContext): await context.state.dispatch_event(events.MessageFrequency(message, self)) @override - async def clear(self): + def clear(self): self._history.clear() From 403f4c4020b4db2a137aac4317b477405f7bab99 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 23:21:30 -0700 Subject: [PATCH 081/158] Fixed targets --- commanderbot/ext/automod/actions/add_roles_to_actor.py | 2 +- commanderbot/ext/automod/actions/add_roles_to_author.py | 2 +- commanderbot/ext/automod/actions/remove_roles_from_actor.py | 2 +- commanderbot/ext/automod/actions/remove_roles_from_author.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/commanderbot/ext/automod/actions/add_roles_to_actor.py b/commanderbot/ext/automod/actions/add_roles_to_actor.py index ea3317f..1c50988 100644 --- a/commanderbot/ext/automod/actions/add_roles_to_actor.py +++ b/commanderbot/ext/automod/actions/add_roles_to_actor.py @@ -18,5 +18,5 @@ class AddRolesToActor(AddRolesToTarget): @override def get_target(self, context: AutomodContext) -> Optional[Member]: - if (member := context.event.member) and is_member(member): + if (member := context.event.actor) and is_member(member): return member diff --git a/commanderbot/ext/automod/actions/add_roles_to_author.py b/commanderbot/ext/automod/actions/add_roles_to_author.py index c58be7c..bc751be 100644 --- a/commanderbot/ext/automod/actions/add_roles_to_author.py +++ b/commanderbot/ext/automod/actions/add_roles_to_author.py @@ -18,5 +18,5 @@ class AddRolesToAuthor(AddRolesToTarget): @override def get_target(self, context: AutomodContext) -> Optional[Member]: - if (member := context.event.member) and is_member(member): + if (member := context.event.author) and is_member(member): return member diff --git a/commanderbot/ext/automod/actions/remove_roles_from_actor.py b/commanderbot/ext/automod/actions/remove_roles_from_actor.py index b52924d..b76d79e 100644 --- a/commanderbot/ext/automod/actions/remove_roles_from_actor.py +++ b/commanderbot/ext/automod/actions/remove_roles_from_actor.py @@ -18,5 +18,5 @@ class RemoveRolesFromActor(RemoveRolesFromTarget): @override def get_target(self, context: AutomodContext) -> Optional[Member]: - if (member := context.event.member) and is_member(member): + if (member := context.event.actor) and is_member(member): return member diff --git a/commanderbot/ext/automod/actions/remove_roles_from_author.py b/commanderbot/ext/automod/actions/remove_roles_from_author.py index 2aece67..bb6d937 100644 --- a/commanderbot/ext/automod/actions/remove_roles_from_author.py +++ b/commanderbot/ext/automod/actions/remove_roles_from_author.py @@ -18,5 +18,5 @@ class RemoveRolesFromAuthor(RemoveRolesFromTarget): @override def get_target(self, context: AutomodContext) -> Optional[Member]: - if (member := context.event.member) and is_member(member): + if (member := context.event.author) and is_member(member): return member From e7e7a5729b53fc7cef76a923788739b8d9f5cdf6 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 23:22:02 -0700 Subject: [PATCH 082/158] Fixed an issue where `FlagsGuard` couldn't be validated --- commanderbot/ext/automod/guards/flags.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/commanderbot/ext/automod/guards/flags.py b/commanderbot/ext/automod/guards/flags.py index 7e3d62e..66fccfa 100644 --- a/commanderbot/ext/automod/guards/flags.py +++ b/commanderbot/ext/automod/guards/flags.py @@ -18,7 +18,7 @@ class FlagsGuard(BaseModel): Checks whether a `discord.User` or `discord.Member` has certain flags. """ - flags: set[FlagNames] = Field(default_factory=set) + flags: set[FlagNames] = Field(default_factory=set, min_length=1) def _get_flags(self, actor: Actor) -> Generator[tuple[str, bool]]: if is_member(actor): @@ -87,6 +87,7 @@ def lost_all(self, before: Actor, after: Actor) -> bool: def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: + default_schema = handler(source_type) from_list_schema = core_schema.chain_schema( [ core_schema.set_schema( @@ -104,7 +105,11 @@ def __get_pydantic_core_schema__( return core_schema.json_or_python_schema( json_schema=from_list_schema, python_schema=core_schema.union_schema( - [core_schema.is_instance_schema(cls), from_list_schema] + [ + core_schema.is_instance_schema(cls), + from_list_schema, + default_schema, + ] ), serialization=core_schema.plain_serializer_function_ser_schema( lambda guard: list(guard.flags) From 576637e8e3f23949fad363f2e1113eac47150dfd Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 23:22:21 -0700 Subject: [PATCH 083/158] Renamed a few things --- .../ext/automod/triggers/message_frequency.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/commanderbot/ext/automod/triggers/message_frequency.py b/commanderbot/ext/automod/triggers/message_frequency.py index 9ca73a3..c1ee6bc 100644 --- a/commanderbot/ext/automod/triggers/message_frequency.py +++ b/commanderbot/ext/automod/triggers/message_frequency.py @@ -40,13 +40,12 @@ async def ignore(self, context: AutomodContext) -> bool: if bucket.name != self.bucket: return True - # Use the bucket to build a record out of our timeframe - + # Build the message history out of our timeframe message = context.event.message since = message.created_at - self.timeframe - record = bucket.build_message_history_since(message.author, since) + message_history = bucket.build_message_history_since(message.author, since) - # Ignore if the record does not meet our thresholds - enough_messages = self.message_count.includes(record.message_count) - enough_channels = self.channel_count.includes(record.channel_count) + # Ignore if the message history does not meet our thresholds + enough_messages = self.message_count.includes(message_history.message_count) + enough_channels = self.channel_count.includes(message_history.channel_count) return not (enough_messages and enough_channels) From 72d54ea9e35717293294798674031958af91905d Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 23:23:01 -0700 Subject: [PATCH 084/158] Added `AutomodBucketRef` to make it easier to resolve buckets --- .../ext/automod/actions/add_to_bucket.py | 7 +- commanderbot/ext/automod/bucket/__init__.py | 1 + commanderbot/ext/automod/bucket/bucket_ref.py | 74 +++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 commanderbot/ext/automod/bucket/bucket_ref.py diff --git a/commanderbot/ext/automod/actions/add_to_bucket.py b/commanderbot/ext/automod/actions/add_to_bucket.py index 12c01f6..a03c8bb 100644 --- a/commanderbot/ext/automod/actions/add_to_bucket.py +++ b/commanderbot/ext/automod/actions/add_to_bucket.py @@ -2,6 +2,7 @@ from commanderbot.ext.automod.action import AutomodAction from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.bucket import AutomodBucketRef __all__ = ("AddToBucket",) @@ -13,14 +14,12 @@ class AddToBucket(AutomodAction): type: Literal["add_to_bucket"] - bucket: str + bucket: AutomodBucketRef """The bucket to add to.""" @override async def apply(self, context: AutomodContext): # Get the bucket and add context data to it - guild = context.state.guild - store = context.state.store - bucket = await store.require_bucket(guild, self.bucket) + bucket = await self.bucket.resolve(context) if not bucket.disabled: await bucket.add(context) diff --git a/commanderbot/ext/automod/bucket/__init__.py b/commanderbot/ext/automod/bucket/__init__.py index 3cdae7f..1f5ab66 100644 --- a/commanderbot/ext/automod/bucket/__init__.py +++ b/commanderbot/ext/automod/bucket/__init__.py @@ -1,2 +1,3 @@ from .bucket_base import * +from .bucket_ref import * from .types import * diff --git a/commanderbot/ext/automod/bucket/bucket_ref.py b/commanderbot/ext/automod/bucket/bucket_ref.py new file mode 100644 index 0000000..d116449 --- /dev/null +++ b/commanderbot/ext/automod/bucket/bucket_ref.py @@ -0,0 +1,74 @@ +from typing import Any, Optional, overload + +from pydantic import BaseModel, Field, GetCoreSchemaHandler +from pydantic_core import core_schema + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.bucket import AutomodBucket + +__all__ = ("AutomodBucketRef",) + + +class AutomodBucketRef(BaseModel): + """ + A reference to a bucket, by name. + """ + + name: str = Field(min_length=1) + """The name of the bucket.""" + + @overload + async def resolve(self, context: AutomodContext) -> AutomodBucket: + """ + Resolve the bucket. Will throw an expection if it's not found. + """ + ... + + @overload + async def resolve[BucketType = AutomodBucket]( + self, context: AutomodContext, bucket_type: type[BucketType] + ) -> BucketType: + """ + Resolve the bucket and require it to have a specific type. Will throw an expection if it's not found. + """ + ... + + async def resolve[BucketType = AutomodBucket]( + self, context: AutomodContext, bucket_type: Optional[type[BucketType]] = None + ) -> AutomodBucket | BucketType: + # Get store from context + guild = context.state.guild + store = context.state.store + + # Resolve the bucket + if bucket_type is not None: + return await store.require_bucket_with_type(guild, self.name, bucket_type) + return await store.require_bucket(guild, self.name) + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + default_schema = handler(source_type) + from_str_schema = core_schema.chain_schema( + [ + core_schema.str_schema(min_length=1), + core_schema.no_info_plain_validator_function( + lambda data: cls(name=data) + ), + ] + ) + + return core_schema.json_or_python_schema( + json_schema=from_str_schema, + python_schema=core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + from_str_schema, + default_schema, + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema( + lambda ref: ref.name + ), + ) From f8ef6f4906e649ea6061ee6ffc79774692d54b5a Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 4 Aug 2026 23:25:21 -0700 Subject: [PATCH 085/158] Added a way to bulk delete messages from an actor or author --- commanderbot/ext/automod/action/types.py | 2 + commanderbot/ext/automod/actions/__init__.py | 2 + .../ext/automod/actions/abc/__init__.py | 1 + .../actions/abc/delete_target_messages.py | 58 +++++++++++++++++++ .../automod/actions/delete_actor_messages.py | 22 +++++++ .../automod/actions/delete_author_messages.py | 22 +++++++ commanderbot/lib/constants.py | 5 ++ 7 files changed, 112 insertions(+) create mode 100644 commanderbot/ext/automod/actions/abc/delete_target_messages.py create mode 100644 commanderbot/ext/automod/actions/delete_actor_messages.py create mode 100644 commanderbot/ext/automod/actions/delete_author_messages.py diff --git a/commanderbot/ext/automod/action/types.py b/commanderbot/ext/automod/action/types.py index 16b4ebb..f2fca7f 100644 --- a/commanderbot/ext/automod/action/types.py +++ b/commanderbot/ext/automod/action/types.py @@ -12,6 +12,8 @@ | actions.AddRolesToAuthor | actions.AddToBucket | actions.AddUsersToThread + | actions.DeleteActorMessages + | actions.DeleteAuthorMessages | actions.DeleteMessage | actions.DMMember | actions.EditThread diff --git a/commanderbot/ext/automod/actions/__init__.py b/commanderbot/ext/automod/actions/__init__.py index 88ec40f..78fdb75 100644 --- a/commanderbot/ext/automod/actions/__init__.py +++ b/commanderbot/ext/automod/actions/__init__.py @@ -3,6 +3,8 @@ from .add_roles_to_author import * from .add_to_bucket import * from .add_users_to_thread import * +from .delete_actor_messages import * +from .delete_author_messages import * from .delete_message import * from .dm_member import * from .edit_thread import * diff --git a/commanderbot/ext/automod/actions/abc/__init__.py b/commanderbot/ext/automod/actions/abc/__init__.py index 897262c..773edd9 100644 --- a/commanderbot/ext/automod/actions/abc/__init__.py +++ b/commanderbot/ext/automod/actions/abc/__init__.py @@ -1,2 +1,3 @@ from .add_roles_to_target import * +from .delete_target_messages import * from .remove_roles_from_target import * diff --git a/commanderbot/ext/automod/actions/abc/delete_target_messages.py b/commanderbot/ext/automod/actions/abc/delete_target_messages.py new file mode 100644 index 0000000..764639c --- /dev/null +++ b/commanderbot/ext/automod/actions/abc/delete_target_messages.py @@ -0,0 +1,58 @@ +from abc import abstractmethod +from itertools import islice +from typing import Optional, override + +from discord import Object, User +from discord.utils import utcnow + +from commanderbot.ext.automod import buckets +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.bucket import AutomodBucketRef +from commanderbot.lib.constants import ( + BULK_DELETE_MAX_MESSAGE_AGE, + BULK_DELETE_MAX_MESSAGES, +) +from commanderbot.lib.predicates import is_messagable_guild_channel, is_thread +from commanderbot.lib.types import Timedelta + +__all__ = ("DeleteTargetMessages",) + + +class DeleteTargetMessages(AutomodAction): + bucket: AutomodBucketRef + """The bucket being used to track message history.""" + + previous_history: Timedelta + """ + How much previous message history to delete. Note that to Discord's limitations, you can't delete + more than 100 messages in each channel and the messages can't be more than 14 days old. + """ + + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[User]: + pass + + @override + async def apply(self, context: AutomodContext): + if target := self.get_target(context): + bucket = await self.bucket.resolve(context, buckets.MessageHistory) + + since = utcnow() - min(self.previous_history, BULK_DELETE_MAX_MESSAGE_AGE) + message_history = bucket.build_message_history_since(target, since) + + for channel_record in message_history.yield_channels(): + channel_id = channel_record.channel_id + channel = context.bot.get_channel(channel_id) + assert is_messagable_guild_channel(channel) or is_thread(channel) + + messages = islice( + channel_record.yield_messages(), + BULK_DELETE_MAX_MESSAGES, + ) + to_delete = (Object(id=m.message_id) for m in messages) + + try: + await channel.delete_messages(to_delete) + except: + pass diff --git a/commanderbot/ext/automod/actions/delete_actor_messages.py b/commanderbot/ext/automod/actions/delete_actor_messages.py new file mode 100644 index 0000000..9fb1a9d --- /dev/null +++ b/commanderbot/ext/automod/actions/delete_actor_messages.py @@ -0,0 +1,22 @@ +from typing import Literal, Optional, override + +from discord import User + +from commanderbot.ext.automod.actions.abc import DeleteTargetMessages +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.predicates import is_user + +__all__ = ("DeleteActorMessages",) + + +class DeleteActorMessages(DeleteTargetMessages): + """ + Delete messages from the actor in context that are being tracked by a bucket. + """ + + type: Literal["delete_actor_messages"] + + @override + def get_target(self, context: AutomodContext) -> Optional[User]: + if (member := context.event.actor) and is_user(member): + return member diff --git a/commanderbot/ext/automod/actions/delete_author_messages.py b/commanderbot/ext/automod/actions/delete_author_messages.py new file mode 100644 index 0000000..0c5640b --- /dev/null +++ b/commanderbot/ext/automod/actions/delete_author_messages.py @@ -0,0 +1,22 @@ +from typing import Literal, Optional, override + +from discord import User + +from commanderbot.ext.automod.actions.abc import DeleteTargetMessages +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.lib.predicates import is_user + +__all__ = ("DeleteAuthorMessages",) + + +class DeleteAuthorMessages(DeleteTargetMessages): + """ + Delete messages from the author in context that are being tracked by a bucket. + """ + + type: Literal["delete_author_messages"] + + @override + def get_target(self, context: AutomodContext) -> Optional[User]: + if (member := context.event.author) and is_user(member): + return member diff --git a/commanderbot/lib/constants.py b/commanderbot/lib/constants.py index 0ff3654..364e3b4 100644 --- a/commanderbot/lib/constants.py +++ b/commanderbot/lib/constants.py @@ -1,5 +1,6 @@ import ctypes import sys +from datetime import timedelta from importlib.metadata import version __all__ = ( @@ -9,6 +10,8 @@ "SUPPORTS_OCR", "USER_AGENT", "MAX_MESSAGE_LENGTH", + "BULK_DELETE_MAX_MESSAGES", + "BULK_DELETE_MAX_MESSAGE_AGE", "MAX_EMBED_TITLE_LENGTH", "MAX_EMBED_DESCRIPTION_LENGTH", "MAX_MODAL_TITLE_LENGTH", @@ -36,6 +39,8 @@ ) MAX_MESSAGE_LENGTH: int = 2000 +BULK_DELETE_MAX_MESSAGES: int = 100 +BULK_DELETE_MAX_MESSAGE_AGE: timedelta = timedelta(days=14) MAX_EMBED_TITLE_LENGTH: int = 256 MAX_EMBED_DESCRIPTION_LENGTH: int = 4096 From 2a300cf62c676f4dd38d1d62f0e6e4451700f403 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 5 Aug 2026 00:09:28 -0700 Subject: [PATCH 086/158] Changed some variable names --- commanderbot/ext/automod/buckets/flagged_image_attachments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commanderbot/ext/automod/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/buckets/flagged_image_attachments.py index f0af431..b4eb1cb 100644 --- a/commanderbot/ext/automod/buckets/flagged_image_attachments.py +++ b/commanderbot/ext/automod/buckets/flagged_image_attachments.py @@ -46,9 +46,9 @@ def _clean_up(self): def is_flagged(self, image_attachment: Attachment) -> bool: return image_attachment.id in self.attachments - def is_flagged_phash(self, phash: ImageHash, max_hamming_distance: int = 0) -> bool: + def is_flagged_phash(self, phash: ImageHash, threshold: int = 0) -> bool: for flagged_attachment in self.attachments.values(): - if flagged_attachment.phash - phash <= max_hamming_distance: + if flagged_attachment.phash - phash <= threshold: return True return False From d1c6a9a2e30646b83b2fa3db9da2011be520a48a Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 5 Aug 2026 00:09:52 -0700 Subject: [PATCH 087/158] Updated comment --- .../ext/automod/conditions/image_attachments_contain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py index 71087b9..b950b09 100644 --- a/commanderbot/ext/automod/conditions/image_attachments_contain.py +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -26,7 +26,7 @@ class ImageAttachmentsContain(AutomodCondition): """ - OCR image attachments and check if they contain a number of substrings. + OCR image attachments in context and check if they contain a number of substrings. """ type: Literal["image_attachments_contain"] From a25ba97d3401c942c801398b79da972f32659d0b Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 5 Aug 2026 00:10:39 -0700 Subject: [PATCH 088/158] Added a condition that checks if any image attachments have already been flagged --- commanderbot/ext/automod/condition/types.py | 1 + .../ext/automod/conditions/__init__.py | 1 + .../has_flagged_image_attachments.py | 51 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 commanderbot/ext/automod/conditions/has_flagged_image_attachments.py diff --git a/commanderbot/ext/automod/condition/types.py b/commanderbot/ext/automod/condition/types.py index 42f83b8..d27b792 100644 --- a/commanderbot/ext/automod/condition/types.py +++ b/commanderbot/ext/automod/condition/types.py @@ -25,6 +25,7 @@ | conditions.AuthorIsNotSelf | conditions.AuthorMemberFor | conditions.AuthorRoles + | conditions.HasFlaggedImageAttachments | conditions.ImageAttachmentsContain | conditions.Inverted | conditions.MessageContentContains diff --git a/commanderbot/ext/automod/conditions/__init__.py b/commanderbot/ext/automod/conditions/__init__.py index 7ede4eb..3fcc8f3 100644 --- a/commanderbot/ext/automod/conditions/__init__.py +++ b/commanderbot/ext/automod/conditions/__init__.py @@ -16,6 +16,7 @@ from .author_is_self import * from .author_member_for import * from .author_roles import * +from .has_flagged_image_attachments import * from .image_attachments_contain import * from .inverted import * from .message_content_contains import * diff --git a/commanderbot/ext/automod/conditions/has_flagged_image_attachments.py b/commanderbot/ext/automod/conditions/has_flagged_image_attachments.py new file mode 100644 index 0000000..e0d8531 --- /dev/null +++ b/commanderbot/ext/automod/conditions/has_flagged_image_attachments.py @@ -0,0 +1,51 @@ +from typing import Literal, override + +from pydantic import Field + +from commanderbot.ext.automod import buckets +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.bucket import AutomodBucketRef +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.constants import IMAGE_MIME_TYPES + +__all__ = ("HasFlaggedImageAttachments",) + + +class HasFlaggedImageAttachments(AutomodCondition): + """ + Check if any image attachments in context have already been flagged. + """ + + type: Literal["has_flagged_image_attachments"] + + bucket: AutomodBucketRef + """The bucket being used to track flagged image attachments.""" + + threshold: int = Field(default=0, ge=0) + """ + Hamming distance threshold. + + - `0-4`: Identical or extremely similar. + - `5-10`: Very similar. + - `11-20`: Potentially a variation or shares similar structures. + - `21+`: Completely different images. + """ + + @override + async def check(self, context: AutomodContext) -> bool: + # We need to have image attachments in context + image_attachments = await context.fetch_attachments_with_type(*IMAGE_MIME_TYPES) + if not image_attachments: + return False + + # Check if any image attachments are in the bucket + bucket = await self.bucket.resolve(context, buckets.FlaggedImageAttachments) + for attachment, _, phash in image_attachments: + assert phash is not None + if bucket.is_flagged(attachment) or bucket.is_flagged_phash( + phash, self.threshold + ): + return True + + # If we got this far, none of the image attachments have been flagged + return False From 6e20251a9ecbfbc9de518d9ae1fe655cdd66f526 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 5 Aug 2026 00:29:02 -0700 Subject: [PATCH 089/158] Made `clear()` async again --- commanderbot/ext/automod/bucket/bucket_base.py | 2 +- commanderbot/ext/automod/buckets/flagged_image_attachments.py | 2 +- commanderbot/ext/automod/buckets/message_history.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/commanderbot/ext/automod/bucket/bucket_base.py b/commanderbot/ext/automod/bucket/bucket_base.py index e21c321..f2dc9ad 100644 --- a/commanderbot/ext/automod/bucket/bucket_base.py +++ b/commanderbot/ext/automod/bucket/bucket_base.py @@ -35,6 +35,6 @@ async def add(self, context: AutomodContext): pass @abstractmethod - def clear(self): + async def clear(self): """Clear all data in the bucket.""" pass diff --git a/commanderbot/ext/automod/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/buckets/flagged_image_attachments.py index b4eb1cb..4219430 100644 --- a/commanderbot/ext/automod/buckets/flagged_image_attachments.py +++ b/commanderbot/ext/automod/buckets/flagged_image_attachments.py @@ -84,5 +84,5 @@ async def add(self, context: AutomodContext): self.attachments[image_attachment.id] = flagged @override - def clear(self): + async def clear(self): self.attachments.clear() diff --git a/commanderbot/ext/automod/buckets/message_history.py b/commanderbot/ext/automod/buckets/message_history.py index cf255c4..01693d2 100644 --- a/commanderbot/ext/automod/buckets/message_history.py +++ b/commanderbot/ext/automod/buckets/message_history.py @@ -177,5 +177,5 @@ async def add(self, context: AutomodContext): await context.state.dispatch_event(events.MessageFrequency(message, self)) @override - def clear(self): + async def clear(self): self._history.clear() From ea3700db1e12ae320aa557588a8346e7f2023a06 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 5 Aug 2026 00:29:10 -0700 Subject: [PATCH 090/158] Fixed comment --- commanderbot/ext/automod/actions/abc/delete_target_messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/actions/abc/delete_target_messages.py b/commanderbot/ext/automod/actions/abc/delete_target_messages.py index 764639c..05c33b5 100644 --- a/commanderbot/ext/automod/actions/abc/delete_target_messages.py +++ b/commanderbot/ext/automod/actions/abc/delete_target_messages.py @@ -25,7 +25,7 @@ class DeleteTargetMessages(AutomodAction): previous_history: Timedelta """ - How much previous message history to delete. Note that to Discord's limitations, you can't delete + How much previous message history to delete. Note that due to Discord's limitations, you can't delete more than 100 messages in each channel and the messages can't be more than 14 days old. """ From 9f5421cb50a69e2a5b958196cde83986576c8a32 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 5 Aug 2026 23:42:39 -0700 Subject: [PATCH 091/158] Fixed issues found by Ruff --- commanderbot/ext/allay/allay_cog.py | 4 +- commanderbot/ext/faq/faq_cog.py | 5 +- commanderbot/ext/faq/faq_data.py | 9 +- commanderbot/ext/faq/faq_guild_state.py | 19 +-- commanderbot/ext/faq/faq_json_store.py | 3 +- commanderbot/ext/faq/faq_store.py | 3 +- commanderbot/ext/feeds/feeds_data.py | 3 +- commanderbot/ext/feeds/feeds_guild_state.py | 4 +- commanderbot/ext/feeds/feeds_json_store.py | 3 +- commanderbot/ext/feeds/feeds_state.py | 4 +- commanderbot/ext/feeds/feeds_store.py | 3 +- .../ext/feeds/providers/feed_provider_base.py | 2 +- .../providers/minecraft_bedrock_updates.py | 2 +- .../providers/minecraft_java_jar_updates.py | 2 +- .../feeds/providers/minecraft_java_updates.py | 2 +- commanderbot/ext/friday/friday_data.py | 5 +- commanderbot/ext/friday/friday_guild_state.py | 4 +- commanderbot/ext/friday/friday_json_store.py | 3 +- commanderbot/ext/friday/friday_store.py | 3 +- .../ext/help_forum/help_forum_data.py | 2 +- .../ext/help_forum/help_forum_guild_state.py | 15 +- commanderbot/ext/invite/invite_data.py | 9 +- commanderbot/ext/invite/invite_exceptions.py | 2 +- commanderbot/ext/invite/invite_guild_state.py | 8 +- commanderbot/ext/invite/invite_json_store.py | 3 +- commanderbot/ext/invite/invite_store.py | 3 +- commanderbot/ext/jira/jira_cog.py | 4 +- commanderbot/ext/manifest/manifest_cog.py | 11 +- commanderbot/ext/manifest/manifest_data.py | 2 +- commanderbot/ext/mccq/mccq_cog.py | 4 +- commanderbot/ext/mccq/mccq_manager.py | 8 +- commanderbot/ext/moderation/moderation_cog.py | 1 - commanderbot/ext/roles/roles_cog.py | 2 +- commanderbot/ext/roles/roles_data.py | 9 +- commanderbot/ext/roles/roles_guild_state.py | 22 +-- commanderbot/ext/roles/roles_json_store.py | 3 +- commanderbot/ext/roles/roles_options.py | 2 +- commanderbot/ext/roles/roles_store.py | 3 +- .../ext/stacktracer/stacktracer_cog.py | 2 +- commanderbot/ext/sudo/sudo_cog.py | 6 +- commanderbot/ext/sudo/sudo_exceptions.py | 2 - commanderbot/ext/vote/vote_cog.py | 5 +- commanderbot/ext/xkcd/xkcd_client.py | 7 +- commanderbot/ext/xkcd/xkcd_cog.py | 16 +-- pyproject.toml | 24 +++- uv.lock | 131 ++++-------------- 46 files changed, 170 insertions(+), 219 deletions(-) diff --git a/commanderbot/ext/allay/allay_cog.py b/commanderbot/ext/allay/allay_cog.py index 6c519e0..1f8be6b 100644 --- a/commanderbot/ext/allay/allay_cog.py +++ b/commanderbot/ext/allay/allay_cog.py @@ -12,9 +12,9 @@ Example usage: .allay [indent] - `​`​` + `\u200b`\u200b` text - `​`​` + `\u200b`\u200b` Both plain-text, inline code blocks, and normal code blocks are all supported. There should always be a newline in between the command (and optional indent, if supplied), and the text. """ diff --git a/commanderbot/ext/faq/faq_cog.py b/commanderbot/ext/faq/faq_cog.py index 93ef6c4..5ecca49 100644 --- a/commanderbot/ext/faq/faq_cog.py +++ b/commanderbot/ext/faq/faq_cog.py @@ -116,11 +116,12 @@ async def faq_and_alias_autocomplete( ) # Create a list of autocomplete choices and return them - choices: list[Choice] = [] + choices: list[Choice[str]] = [] for item in items: if isinstance(item, tuple): + alias, entry = item choices.append( - Choice(name=f"💬 {item[0]} → {item[1].key}", value=item[0]) + Choice(name=f"💬 {alias} → {entry.key}", value=alias) ) else: choices.append(Choice(name=f"💬 {item.key}", value=item.key)) diff --git a/commanderbot/ext/faq/faq_data.py b/commanderbot/ext/faq/faq_data.py index c456663..3dcb82e 100644 --- a/commanderbot/ext/faq/faq_data.py +++ b/commanderbot/ext/faq/faq_data.py @@ -1,9 +1,10 @@ import re from collections import defaultdict +from collections.abc import AsyncIterable, Iterable from dataclasses import dataclass, field from datetime import datetime from itertools import chain, islice -from typing import Any, AsyncIterable, Iterable, Optional, Self +from typing import Any, Optional, Self from discord import Guild from discord.utils import utcnow @@ -226,13 +227,13 @@ def _rebuild_category_mappings(self): self.uncategorized_faq_entries.append(entry) def _is_faq_key_available(self, key: str) -> bool: - return key not in self.faq_entries.keys() + return key not in self.faq_entries def _is_faq_alias_available(self, alias: str) -> bool: - return alias not in self.faq_entries_by_alias.keys() + return alias not in self.faq_entries_by_alias def _is_category_key_available(self, key: str) -> bool: - return key not in self.categories.keys() + return key not in self.categories def require_faq(self, key: str) -> FaqEntryData: if entry := self.faq_entries.get(key): diff --git a/commanderbot/ext/faq/faq_guild_state.py b/commanderbot/ext/faq/faq_guild_state.py index 5af5b46..5f2f8e3 100644 --- a/commanderbot/ext/faq/faq_guild_state.py +++ b/commanderbot/ext/faq/faq_guild_state.py @@ -1,6 +1,7 @@ import re +from collections.abc import Iterable from dataclasses import dataclass -from typing import Iterable, Optional +from typing import Optional from discord import AllowedMentions, Embed, Interaction, Message, TextStyle from discord.ui import TextInput @@ -111,7 +112,7 @@ async def list_faqs(self, interaction: Interaction): # Add uncategorized faqs to lines if uncategorized: - lines.append(f"**Uncategorized**") + lines.append("**Uncategorized**") lines.append(", ".join((f"`{e.key}`" for e in uncategorized))) total_faqs += len(uncategorized) @@ -151,9 +152,9 @@ async def remove_faq(self, interaction: Interaction, faq: str): await interaction.followup.send( content=f"Removed the FAQ `{entry.key}`" ) - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise case _: # If the answer was no, send a response await interaction.followup.send(f"Did not remove the FAQ `{entry.key}`") @@ -167,13 +168,13 @@ async def show_faq_details(self, interaction: Interaction, faq: str): (f"> {line}" for line in entry.content.split("\n")) ) formatted_aliases: str = ( - f", ".join((f"`{alias}`" for alias in entry.sorted_aliases)) or "**None!**" + ", ".join((f"`{alias}`" for alias in entry.sorted_aliases)) or "**None!**" ) formatted_category_key: str = ( - f"`{entry.category_key}`" if entry.category_key else "**None!**" + "`{entry.category_key}`" if entry.category_key else "**None!**" ) formatted_tags: str = ( - f", ".join((f"`{tag}`" for tag in entry.sorted_tags)) or "**None!**" + ", ".join((f"`{tag}`" for tag in entry.sorted_tags)) or "**None!**" ) # Create faq details embed @@ -249,9 +250,9 @@ async def remove_category(self, interaction: Interaction, key: str): await interaction.followup.send( content=f"Removed the category `{category.key}`" ) - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise case _: # If the answer was no, send a response await interaction.followup.send( diff --git a/commanderbot/ext/faq/faq_json_store.py b/commanderbot/ext/faq/faq_json_store.py index e56849c..f83a03c 100644 --- a/commanderbot/ext/faq/faq_json_store.py +++ b/commanderbot/ext/faq/faq_json_store.py @@ -1,6 +1,7 @@ import re +from collections.abc import AsyncIterable from dataclasses import dataclass -from typing import AsyncIterable, Optional +from typing import Optional from discord import Guild diff --git a/commanderbot/ext/faq/faq_store.py b/commanderbot/ext/faq/faq_store.py index b7144dd..b9bb127 100644 --- a/commanderbot/ext/faq/faq_store.py +++ b/commanderbot/ext/faq/faq_store.py @@ -1,6 +1,7 @@ import re +from collections.abc import AsyncIterable from datetime import datetime -from typing import AsyncIterable, Optional, Protocol +from typing import Optional, Protocol from discord import Guild diff --git a/commanderbot/ext/feeds/feeds_data.py b/commanderbot/ext/feeds/feeds_data.py index e8e1b6b..f221a85 100644 --- a/commanderbot/ext/feeds/feeds_data.py +++ b/commanderbot/ext/feeds/feeds_data.py @@ -1,6 +1,7 @@ +from collections.abc import AsyncIterable from dataclasses import dataclass, field from datetime import datetime -from typing import Any, AsyncIterable, Optional, Self +from typing import Any, Optional, Self from discord import ChannelType from discord.utils import utcnow diff --git a/commanderbot/ext/feeds/feeds_guild_state.py b/commanderbot/ext/feeds/feeds_guild_state.py index ad3a38a..a9c0696 100644 --- a/commanderbot/ext/feeds/feeds_guild_state.py +++ b/commanderbot/ext/feeds/feeds_guild_state.py @@ -92,9 +92,9 @@ async def unsubscribe_from_feed( await interaction.followup.send( f"<#{subscription.channel_id}> has unsubscribed from the feed `{feed.value}`" ) - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise case _: # If the answer was no, send a response await interaction.followup.send( diff --git a/commanderbot/ext/feeds/feeds_json_store.py b/commanderbot/ext/feeds/feeds_json_store.py index e9121e6..346d777 100644 --- a/commanderbot/ext/feeds/feeds_json_store.py +++ b/commanderbot/ext/feeds/feeds_json_store.py @@ -1,5 +1,6 @@ +from collections.abc import AsyncIterable from dataclasses import dataclass -from typing import AsyncIterable, Optional +from typing import Optional from discord import ChannelType diff --git a/commanderbot/ext/feeds/feeds_state.py b/commanderbot/ext/feeds/feeds_state.py index 3115812..9ef4350 100644 --- a/commanderbot/ext/feeds/feeds_state.py +++ b/commanderbot/ext/feeds/feeds_state.py @@ -357,7 +357,9 @@ async def feed_provider_status( if dt := provider.next_request_date: formatted_next_update = format_dt(dt, "R") - formatted_prev_status_code = f"`{provider.prev_status_code}`" or "**?**" + formatted_prev_status_code: str = "**?**" + if status_code := provider.prev_status_code: + formatted_prev_status_code = f"`{status_code}`" # Create feed provider status embed embed = Embed(title=feed_provider.value, color=0x00ACED) diff --git a/commanderbot/ext/feeds/feeds_store.py b/commanderbot/ext/feeds/feeds_store.py index 4742f8a..d82822f 100644 --- a/commanderbot/ext/feeds/feeds_store.py +++ b/commanderbot/ext/feeds/feeds_store.py @@ -1,5 +1,6 @@ +from collections.abc import AsyncIterable from datetime import datetime -from typing import AsyncIterable, Optional, Protocol +from typing import Optional, Protocol from discord import ChannelType diff --git a/commanderbot/ext/feeds/providers/feed_provider_base.py b/commanderbot/ext/feeds/providers/feed_provider_base.py index b5a6bb8..401a20a 100644 --- a/commanderbot/ext/feeds/providers/feed_provider_base.py +++ b/commanderbot/ext/feeds/providers/feed_provider_base.py @@ -8,8 +8,8 @@ from commanderbot.lib import FromDataMixin __all__ = ( - "FeedProviderOptionsBase", "FeedProviderBase", + "FeedProviderOptionsBase", ) diff --git a/commanderbot/ext/feeds/providers/minecraft_bedrock_updates.py b/commanderbot/ext/feeds/providers/minecraft_bedrock_updates.py index d4ac971..358b966 100644 --- a/commanderbot/ext/feeds/providers/minecraft_bedrock_updates.py +++ b/commanderbot/ext/feeds/providers/minecraft_bedrock_updates.py @@ -15,8 +15,8 @@ __all__ = ( "MinecraftBedrockUpdateInfo", - "MinecraftBedrockUpdatesOptions", "MinecraftBedrockUpdates", + "MinecraftBedrockUpdatesOptions", ) VERSION_PATTERN = re.compile(r"\d+\.\d+(?:\.\d+)?(?:\.\d+)?") diff --git a/commanderbot/ext/feeds/providers/minecraft_java_jar_updates.py b/commanderbot/ext/feeds/providers/minecraft_java_jar_updates.py index a6667cb..d217a77 100644 --- a/commanderbot/ext/feeds/providers/minecraft_java_jar_updates.py +++ b/commanderbot/ext/feeds/providers/minecraft_java_jar_updates.py @@ -14,8 +14,8 @@ __all__ = ( "MinecraftJavaJarUpdateInfo", - "MinecraftJavaJarUpdatesOptions", "MinecraftJavaJarUpdates", + "MinecraftJavaJarUpdatesOptions", ) diff --git a/commanderbot/ext/feeds/providers/minecraft_java_updates.py b/commanderbot/ext/feeds/providers/minecraft_java_updates.py index 29ce634..c7caffa 100644 --- a/commanderbot/ext/feeds/providers/minecraft_java_updates.py +++ b/commanderbot/ext/feeds/providers/minecraft_java_updates.py @@ -14,8 +14,8 @@ __all__ = ( "MinecraftJavaUpdateInfo", - "MinecraftJavaUpdatesOptions", "MinecraftJavaUpdates", + "MinecraftJavaUpdatesOptions", ) diff --git a/commanderbot/ext/friday/friday_data.py b/commanderbot/ext/friday/friday_data.py index a7dd9bd..ddf3641 100644 --- a/commanderbot/ext/friday/friday_data.py +++ b/commanderbot/ext/friday/friday_data.py @@ -1,10 +1,11 @@ import random import re from collections import defaultdict +from collections.abc import AsyncIterable, Iterable from dataclasses import dataclass, field from datetime import datetime, timedelta from itertools import islice -from typing import Any, AsyncIterable, Iterable, Optional, Self +from typing import Any, Optional, Self from discord import Guild from discord.utils import utcnow @@ -166,7 +167,7 @@ def to_json(self) -> Any: ) def _is_rule_name_available(self, name: str) -> bool: - return name not in self.rules.keys() + return name not in self.rules def is_channel_registered(self, channel_id: ChannelID) -> bool: return channel_id in self.channels diff --git a/commanderbot/ext/friday/friday_guild_state.py b/commanderbot/ext/friday/friday_guild_state.py index 7ef87bf..dd93e4e 100644 --- a/commanderbot/ext/friday/friday_guild_state.py +++ b/commanderbot/ext/friday/friday_guild_state.py @@ -117,9 +117,9 @@ async def remove_rule(self, interaction: Interaction, name: str): await interaction.followup.send( content=f"Removed the rule `{rule.name}`" ) - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise case _: # If the answer was no, send a response await interaction.followup.send( diff --git a/commanderbot/ext/friday/friday_json_store.py b/commanderbot/ext/friday/friday_json_store.py index b6f06f8..7feb12c 100644 --- a/commanderbot/ext/friday/friday_json_store.py +++ b/commanderbot/ext/friday/friday_json_store.py @@ -1,5 +1,6 @@ +from collections.abc import AsyncIterable from dataclasses import dataclass -from typing import AsyncIterable, Optional +from typing import Optional from discord import Guild diff --git a/commanderbot/ext/friday/friday_store.py b/commanderbot/ext/friday/friday_store.py index 37f8228..64fbb9f 100644 --- a/commanderbot/ext/friday/friday_store.py +++ b/commanderbot/ext/friday/friday_store.py @@ -1,6 +1,7 @@ import re +from collections.abc import AsyncIterable from datetime import datetime -from typing import AsyncIterable, Optional, Protocol +from typing import Optional, Protocol from discord import Guild diff --git a/commanderbot/ext/help_forum/help_forum_data.py b/commanderbot/ext/help_forum/help_forum_data.py index c52ae87..792cdff 100644 --- a/commanderbot/ext/help_forum/help_forum_data.py +++ b/commanderbot/ext/help_forum/help_forum_data.py @@ -49,7 +49,7 @@ class HelpForumGuildData(BaseModel): ) def _is_forum_registered(self, forum: ForumChannel): - return forum.id in self.help_forums.keys() + return forum.id in self.help_forums def _require_tag(self, forum: ForumChannel, tag_str: str) -> ForumTag: # Returns the forum tag if it exists diff --git a/commanderbot/ext/help_forum/help_forum_guild_state.py b/commanderbot/ext/help_forum/help_forum_guild_state.py index ab8a44a..b422a85 100644 --- a/commanderbot/ext/help_forum/help_forum_guild_state.py +++ b/commanderbot/ext/help_forum/help_forum_guild_state.py @@ -21,7 +21,6 @@ ) from commanderbot.ext.help_forum.help_forum_store import HelpForum, HelpForumStore from commanderbot.lib import ( - AllowedMentions, ConfirmationResult, ForumTagID, UserID, @@ -144,9 +143,9 @@ async def _setup_thread(self, thread: Thread, forum_data: HelpForum): title="Thanks for asking your question", description="\n".join( ( - f"- When your question has been answered, please resolve your post.", + "- When your question has been answered, please resolve your post.", f"- You can resolve your post by using {resolve_cmd}, reacting to a message with {resolved_emoji}, or sending {resolved_emoji} as a message.", - f"- Once your post has been resolved, any additional messages or reactions will unresolve it.", + "- Once your post has been resolved, any additional messages or reactions will unresolve it.", ) ), color=0x00ACED, @@ -210,7 +209,7 @@ async def on_resolve_message( "\n".join( ( f"{forum_data.partial_resolved_emoji} {message.author.mention} resolved this post", - f"-# Any additional messages or reactions will unresolve this post", + "-# Any additional messages or reactions will unresolve this post", ) ), allowed_mentions=AllowedMentions.none(), @@ -251,7 +250,7 @@ async def on_resolve_reaction( "\n".join( ( f"{forum_data.partial_resolved_emoji} <@{user_id}> resolved this post", - f"-# Any additional messages or reactions will unresolve this post", + "-# Any additional messages or reactions will unresolve this post", ) ), mention_author=False, @@ -281,7 +280,7 @@ async def on_resolve_command( "\n".join( ( f"{forum_data.partial_resolved_emoji} {interaction.user.mention} resolved this post", - f"-# Any additional messages or reactions will unresolve this post", + "-# Any additional messages or reactions will unresolve this post", ) ), allowed_mentions=AllowedMentions.none(), @@ -337,9 +336,9 @@ async def deregister_forum_channel( await interaction.followup.send( content=f"Deregistered <#{forum_data.channel_id}> from being a help forum" ) - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise case _: # If the answer was no, send a response await interaction.followup.send( diff --git a/commanderbot/ext/invite/invite_data.py b/commanderbot/ext/invite/invite_data.py index 833116f..e7c3958 100644 --- a/commanderbot/ext/invite/invite_data.py +++ b/commanderbot/ext/invite/invite_data.py @@ -1,8 +1,9 @@ from collections import defaultdict +from collections.abc import AsyncIterable, Iterable from dataclasses import dataclass, field from datetime import datetime from itertools import chain, islice -from typing import Any, AsyncIterable, Iterable, Optional, Self +from typing import Any, Optional, Self from discord import Guild from discord.utils import utcnow @@ -132,11 +133,11 @@ def _rebuild_tag_mappings(self): def _is_invite_key_available(self, key: str) -> bool: # Check whether the given invite key is already in use - return key not in self.invite_entries.keys() + return key not in self.invite_entries def _is_invite_tag_available(self, tag: str) -> bool: # Check whether the given invite tag is already in use - return tag not in self.invite_entries_by_tag.keys() + return tag not in self.invite_entries_by_tag def require_invite(self, key: str) -> InviteEntryData: # Returns the invite entry if it exists @@ -269,7 +270,7 @@ def all_tags_matching( yield from self.invite_entries_by_tag.keys() else: tag_filter = tag_filter if case_sensitive else tag_filter.lower() - for tag in self.invite_entries_by_tag.keys(): + for tag in self.invite_entries_by_tag: invite_tag: str = tag if case_sensitive else tag.lower() if tag_filter in invite_tag: yield tag diff --git a/commanderbot/ext/invite/invite_exceptions.py b/commanderbot/ext/invite/invite_exceptions.py index 31d4f4a..ec7be30 100644 --- a/commanderbot/ext/invite/invite_exceptions.py +++ b/commanderbot/ext/invite/invite_exceptions.py @@ -36,7 +36,7 @@ def __init__(self, key: str): class GuildInviteNotSet(InviteException): def __init__(self): - super().__init__(f"😬 An invite for this guild has not been set") + super().__init__("😬 An invite for this guild has not been set") class QueryReturnedNoResults(InviteException): diff --git a/commanderbot/ext/invite/invite_guild_state.py b/commanderbot/ext/invite/invite_guild_state.py index ce09c28..87896da 100644 --- a/commanderbot/ext/invite/invite_guild_state.py +++ b/commanderbot/ext/invite/invite_guild_state.py @@ -1,5 +1,5 @@ +from collections.abc import Iterable from dataclasses import dataclass -from typing import Iterable from discord import Embed, Interaction, TextStyle from discord.ui import TextInput @@ -130,9 +130,9 @@ async def remove_invite(self, interaction: Interaction, invite: str): await interaction.followup.send( content=f"Removed the invite `{entry.key}`" ) - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise case _: # If the answer was no, send a response await interaction.followup.send( @@ -174,7 +174,7 @@ async def set_guild_invite(self, interaction: Interaction, invite: str): async def clear_guild_invite(self, interaction: Interaction): await self.store.clear_guild_invite(self.guild) - await interaction.response.send_message(f"Cleared the invite for this guild") + await interaction.response.send_message("Cleared the invite for this guild") async def show_guild_invite(self, interaction: Interaction): entry: InviteEntry = await self.store.require_guild_invite(self.guild) diff --git a/commanderbot/ext/invite/invite_json_store.py b/commanderbot/ext/invite/invite_json_store.py index 028762c..aebab06 100644 --- a/commanderbot/ext/invite/invite_json_store.py +++ b/commanderbot/ext/invite/invite_json_store.py @@ -1,5 +1,6 @@ +from collections.abc import AsyncIterable from dataclasses import dataclass -from typing import AsyncIterable, Optional +from typing import Optional from discord import Guild diff --git a/commanderbot/ext/invite/invite_store.py b/commanderbot/ext/invite/invite_store.py index 25c33d2..2f94c55 100644 --- a/commanderbot/ext/invite/invite_store.py +++ b/commanderbot/ext/invite/invite_store.py @@ -1,5 +1,6 @@ +from collections.abc import AsyncIterable from datetime import datetime -from typing import AsyncIterable, Optional, Protocol +from typing import Optional, Protocol from discord import Guild diff --git a/commanderbot/ext/jira/jira_cog.py b/commanderbot/ext/jira/jira_cog.py index 2024d11..3b27404 100644 --- a/commanderbot/ext/jira/jira_cog.py +++ b/commanderbot/ext/jira/jira_cog.py @@ -63,9 +63,9 @@ async def cmd_jira( # Try to get the issue try: issue: JiraIssue = await self.jira_client.get_issue(query) - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise # Create embed title and limit it to 256 characters title: str = f"[{issue.issue_id}] {issue.summary}" diff --git a/commanderbot/ext/manifest/manifest_cog.py b/commanderbot/ext/manifest/manifest_cog.py index e6f1495..287bc6f 100644 --- a/commanderbot/ext/manifest/manifest_cog.py +++ b/commanderbot/ext/manifest/manifest_cog.py @@ -5,7 +5,6 @@ from discord import Embed, Interaction, Permissions from discord.app_commands import ( - AppCommandContext, AppInstallationType, Choice, Group, @@ -42,10 +41,10 @@ class ManifestType(Enum): with autocompletion of these choices """ - addon = [ModuleType.DATA, ModuleType.RESOURCE] - behavior = [ModuleType.DATA] - resource = [ModuleType.RESOURCE] - skin = [ModuleType.SKIN] + addon = (ModuleType.DATA, ModuleType.RESOURCE) + behavior = (ModuleType.DATA,) + resource = (ModuleType.RESOURCE,) + skin = (ModuleType.SKIN,) class VersionTransformer(Transformer): @@ -67,7 +66,7 @@ def __init__(self, bot: Bot, **options): # Get url option and print a warning if it doesn't exist url: Optional[str] = options.get("version_url") if not url: - self.log.warn( + self.log.warning( "No version URL was given in the bot config. " f"Using `{ManifestVersionManager.default_version()}` for the latest min engine version." ) diff --git a/commanderbot/ext/manifest/manifest_data.py b/commanderbot/ext/manifest/manifest_data.py index 8339cec..94eb6c0 100644 --- a/commanderbot/ext/manifest/manifest_data.py +++ b/commanderbot/ext/manifest/manifest_data.py @@ -14,7 +14,7 @@ class Version: patch: int @classmethod - def from_str(cls, version_str: str) -> Optional["Version"]: + def from_str(cls, version_str: str) -> Optional[Version]: if VERSION_PATTERN.match(version_str): version_numbers: list[int] = [int(i) for i in version_str.split(".")][:3] return cls(*version_numbers) diff --git a/commanderbot/ext/mccq/mccq_cog.py b/commanderbot/ext/mccq/mccq_cog.py index 8389492..9f58217 100644 --- a/commanderbot/ext/mccq/mccq_cog.py +++ b/commanderbot/ext/mccq/mccq_cog.py @@ -85,9 +85,9 @@ async def _mccq( # Try to query the command try: results, wiki_url = await query_manager.query_command(query) - except Exception as ex: + except Exception : await interaction.delete_original_response() - raise ex + raise # Format the message and create the view if necessary msg = f"```hs\n{results}\n```" diff --git a/commanderbot/ext/mccq/mccq_manager.py b/commanderbot/ext/mccq/mccq_manager.py index 142e8f5..ab0e31c 100644 --- a/commanderbot/ext/mccq/mccq_manager.py +++ b/commanderbot/ext/mccq/mccq_manager.py @@ -16,6 +16,8 @@ ) from commanderbot.ext.mccq.mccq_options import MCCQManagerOptions +type MCCQQueryResults = QueryResults | tuple[dict[str, list[str]]] + class MCCQManager: def __init__( @@ -68,7 +70,7 @@ def from_bedrock_options(cls, options: MCCQManagerOptions) -> Self: logger_name="commanderbot.ext.mccq.bedrock_manager", ) - async def _do_query(self, query: str) -> tuple[QueryResults, int]: + async def _do_query(self, query: str) -> tuple[MCCQQueryResults, int]: try: # Get the query results full_results = await asyncio.to_thread(self._query_manager.results, query) @@ -83,7 +85,7 @@ async def _do_query(self, query: str) -> tuple[QueryResults, int]: return (full_results, 0) # Trim results - trimmed_results = {} + trimmed_results: dict[str, list[str]] = {} num_results = 0 for version, lines in full_results.items(): trimmed_results[version] = [] @@ -106,7 +108,7 @@ async def _do_query(self, query: str) -> tuple[QueryResults, int]: self._log.info(f"No versions available for the command: {query}") raise NoVersionsAvailable - except (mccq.errors.LoaderFailure, mccq.errors.ParserFailure): + except mccq.errors.LoaderFailure, mccq.errors.ParserFailure: self._log.exception(f"Failed to load data for the command: {query}") raise FailedToLoadData diff --git a/commanderbot/ext/moderation/moderation_cog.py b/commanderbot/ext/moderation/moderation_cog.py index 5d74086..12403b8 100644 --- a/commanderbot/ext/moderation/moderation_cog.py +++ b/commanderbot/ext/moderation/moderation_cog.py @@ -13,7 +13,6 @@ from discord.app_commands.checks import bot_has_permissions from discord.app_commands.models import Choice from discord.ext.commands import Bot, Cog -from discord.interactions import Interaction from commanderbot.ext.moderation.moderation_exceptions import ( CannotBanBotOrSelf, diff --git a/commanderbot/ext/roles/roles_cog.py b/commanderbot/ext/roles/roles_cog.py index 1d76102..55acd53 100644 --- a/commanderbot/ext/roles/roles_cog.py +++ b/commanderbot/ext/roles/roles_cog.py @@ -12,13 +12,13 @@ from commanderbot.ext.roles.roles_store import RolesStore from commanderbot.lib import is_guild, is_member from commanderbot.lib.cogs import CogGuildStateManager +from commanderbot.lib.commands import LenientRoleConverter, checks from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, UnsupportedDatabaseOptions, ) -from commanderbot.lib.commands import LenientRoleConverter, checks def make_roles_store(bot: Bot, cog: Cog, options: RolesOptions) -> RolesStore: diff --git a/commanderbot/ext/roles/roles_data.py b/commanderbot/ext/roles/roles_data.py index 9f5fb0a..190f11c 100644 --- a/commanderbot/ext/roles/roles_data.py +++ b/commanderbot/ext/roles/roles_data.py @@ -1,7 +1,8 @@ from collections import defaultdict +from collections.abc import AsyncIterable from dataclasses import dataclass, field from datetime import datetime -from typing import AsyncIterable, Optional +from typing import Optional from discord import Guild, Role from discord.utils import utcnow @@ -24,7 +25,7 @@ class RoleEntryData: description: Optional[str] = None @staticmethod - def from_data(data: JsonObject) -> "RoleEntryData": + def from_data(data: JsonObject) -> RoleEntryData: return RoleEntryData( role_id=int(data["role_id"]), added_on=datetime.fromisoformat(data["added_on"]), @@ -52,7 +53,7 @@ class RolesGuildData: permitted_roles: Optional[RoleSet] = None @staticmethod - def from_data(data: JsonObject) -> "RolesGuildData": + def from_data(data: JsonObject) -> RolesGuildData: role_entries_flat = [ RoleEntryData.from_data(raw_entry) for raw_entry in data.get("role_entries", []) @@ -140,7 +141,7 @@ class RolesData: ) @staticmethod - def from_data(data: JsonObject) -> "RolesData": + def from_data(data: JsonObject) -> RolesData: guilds = _guilds_defaultdict_factory() guilds.update( { diff --git a/commanderbot/ext/roles/roles_guild_state.py b/commanderbot/ext/roles/roles_guild_state.py index abd739c..772a46d 100644 --- a/commanderbot/ext/roles/roles_guild_state.py +++ b/commanderbot/ext/roles/roles_guild_state.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Optional, Type +from typing import Optional from discord import AllowedMentions, Member, Message, Permissions, Role from discord.ext.commands import Context @@ -233,10 +233,10 @@ async def show_all_roles(self, ctx: Context): ctx, f"There are {len(role_pairs)} roles registered: {role_pairs_str}" + f"\n\n{ROLE_TIPS}" - + f"\n\n**Use `roles about ` for details about roles.**", + + "\n\n**Use `roles about ` for details about roles.**", ) else: - await self.reply(ctx, f"🤷 There are no roles registered.") + await self.reply(ctx, "🤷 There are no roles registered.") async def show_relevant_roles(self, ctx: Context): # List only roles that are relevant to this user. @@ -248,10 +248,10 @@ async def show_relevant_roles(self, ctx: Context): ctx, f"There are {len(role_pairs)} roles relevant to you: {role_pairs_str}" + f"\n\n{ROLE_TIPS}" - + f"\n\n**Use `roles about ` for details about roles.**", + + "\n\n**Use `roles about ` for details about roles.**", ) else: - await self.reply(ctx, f"🤷 There are no roles relevant to you.") + await self.reply(ctx, "🤷 There are no roles relevant to you.") async def get_matching_role_pairs( self, roles: list[Role] @@ -270,7 +270,7 @@ async def about_roles(self, ctx: Context, roles: list[Role]): f"Found {len(role_pairs)} matching roles:\n{role_pairs_str}", ) else: - await self.reply(ctx, f"🤷 Couldn't find any matching roles.") + await self.reply(ctx, "🤷 Couldn't find any matching roles.") async def join_roles(self, ctx: Context, roles: list[Role]): await self.join_leave_roles(ctx, roles, JoinableRolesResult) @@ -279,7 +279,7 @@ async def leave_roles(self, ctx: Context, roles: list[Role]): await self.join_leave_roles(ctx, roles, LeavableRolesResult) async def join_leave_roles( - self, ctx: Context, roles: list[Role], result_type: Type[RolesResult] + self, ctx: Context, roles: list[Role], result_type: type[RolesResult] ): if not roles: await self.reply(ctx, "🤔 You didn't provide any roles.") @@ -305,7 +305,7 @@ async def add_remove_roles( ctx: Context, roles: list[Role], members: list[Member], - result_type: Type[RolesResult], + result_type: type[RolesResult], ): if not roles: await self.reply(ctx, "🤔 You didn't provide any roles.") @@ -338,14 +338,14 @@ async def show_permitted_roles(self, ctx: Context): else: await self.reply( ctx, - f"No roles are permitted to add/remove other users to/from roles.", + "No roles are permitted to add/remove other users to/from roles.", ) async def set_permitted_roles(self, ctx: Context, *roles: Role): if not roles: await self.reply(ctx, "🤷 No roles provided.") return - new_permitted_roles = RoleSet(set(role.id for role in roles)) + new_permitted_roles = RoleSet({role.id for role in roles}) new_role_mentions = new_permitted_roles.to_mentions(self.guild) old_permitted_roles = await self.store.set_permitted_roles( self.guild, new_permitted_roles @@ -368,7 +368,7 @@ async def clear_permitted_roles(self, ctx: Context): else: await self.reply( ctx, - f"🤷 No roles are permitted to add/remove other users to/from roles.", + "🤷 No roles are permitted to add/remove other users to/from roles.", ) async def member_has_permission(self, member: Member) -> bool: diff --git a/commanderbot/ext/roles/roles_json_store.py b/commanderbot/ext/roles/roles_json_store.py index 340f0bd..5e02e09 100644 --- a/commanderbot/ext/roles/roles_json_store.py +++ b/commanderbot/ext/roles/roles_json_store.py @@ -1,5 +1,6 @@ +from collections.abc import AsyncIterable from dataclasses import dataclass -from typing import AsyncIterable, Optional +from typing import Optional from discord import Guild, Role diff --git a/commanderbot/ext/roles/roles_options.py b/commanderbot/ext/roles/roles_options.py index a167042..0fbdc11 100644 --- a/commanderbot/ext/roles/roles_options.py +++ b/commanderbot/ext/roles/roles_options.py @@ -13,6 +13,6 @@ class RolesOptions: database: DatabaseOptions = field(default_factory=InMemoryDatabaseOptions) @staticmethod - def from_dict(options: dict[str, Any]) -> "RolesOptions": + def from_dict(options: dict[str, Any]) -> RolesOptions: database_options = make_database_options(options.get("database")) return RolesOptions(database=database_options) diff --git a/commanderbot/ext/roles/roles_store.py b/commanderbot/ext/roles/roles_store.py index ec8efb5..76bba03 100644 --- a/commanderbot/ext/roles/roles_store.py +++ b/commanderbot/ext/roles/roles_store.py @@ -1,5 +1,6 @@ +from collections.abc import AsyncIterable from datetime import datetime -from typing import AsyncIterable, Optional, Protocol +from typing import Optional, Protocol from discord import Guild, Role diff --git a/commanderbot/ext/stacktracer/stacktracer_cog.py b/commanderbot/ext/stacktracer/stacktracer_cog.py index b0a1c0b..8814ac2 100644 --- a/commanderbot/ext/stacktracer/stacktracer_cog.py +++ b/commanderbot/ext/stacktracer/stacktracer_cog.py @@ -28,13 +28,13 @@ from commanderbot.lib import Color, EventData, is_guild from commanderbot.lib.app_commands import ColorTransformer, EmojiTransformer, checks from commanderbot.lib.cogs import CogGuildStateManager +from commanderbot.lib.commands import checks as command_checks from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, UnsupportedDatabaseOptions, ) -from commanderbot.lib.commands import checks as command_checks def _make_store(bot: Bot, cog: Cog, options: StacktracerOptions) -> StacktracerStore: diff --git a/commanderbot/ext/sudo/sudo_cog.py b/commanderbot/ext/sudo/sudo_cog.py index cc1ed70..31f9860 100644 --- a/commanderbot/ext/sudo/sudo_cog.py +++ b/commanderbot/ext/sudo/sudo_cog.py @@ -492,7 +492,7 @@ async def cmd_sudo_avatar_clear(self, interaction: Interaction): # Respond with a confirmation dialog result = await respond_with_confirmation( interaction, - f"Are you sure you want to clear the bot's avatar?", + "Are you sure you want to clear the bot's avatar?", ephemeral=True, ) match result: @@ -564,7 +564,7 @@ async def cmd_sudo_banner_clear(self, interaction: Interaction): # Respond with a confirmation dialog result = await respond_with_confirmation( interaction, - f"Are you sure you want to clear the bot's banner?", + "Are you sure you want to clear the bot's banner?", ephemeral=True, ) match result: @@ -736,7 +736,7 @@ async def cmd_sudo_emoji_refresh(self, interaction: Interaction): assert is_commander_bot(self.bot) await self.bot.application_emojis.update_cache() await interaction.followup.send( - f"✅ Refreshed the application emoji cache", ephemeral=True + "✅ Refreshed the application emoji cache", ephemeral=True ) except Exception as ex: raise ErrorRefreshingApplicationEmojis(str(ex)) diff --git a/commanderbot/ext/sudo/sudo_exceptions.py b/commanderbot/ext/sudo/sudo_exceptions.py index abe640d..05122a0 100644 --- a/commanderbot/ext/sudo/sudo_exceptions.py +++ b/commanderbot/ext/sudo/sudo_exceptions.py @@ -1,5 +1,3 @@ -from typing import Any - from discord import Object from discord.app_commands import AppCommandError diff --git a/commanderbot/ext/vote/vote_cog.py b/commanderbot/ext/vote/vote_cog.py index 7a1785d..512c95d 100644 --- a/commanderbot/ext/vote/vote_cog.py +++ b/commanderbot/ext/vote/vote_cog.py @@ -1,5 +1,6 @@ import re -from typing import Iterable, cast +from collections.abc import Iterable +from typing import cast import emoji from discord import Message @@ -66,7 +67,7 @@ def get_emojis(message: Message) -> Iterable[str]: async def cmd_vote(self, ctx: Context): # Determine which emoji reactions to seed the message with, silently ignoring # errors raised by any individual emoji. - for emoji in self.get_emojis(cast(Message, ctx.message)): + for message_emoji in self.get_emojis(cast(Message, ctx.message)): try: await ctx.message.add_reaction(emoji) except: diff --git a/commanderbot/ext/xkcd/xkcd_client.py b/commanderbot/ext/xkcd/xkcd_client.py index 95499a2..6911362 100644 --- a/commanderbot/ext/xkcd/xkcd_client.py +++ b/commanderbot/ext/xkcd/xkcd_client.py @@ -1,17 +1,18 @@ import random import re +from collections.abc import AsyncIterable from dataclasses import dataclass from datetime import date, datetime from itertools import islice from logging import Logger, getLogger -from typing import Any, AsyncIterable, Optional, Self +from typing import Any, Optional, Self import aiohttp from discord.utils import utcnow from commanderbot.ext.xkcd.xkcd_exception import ComicNotFound from commanderbot.ext.xkcd.xkcd_options import XKCDOptions -from commanderbot.lib import FromDataMixin, JsonObject, constants +from commanderbot.lib import FromDataMixin, constants BASE_URL: str = "https://xkcd.com" ARCHIVE_URL: str = f"{BASE_URL}/archive/" @@ -182,7 +183,7 @@ async def _get_comic_num(self, num: int) -> XKCDComic: return cached_comic # Throw an exception if the comic is not in the archive - if num not in self._archive_cache.keys(): + if num not in self._archive_cache: raise ComicNotFound(num) # Try to get the comic and throw an exception if it couldn't be found diff --git a/commanderbot/ext/xkcd/xkcd_cog.py b/commanderbot/ext/xkcd/xkcd_cog.py index 06cebf7..4256237 100644 --- a/commanderbot/ext/xkcd/xkcd_cog.py +++ b/commanderbot/ext/xkcd/xkcd_cog.py @@ -34,7 +34,7 @@ def _create_comic_embed(self, comic: XKCDComic) -> Embed: embed = Embed( title=f"#{comic.num} {comic.title}", description=( - f"-# This comic is interactive; check it out on the website!" + "-# This comic is interactive; check it out on the website!" if comic.interactive else "" ), @@ -46,7 +46,7 @@ def _create_comic_embed(self, comic: XKCDComic) -> Embed: ) embed.set_image(url=comic.image_url) embed.set_footer( - text=f"{comic.description}\n\nPublished: {comic.publication_date.strftime("%Y/%m/%d")}" + text=f"{comic.description}\n\nPublished: {comic.publication_date.strftime('%Y/%m/%d')}" ) return embed @@ -88,9 +88,9 @@ async def cmd_xkcd_get(self, interaction: Interaction, query: Range[int, 1]): # Try to get the comic try: comic: XKCDComic = await self.xkcd_client.get_comic(query) - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise # Respond with the comic await interaction.followup.send(embed=self._create_comic_embed(comic)) @@ -104,9 +104,9 @@ async def cmd_xkcd_latest(self, interaction: Interaction): # Try to get the comic try: comic: XKCDComic = await self.xkcd_client.get_latest_comic() - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise # Respond with the comic await interaction.followup.send(embed=self._create_comic_embed(comic)) @@ -120,9 +120,9 @@ async def cmd_xkcd_random(self, interaction: Interaction): # Try to get the comic try: comic: XKCDComic = await self.xkcd_client.get_random_comic() - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise # Respond with the comic await interaction.followup.send(embed=self._create_comic_embed(comic)) diff --git a/pyproject.toml b/pyproject.toml index 5d760b1..8846c9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "python-dotenv>=1.0.0", "pydantic>=2.13.0", "aiohttp>=3.13.0", - + # These are used for logging. "colorama>=0.4.0", "colorlog>=6.10.0", @@ -55,13 +55,23 @@ Changelog = "https://github.com/MinecraftCommands/commanderbot-py/blob/main/CHAN commanderbot = "commanderbot:__main__" [dependency-groups] -dev = ["black>=24.0.0", "isort>=5.13.0"] - -[tool.isort] -profile = "black" +dev = ["ruff>=0.16.0"] -[tool.pyright] -typeCheckingMode = "basic" +[tool.ruff.lint] +ignore = [ + "UP034", # https://docs.astral.sh/ruff/rules/extraneous-parentheses/ + "UP045", # https://docs.astral.sh/ruff/rules/non-pep604-annotation-optional/ + "E722", # https://docs.astral.sh/ruff/rules/bare-except/ + "S110", # https://docs.astral.sh/ruff/rules/try-except-pass/ + "BLE001", # https://docs.astral.sh/ruff/rules/blind-except/ + "SIM102", # https://docs.astral.sh/ruff/rules/collapsible-if/ + "SIM103", # https://docs.astral.sh/ruff/rules/needless-bool/ + "SIM114", # https://docs.astral.sh/ruff/rules/if-with-same-arms/ + "SIM117", # https://docs.astral.sh/ruff/rules/multiple-with-statements/ + "B009", # https://docs.astral.sh/ruff/rules/get-attr-with-constant/ + "FLY002", # https://docs.astral.sh/ruff/rules/static-join-to-f-string/ + "PIE796", # https://docs.astral.sh/ruff/rules/non-unique-enums/ +] [tool.uv.sources] tesserocr = { url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl", marker = "sys_platform == 'win32'", extra = "ocr" } diff --git a/uv.lock b/uv.lock index 56bb0dc..f7f76be 100644 --- a/uv.lock +++ b/uv.lock @@ -156,40 +156,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, ] -[[package]] -name = "black" -version = "26.5.1" -source = { registry = "https://pypi.org/simple/" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, - { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, - { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, - { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, - { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, -] - -[[package]] -name = "click" -version = "8.4.1" -source = { registry = "https://pypi.org/simple/" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -243,8 +209,7 @@ ocr = [ [package.dev-dependencies] dev = [ - { name = "black" }, - { name = "isort" }, + { name = "ruff" }, ] [package.metadata] @@ -272,10 +237,7 @@ requires-dist = [ provides-extras = ["ocr"] [package.metadata.requires-dev] -dev = [ - { name = "black", specifier = ">=24.0.0" }, - { name = "isort", specifier = ">=5.13.0" }, -] +dev = [{ name = "ruff", specifier = ">=0.16.0" }] [[package]] name = "cysignals" @@ -378,15 +340,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/2c/5f0903a53a62029875aaa3884c38070cc388248a2c1b9aa935632669e5a7/ImageHash-4.3.2-py2.py3-none-any.whl", hash = "sha256:02b0f965f8c77cd813f61d7d39031ea27d4780e7ebcad56c6cd6a709acc06e5f", size = 296657, upload-time = "2025-02-01T08:45:36.102Z" }, ] -[[package]] -name = "isort" -version = "8.0.1" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, -] - [[package]] name = "jsonpath-ng" version = "1.8.0" @@ -459,15 +412,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - [[package]] name = "numpy" version = "2.5.1" @@ -516,24 +460,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, ] -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - [[package]] name = "pillow" version = "12.3.0" @@ -563,15 +489,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, ] -[[package]] -name = "platformdirs" -version = "4.10.0" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, -] - [[package]] name = "propcache" version = "0.5.2" @@ -702,25 +619,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] -[[package]] -name = "pytokens" -version = "0.4.1" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, - { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, - { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, - { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, - { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, - { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, - { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, -] - [[package]] name = "pywavelets" version = "1.9.0" @@ -774,6 +672,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + [[package]] name = "scipy" version = "1.18.0" From b88ccf55cdfdc7a22dec1bc5963d7e98600ef0cf Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 5 Aug 2026 23:51:23 -0700 Subject: [PATCH 092/158] Fixed issues found by Ruff --- commanderbot/core/command_tree.py | 13 +++++++------ commanderbot/core/commander_bot.py | 16 ++++++++-------- commanderbot/core/error_handling.py | 29 +++++++++++++++-------------- commanderbot/core/logging.py | 4 ++-- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/commanderbot/core/command_tree.py b/commanderbot/core/command_tree.py index bcbf758..26e3b65 100644 --- a/commanderbot/core/command_tree.py +++ b/commanderbot/core/command_tree.py @@ -1,7 +1,8 @@ from collections import defaultdict +from collections.abc import Iterable from dataclasses import dataclass, field from logging import Logger, getLogger -from typing import Iterable, Optional, TypeAlias +from typing import Optional from discord import AppCommandType from discord.abc import Snowflake @@ -11,8 +12,8 @@ __all__ = ("CachingCommandTree",) -Cache: TypeAlias = dict[str, AppCommand] -GuildType: TypeAlias = Snowflake | GuildID +type Cache = dict[str, AppCommand] +type GuildType = Snowflake | GuildID @dataclass @@ -90,7 +91,7 @@ def _get_cache( if guild: return self._guild_cache.get(self._guild_to_id(guild)) else: - self._global_cache + return self._global_cache def _guild_to_id(self, guild: GuildType) -> GuildID: return guild.id if isinstance(guild, Snowflake) else guild @@ -205,8 +206,8 @@ async def sync(self, *, guild: Optional[Snowflake] = None) -> list[AppCommand]: except Exception as ex: # Temporarily handle exception then re-raise it # This is only so we can print the warning to the log - self._log.warn(f"Unable to sync {sync_msg}. Reason: {ex}") - raise ex + self._log.warning(f"Unable to sync {sync_msg}. Reason: {ex}") + raise # Update cache self._log.info(f"Started updating {cache_msg}...") diff --git a/commanderbot/core/commander_bot.py b/commanderbot/core/commander_bot.py index 1c0ee6c..76919da 100644 --- a/commanderbot/core/commander_bot.py +++ b/commanderbot/core/commander_bot.py @@ -3,7 +3,7 @@ from concurrent.futures import ProcessPoolExecutor from datetime import datetime, timedelta from logging import Logger, getLogger -from typing import Any, Optional, Type +from typing import Any, Optional from discord import AppInfo, Asset, Attachment, User from discord.ext.commands import Bot, Cog, Context, ExtensionNotFound @@ -75,7 +75,7 @@ def add_command_error_handler(self, handler: CommandErrorHandler): def add_app_command_error_handler(self, handler: AppCommandErrorHandler): self.error_handling.add_app_command_error_handler(handler) - async def add_configured_cog(self, ext_name: str, cog_class: Type[Cog]): + async def add_configured_cog(self, ext_name: str, cog_class: type[Cog]): cog: Optional[Cog] = None if options := self.config.get_extension_options(ext_name): cog = cog_class(self, **options) @@ -177,9 +177,9 @@ async def load_extension(self, name: str, *, package: Optional[str] = None): self.log.info(f"[--->] {ext.name}") await super().load_extension(ext.name) self.config.enable_extension(ext.name) - except Exception as ex: + except Exception: self.log.exception(f"Failed to load extension: {name}") - raise ex + raise # @overrides Bot async def unload_extension(self, name: str, *, package: Optional[str] = None): @@ -192,9 +192,9 @@ async def unload_extension(self, name: str, *, package: Optional[str] = None): self.log.info(f"[-x->] {ext.name}") await super().unload_extension(name) self.config.disable_extension(name) - except Exception as ex: + except Exception: self.log.exception(f"Failed to unload extension: {name}") - raise ex + raise # @overrides Bot async def reload_extension(self, name: str, *, package: Optional[str] = None): @@ -206,9 +206,9 @@ async def reload_extension(self, name: str, *, package: Optional[str] = None): # Reload extension self.log.info(f"[-o->] {ext.name}") await super().reload_extension(ext.name) - except Exception as ex: + except Exception: self.log.exception(f"Failed to reload extension: {name}") - raise ex + raise # @overrides Bot async def setup_hook(self): diff --git a/commanderbot/core/error_handling.py b/commanderbot/core/error_handling.py index d52fcb6..cfc9517 100644 --- a/commanderbot/core/error_handling.py +++ b/commanderbot/core/error_handling.py @@ -1,6 +1,7 @@ +from collections.abc import Callable, Coroutine from dataclasses import dataclass, field from logging import Logger -from typing import Any, Callable, Coroutine, Optional, TypeAlias +from typing import Any, Optional from discord import AllowedMentions, Message from discord.app_commands import errors as ace @@ -11,15 +12,15 @@ from commanderbot.lib import EventData, ResponsiveException from commanderbot.lib.app_commands import command_name, send_or_followup -EventErrorHandler: TypeAlias = Callable[ +type EventErrorHandler = Callable[ [Exception, EventData, bool], Coroutine[Any, Any, Optional[bool]] ] -CommandErrorHandler: TypeAlias = Callable[ +type CommandErrorHandler = Callable[ [Exception, Context, bool], Coroutine[Any, Any, Optional[bool]] ] -AppCommandErrorHandler: TypeAlias = Callable[ +type AppCommandErrorHandler = Callable[ [Exception, Interaction, bool], Coroutine[Any, Any, Optional[bool]] ] @@ -120,7 +121,7 @@ async def on_command_error(self, ex: Exception, ctx: Context): self.log.exception( f"Ignoring unhandled exception in cog `{cog_name}` from command: `{ctx.command}`" ) - await self.reply(ctx, f"🔥 Something went wrong trying to do that.") + await self.reply(ctx, "🔥 Something went wrong trying to do that.") async def try_handle_command_error(self, error: Exception, ctx: Context) -> bool: match error: @@ -131,16 +132,16 @@ async def try_handle_command_error(self, error: Exception, ctx: Context) -> bool await ctx.send_help(ctx.command) return True case ce.MissingPermissions(): - await self.reply(ctx, f"😠 You don't have permission to do that.") + await self.reply(ctx, "😠 You don't have permission to do that.") return True case ce.BotMissingPermissions(): - await self.reply(ctx, f"😳 I don't have permission to do that.") + await self.reply(ctx, "😳 I don't have permission to do that.") return True case ce.NoPrivateMessage(): - await self.reply(ctx, f"🤐 You can't do that in a private message.") + await self.reply(ctx, "🤐 You can't do that in a private message.") return True case ce.CheckFailure(): - await self.reply(ctx, f"🤔 You can't do that.") + await self.reply(ctx, "🤔 You can't do that.") return True case ResponsiveException(): await error.respond(ctx) @@ -179,7 +180,7 @@ async def on_app_command_error(self, ex: Exception, interaction: Interaction): ) await send_or_followup( interaction, - f"🔥 Something went wrong trying to do that.", + "🔥 Something went wrong trying to do that.", ephemeral=True, ) @@ -199,27 +200,27 @@ async def try_handle_app_command_error( case ace.MissingPermissions(): await send_or_followup( interaction, - f"😠 You don't have permission to do that.", + "😠 You don't have permission to do that.", ephemeral=True, ) return True case ace.BotMissingPermissions(): await send_or_followup( interaction, - f"😳 I don't have permission to do that.", + "😳 I don't have permission to do that.", ephemeral=True, ) return True case ace.NoPrivateMessage(): await send_or_followup( interaction, - f"🤐 You can't do that in a private message.", + "🤐 You can't do that in a private message.", ephemeral=True, ) return True case ace.CheckFailure(): await send_or_followup( - interaction, f"🤔 You can't do that.", ephemeral=True + interaction, "🤔 You can't do that.", ephemeral=True ) return True case ResponsiveException(): diff --git a/commanderbot/core/logging.py b/commanderbot/core/logging.py index 7588ddc..55dd357 100644 --- a/commanderbot/core/logging.py +++ b/commanderbot/core/logging.py @@ -10,8 +10,8 @@ __all__ = ( - "setup_logging", "preview_logging", + "setup_logging", ) @@ -46,7 +46,7 @@ def setup_logging( try: log_handler.setFormatter( - colorlog.ColoredFormatter(fmt=log_format, log_colors=log_colors) # type: ignore + colorlog.ColoredFormatter(fmt=log_format, log_colors=log_colors) ) except: print( From 618142544d14d7b8bfa6f3ef9777c494a72cc620 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 01:09:47 -0700 Subject: [PATCH 093/158] Fixed issues found by Ruff --- commanderbot/lib/allowed_mentions.py | 21 +--- commanderbot/lib/app_commands/checks.py | 4 +- commanderbot/lib/app_commands/transformers.py | 13 +-- commanderbot/lib/app_commands/utils.py | 2 +- commanderbot/lib/cogs/state.py | 4 +- commanderbot/lib/cogs/views.py | 13 +-- commanderbot/lib/color.py | 5 +- commanderbot/lib/commands/converters.py | 2 +- commanderbot/lib/constants.py | 22 ++-- commanderbot/lib/databases/json_db/config.py | 2 +- commanderbot/lib/databases/json_db/db.py | 4 +- .../lib/databases/json_db/v1/adapters.py | 9 +- .../lib/databases/json_db/v1/options.py | 4 +- commanderbot/lib/dialogs.py | 36 +++--- commanderbot/lib/exceptions.py | 4 +- commanderbot/lib/intents.py | 3 +- commanderbot/lib/json.py | 6 +- commanderbot/lib/log_options.py | 7 +- commanderbot/lib/predicates.py | 28 ++--- commanderbot/lib/role_set.py | 3 +- commanderbot/lib/types.py | 106 +++++++++--------- commanderbot/lib/utils/channels.py | 2 +- commanderbot/lib/utils/datetimes.py | 2 +- commanderbot/lib/utils/json_path.py | 2 +- commanderbot/lib/utils/timedeltas.py | 16 +-- commanderbot/lib/utils/utils.py | 3 +- 26 files changed, 155 insertions(+), 168 deletions(-) diff --git a/commanderbot/lib/allowed_mentions.py b/commanderbot/lib/allowed_mentions.py index 6790c50..ced150e 100644 --- a/commanderbot/lib/allowed_mentions.py +++ b/commanderbot/lib/allowed_mentions.py @@ -1,5 +1,6 @@ import inspect -from typing import Any, Callable, ClassVar, Optional, Self, cast, override +from collections.abc import Callable +from typing import Any, ClassVar, Optional, Self, cast, override import discord from discord.mentions import default @@ -24,24 +25,6 @@ class AllowedMentions(discord.AllowedMentions, FromDataMixin, JsonSerializable): _factories: ClassVar[dict[str, Callable[[], Self]]] = {} - # @overrides discord.AllowedMentions - @override - @classmethod - def all(cls) -> Self: - """ - A factory method that returns a :class:`AllowedMentions` with all fields explicitly set to ``True`` - """ - return cast(Self, super().all()) - - # @overrides discord.AllowedMentions - @override - @classmethod - def none(cls) -> Self: - """ - A factory method that returns a :class:`AllowedMentions` with all fields set to ``False`` - """ - return cast(Self, super().none()) - @classmethod def not_everyone(cls) -> Self: """ diff --git a/commanderbot/lib/app_commands/checks.py b/commanderbot/lib/app_commands/checks.py index 1635576..22b0732 100644 --- a/commanderbot/lib/app_commands/checks.py +++ b/commanderbot/lib/app_commands/checks.py @@ -1,8 +1,8 @@ from discord import Interaction, app_commands -import commanderbot.lib.predicates as predicates +from commanderbot.lib import predicates -__all__ = ("is_owner", "is_administrator", "is_guild_admin_or_bot_owner") +__all__ = ("is_administrator", "is_guild_admin_or_bot_owner", "is_owner") def is_owner(): diff --git a/commanderbot/lib/app_commands/transformers.py b/commanderbot/lib/app_commands/transformers.py index 371046b..acf82b4 100644 --- a/commanderbot/lib/app_commands/transformers.py +++ b/commanderbot/lib/app_commands/transformers.py @@ -4,7 +4,6 @@ from discord import Interaction, Message from discord.app_commands import AppCommandError, Choice, Transformer from discord.ext.commands import BadArgument, CommandError, Context, MessageConverter -from discord.interactions import Interaction from commanderbot.lib.color import Color from commanderbot.lib.constants import MAX_AUTOCOMPLETE_CHOICES @@ -12,13 +11,13 @@ from commanderbot.lib.predicates import is_custom_emoji, is_message_link __all__ = ( + "ColorTransformer", + "EmojiTransformer", + "InvalidColor", "InvalidEmoji", "InvalidMessageLink", - "UnableToFindMessage", - "InvalidColor", - "EmojiTransformer", "MessageTransformer", - "ColorTransformer", + "UnableToFindMessage", ) @@ -79,8 +78,8 @@ async def transform(self, interaction: Interaction, value: str) -> Message: # Try to transform `value` into a `discord.Message` try: - ctx = await Context.from_interaction(interaction) # type: ignore - return await MessageConverter().convert(ctx, value) # type: ignore + ctx = await Context.from_interaction(interaction) + return await MessageConverter().convert(ctx, value) except (CommandError, BadArgument): raise UnableToFindMessage(value) diff --git a/commanderbot/lib/app_commands/utils.py b/commanderbot/lib/app_commands/utils.py index 2090838..50c7538 100644 --- a/commanderbot/lib/app_commands/utils.py +++ b/commanderbot/lib/app_commands/utils.py @@ -2,7 +2,7 @@ from discord import AllowedMentions, Interaction, InteractionResponseType -__all__ = ("send_or_followup", "command_name", "is_deferred", "is_user_app") +__all__ = ("command_name", "is_deferred", "is_user_app", "send_or_followup") async def send_or_followup( diff --git a/commanderbot/lib/cogs/state.py b/commanderbot/lib/cogs/state.py index d92e4b5..9822614 100644 --- a/commanderbot/lib/cogs/state.py +++ b/commanderbot/lib/cogs/state.py @@ -1,6 +1,6 @@ +from collections.abc import Callable, Iterable from dataclasses import dataclass, field from logging import Logger, getLogger -from typing import Callable, Iterable from discord import Guild from discord.ext.commands import Bot, Cog @@ -9,9 +9,9 @@ from commanderbot.lib.types import GuildID __all__ = ( - "CogState", "CogGuildState", "CogGuildStateManager", + "CogState", "GuildPartitionedCogState", ) diff --git a/commanderbot/lib/cogs/views.py b/commanderbot/lib/cogs/views.py index 8edbd7b..151194f 100644 --- a/commanderbot/lib/cogs/views.py +++ b/commanderbot/lib/cogs/views.py @@ -1,10 +1,7 @@ -from typing import Any, Generic, Optional, TypeVar +from typing import Generic, Optional, TypeVar -from discord import Interaction +from discord import Interaction, ui from discord.ext.commands import Bot, Cog -from discord.interactions import Interaction -from discord.ui import Modal, View -from discord.ui.item import Item __all__ = ("CogStateModal", "CogStateView") @@ -12,7 +9,7 @@ CogStoreType = TypeVar("CogStoreType") -class CogStateModal(Generic[CogStateType, CogStoreType], Modal): +class CogStateModal(Generic[CogStateType, CogStoreType], ui.Modal): # noqa: PYI059, UP046 - Discord.py has issues with the newer generic syntax """ A base class for modals that can access a cog state. @@ -65,7 +62,7 @@ async def on_error(self, interaction: Interaction, error: Exception): await self.bot.tree.on_error(interaction, error) # type: ignore -class CogStateView(Generic[CogStateType, CogStoreType], View): +class CogStateView(Generic[CogStateType, CogStoreType], ui.View): # noqa: PYI059, UP046 - Discord.py has issues with the newer generic syntax """ A base class for views that can access a cog state. @@ -111,6 +108,6 @@ async def interaction_check(self, interaction: Interaction) -> bool: return interaction.user == self.original_interaction.user # @overrides View - async def on_error(self, interaction: Interaction, error: Exception, item: Item): + async def on_error(self, interaction: Interaction, error: Exception, item: ui.Item): # Pipe this error through the command tree's error handler await self.bot.tree.on_error(interaction, error) # type: ignore diff --git a/commanderbot/lib/color.py b/commanderbot/lib/color.py index 50979e1..97c9c0a 100644 --- a/commanderbot/lib/color.py +++ b/commanderbot/lib/color.py @@ -1,5 +1,6 @@ import inspect -from typing import Any, Callable, ClassVar, Optional, Self, cast, override +from collections.abc import Callable +from typing import Any, ClassVar, Optional, Self, cast, override import discord from pydantic import GetCoreSchemaHandler, TypeAdapter @@ -35,7 +36,7 @@ def from_str(cls, value: str) -> Self: @classmethod def presets( cls, *, color_filter: Optional[str] = None, case_sensitive: bool = False - ) -> dict[str, "Color"]: + ) -> dict[str, Color]: """ Returns a dictionary containing all color presets. The `color_filter` parameter can be used to filter the color presets that are returned. diff --git a/commanderbot/lib/commands/converters.py b/commanderbot/lib/commands/converters.py index 5f66a94..1ea4347 100644 --- a/commanderbot/lib/commands/converters.py +++ b/commanderbot/lib/commands/converters.py @@ -10,8 +10,8 @@ __all__ = ( "CannotDisambiguateRole", - "LenientRoleConverter", "ColorConverter", + "LenientRoleConverter", ) diff --git a/commanderbot/lib/constants.py b/commanderbot/lib/constants.py index 364e3b4..563f8cf 100644 --- a/commanderbot/lib/constants.py +++ b/commanderbot/lib/constants.py @@ -4,21 +4,21 @@ from importlib.metadata import version __all__ = ( - "COMMANDERBOT_VERSION", - "DISCORD_PY_VERSION", - "PYTHON_VERSION", - "SUPPORTS_OCR", - "USER_AGENT", - "MAX_MESSAGE_LENGTH", "BULK_DELETE_MAX_MESSAGES", "BULK_DELETE_MAX_MESSAGE_AGE", - "MAX_EMBED_TITLE_LENGTH", + "COMMANDERBOT_VERSION", + "DISCORD_PY_VERSION", + "MAX_APPLICATION_EMOJIS", + "MAX_AUTOCOMPLETE_CHOICES", "MAX_EMBED_DESCRIPTION_LENGTH", + "MAX_EMBED_TITLE_LENGTH", + "MAX_MESSAGE_LENGTH", "MAX_MODAL_TITLE_LENGTH", - "MAX_AUTOCOMPLETE_CHOICES", - "MAX_APPLICATION_EMOJIS", - "POINTER_SIZE_BYTES", "POINTER_SIZE_BITS", + "POINTER_SIZE_BYTES", + "PYTHON_VERSION", + "SUPPORTS_OCR", + "USER_AGENT", ) COMMANDERBOT_VERSION: str = version("commanderbot") @@ -28,7 +28,7 @@ ) try: - import tesserocr + import tesserocr # noqa: F401 - Just checking if the import is available except: SUPPORTS_OCR: bool = False else: diff --git a/commanderbot/lib/databases/json_db/config.py b/commanderbot/lib/databases/json_db/config.py index efc70f0..860a784 100644 --- a/commanderbot/lib/databases/json_db/config.py +++ b/commanderbot/lib/databases/json_db/config.py @@ -5,8 +5,8 @@ __all__ = ( "InMemoryDatabaseOptions", - "JsonFileDatabaseOptions", "JsonDBOptions", + "JsonFileDatabaseOptions", ) diff --git a/commanderbot/lib/databases/json_db/db.py b/commanderbot/lib/databases/json_db/db.py index 0269785..ef243ef 100644 --- a/commanderbot/lib/databases/json_db/db.py +++ b/commanderbot/lib/databases/json_db/db.py @@ -59,11 +59,11 @@ def _read(self) -> str: try: # Attempt read the file. return self.options.path.read_text(encoding="utf-8") - except FileNotFoundError as ex: + except FileNotFoundError: if self.options.no_init: # If the file doesn't exist, and we've been specifically told not to # automatically create it, then let the error fall through. - raise ex + raise else: # Otherwise, we can go ahead and automatically initialize the file. self.log.warning( diff --git a/commanderbot/lib/databases/json_db/v1/adapters.py b/commanderbot/lib/databases/json_db/v1/adapters.py index 16f07fb..0752fb9 100644 --- a/commanderbot/lib/databases/json_db/v1/adapters.py +++ b/commanderbot/lib/databases/json_db/v1/adapters.py @@ -1,7 +1,8 @@ import asyncio +from collections.abc import Callable from dataclasses import dataclass, field from logging import Logger, getLogger -from typing import Callable, Optional +from typing import Optional from commanderbot.lib.json import json_dump_async, json_load_async from commanderbot.lib.types import JsonObject @@ -73,11 +74,11 @@ async def read(self) -> JsonObject: try: # Attempt to async load the file. return await json_load_async(self.options.path) - except FileNotFoundError as ex: + except FileNotFoundError: if self.options.no_init: # If the file doesn't exist, and we've been specifically told not to # automatically create it, then let the error fall through. - raise ex + raise else: # Otherwise, we can go ahead and automatically initialize the file. self.log.warning( @@ -89,4 +90,4 @@ async def read(self) -> JsonObject: async def write(self, data: JsonObject): """Write the given data to the database file.""" - await json_dump_async(data, self.options.path, indent=self.options.indent) # type: ignore + await json_dump_async(data, self.options.path, indent=self.options.indent) diff --git a/commanderbot/lib/databases/json_db/v1/options.py b/commanderbot/lib/databases/json_db/v1/options.py index 2c99f84..da13bbe 100644 --- a/commanderbot/lib/databases/json_db/v1/options.py +++ b/commanderbot/lib/databases/json_db/v1/options.py @@ -7,10 +7,10 @@ __all__ = ( "DatabaseOptions", "InMemoryDatabaseOptions", - "JsonFileDatabaseOptions", "InvalidDatabaseOptions", - "UnknownDatabaseType", + "JsonFileDatabaseOptions", "MissingDatabaseType", + "UnknownDatabaseType", "UnsupportedDatabaseOptions", "make_database_options", ) diff --git a/commanderbot/lib/dialogs.py b/commanderbot/lib/dialogs.py index 3e38d4b..24fd5ef 100644 --- a/commanderbot/lib/dialogs.py +++ b/commanderbot/lib/dialogs.py @@ -1,19 +1,16 @@ -import asyncio from enum import Enum from typing import Optional import discord -from discord import ButtonStyle, Interaction, Member, Message, Reaction +from discord import ButtonStyle, Interaction, Member, Message, Reaction, ui from discord.ext.commands import Bot, Context -from discord.interactions import Interaction -from discord.ui import Button, View, button from commanderbot.lib import AllowedMentions __all__ = ( + "ConfirmView", "ConfirmationResult", "confirm_with_reaction", - "ConfirmView", "respond_with_confirmation", ) @@ -66,9 +63,9 @@ def reacted_to_conf_message(reaction: Reaction, user: Member): ) # If an appropriate reaction is not received soon enough, assume "no." - except asyncio.TimeoutError: - await conf_message.remove_reaction(reaction_yes, bot.user) # type: ignore - await conf_message.remove_reaction(reaction_no, bot.user) # type: ignore + except TimeoutError: + await conf_message.remove_reaction(reaction_yes, bot.user) + await conf_message.remove_reaction(reaction_no, bot.user) return ConfirmationResult.NO_RESPONSE # Otherwise, check which reaction was applied. @@ -76,15 +73,15 @@ def reacted_to_conf_message(reaction: Reaction, user: Member): assert isinstance(conf_reaction, Reaction) # Check if the response is a "yes." if str(conf_reaction.emoji) == reaction_yes: - await conf_message.remove_reaction(reaction_no, bot.user) # type: ignore + await conf_message.remove_reaction(reaction_no, bot.user) return ConfirmationResult.YES # If we get this far, the answer is an explicit "no." - await conf_message.remove_reaction(reaction_yes, bot.user) # type: ignore + await conf_message.remove_reaction(reaction_yes, bot.user) return ConfirmationResult.NO -class ConfirmView(View): +class ConfirmView(ui.View): def __init__(self, interaction: Interaction, timeout: Optional[float] = None): self.original_interaction: Interaction = interaction self.result = ConfirmationResult.NO_RESPONSE @@ -103,7 +100,7 @@ async def interaction_check(self, interaction: Interaction) -> bool: async def on_timeout(self): # Disable all buttons and set their color to gray for button in self.children: - assert isinstance(button, Button) + assert isinstance(button, ui.Button) button.style = ButtonStyle.gray button.disabled = True @@ -113,28 +110,28 @@ async def on_timeout(self): except: pass - @button( + @ui.button( label="Yes", style=ButtonStyle.green, custom_id="commanderbot_lib:dialogs.confirm_view.yes", ) - async def yes_callback(self, interaction: Interaction, button: Button): + async def yes_callback(self, interaction: Interaction, button: ui.Button): self.result = ConfirmationResult.YES await self._on_confirm(interaction, button) - @button( + @ui.button( label="No", style=ButtonStyle.red, custom_id="commanderbot_lib:dialogs.confirm_view.no", ) - async def no_callback(self, interaction: Interaction, button: Button): + async def no_callback(self, interaction: Interaction, button: ui.Button): self.result = ConfirmationResult.NO await self._on_confirm(interaction, button) - async def _on_confirm(self, interaction: Interaction, pressed_button: Button): + async def _on_confirm(self, interaction: Interaction, pressed_button: ui.Button): # Remove all buttons except for the one that was interacted with for button in self.children[:]: - assert isinstance(button, Button) + assert isinstance(button, ui.Button) if button is not pressed_button: self.remove_item(button) @@ -157,7 +154,7 @@ async def respond_with_confirmation( *, timeout: float = 60.0, ephemeral=False, - allowed_mentions: discord.AllowedMentions = discord.AllowedMentions.none(), + allowed_mentions: Optional[discord.AllowedMentions] = None, ) -> ConfirmationResult: """ Ask a user to confirm an action via a `discord.ui.View` with buttons. @@ -169,6 +166,7 @@ async def respond_with_confirmation( # Create the view and send it as a response view = ConfirmView(interaction, timeout) + allowed_mentions = allowed_mentions or discord.AllowedMentions.none() if not interaction.response.is_done(): await interaction.response.send_message( content, view=view, ephemeral=ephemeral, allowed_mentions=allowed_mentions diff --git a/commanderbot/lib/exceptions.py b/commanderbot/lib/exceptions.py index c1c3abb..763c348 100644 --- a/commanderbot/lib/exceptions.py +++ b/commanderbot/lib/exceptions.py @@ -4,9 +4,9 @@ from discord.ext.commands import Context __all__ = ( + "InstantiationError", "MalformedData", "NoSuchFactory", - "InstantiationError", "ResponsiveException", ) @@ -34,7 +34,7 @@ class InstantiationError(ValueError): def __init__(self, cls: type, exception: Exception): super().__init__( - f"An error occurred while instantiating a '{cls.__name__}': {str(exception)}", + f"An error occurred while instantiating a '{cls.__name__}': {exception}", ) diff --git a/commanderbot/lib/intents.py b/commanderbot/lib/intents.py index c292581..96f2f0d 100644 --- a/commanderbot/lib/intents.py +++ b/commanderbot/lib/intents.py @@ -1,5 +1,6 @@ import inspect -from typing import Any, Callable, ClassVar, Optional, Self, cast, override +from collections.abc import Callable +from typing import Any, ClassVar, Optional, Self, cast, override import discord from pydantic import GetCoreSchemaHandler, TypeAdapter diff --git a/commanderbot/lib/json.py b/commanderbot/lib/json.py index eb02a82..4293ac3 100644 --- a/commanderbot/lib/json.py +++ b/commanderbot/lib/json.py @@ -13,13 +13,13 @@ __all__ = ( "ExtendedJsonEncoder", - "to_data", - "json_load", - "json_load_async", "json_dump", "json_dump_async", "json_dumps", "json_dumps_async", + "json_load", + "json_load_async", + "to_data", ) diff --git a/commanderbot/lib/log_options.py b/commanderbot/lib/log_options.py index 0643a30..ce30479 100644 --- a/commanderbot/lib/log_options.py +++ b/commanderbot/lib/log_options.py @@ -1,5 +1,6 @@ +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, Optional +from typing import Optional from discord import Client, Embed, Message, TextChannel, Thread @@ -11,8 +12,8 @@ ) from commanderbot.lib.exceptions import ResponsiveException from commanderbot.lib.from_data_mixin import FromDataMixin -from commanderbot.lib.types import ChannelID from commanderbot.lib.predicates import is_text_channel, is_thread +from commanderbot.lib.types import ChannelID from commanderbot.lib.utils import ( sanitize_stacktrace, send_message_or_file, @@ -204,4 +205,4 @@ def _format_error_content(self, error: Exception) -> str: return str(error) def format_settings(self) -> str: - return f"```python\n{repr(self)}\n```" + return f"```python\n{self!r}\n```" diff --git a/commanderbot/lib/predicates.py b/commanderbot/lib/predicates.py index f4980d2..49191d8 100644 --- a/commanderbot/lib/predicates.py +++ b/commanderbot/lib/predicates.py @@ -30,28 +30,28 @@ __all__ = ( "is_bot", - "is_owner", + "is_category_channel", + "is_connectable_channel", "is_convertable_to", - "is_invite_link", - "is_message_link", "is_custom_emoji", - "is_user", - "is_member", - "is_guild", - "is_text_channel", - "is_forum_channel", - "is_thread", - "is_voice_channel", - "is_stage_channel", "is_dm_channel", + "is_forum_channel", "is_group_dm_channel", - "is_category_channel", + "is_guild", "is_guild_channel", + "is_invite_link", + "is_member", "is_messagable_channel", "is_messagable_guild_channel", - "is_threadable_channel", - "is_connectable_channel", + "is_message_link", + "is_owner", "is_partial_messagable", + "is_stage_channel", + "is_text_channel", + "is_thread", + "is_threadable_channel", + "is_user", + "is_voice_channel", ) INVITE_LINK_PATTERN = re.compile( diff --git a/commanderbot/lib/role_set.py b/commanderbot/lib/role_set.py index 539e7f1..5a20dee 100644 --- a/commanderbot/lib/role_set.py +++ b/commanderbot/lib/role_set.py @@ -1,5 +1,6 @@ +from collections.abc import Iterable, Iterator from dataclasses import dataclass, field -from typing import Any, Iterable, Iterator, Optional +from typing import Any, Optional from discord import Guild, Member, Role diff --git a/commanderbot/lib/types.py b/commanderbot/lib/types.py index c2e105a..a12c081 100644 --- a/commanderbot/lib/types.py +++ b/commanderbot/lib/types.py @@ -1,5 +1,5 @@ from datetime import timedelta -from typing import Annotated, Any, Literal, TypeAlias +from typing import Annotated, Any, Literal from discord import ( CategoryChannel, @@ -16,58 +16,58 @@ from pydantic import WithJsonSchema __all__ = ( - "IDType", - "GuildID", + "AppCommandID", + "AttachmentID", "CategoryID", + "Channel", "ChannelID", - "ThreadID", - "MessageID", - "RoleID", - "UserID", - "ForumTagID", - "AppCommandID", - "EmojiID", + "ChannelTypeNames", + "ConnectableChannel", "DiscordAutomodRuleID", - "AttachmentID", - "RawOptions", + "EmojiID", + "ForumTagID", + "GuildChannel", + "GuildID", + "IDType", "JsonObject", "MemberOrUser", - "Channel", - "GuildChannel", + "MessageID", "MessageableChannel", "MessageableGuildChannel", + "RawOptions", + "RoleID", + "TesseractLanguages", + "TesseractScripts", + "ThreadID", "ThreadableChannel", - "ConnectableChannel", - "ChannelTypeNames", "Timedelta", "UnicodeNormalizationForms", - "TesseractLanguages", - "TesseractScripts", + "UserID", ) -IDType: TypeAlias = int +type IDType = int -GuildID: TypeAlias = IDType -CategoryID: TypeAlias = IDType -ChannelID: TypeAlias = IDType -ThreadID: TypeAlias = IDType -MessageID: TypeAlias = IDType -RoleID: TypeAlias = IDType -UserID: TypeAlias = IDType -ForumTagID: TypeAlias = IDType -AppCommandID: TypeAlias = IDType -EmojiID: TypeAlias = IDType -DiscordAutomodRuleID: TypeAlias = IDType -AttachmentID: TypeAlias = IDType +type GuildID = IDType +type CategoryID = IDType +type ChannelID = IDType +type ThreadID = IDType +type MessageID = IDType +type RoleID = IDType +type UserID = IDType +type ForumTagID = IDType +type AppCommandID = IDType +type EmojiID = IDType +type DiscordAutomodRuleID = IDType +type AttachmentID = IDType -RawOptions: TypeAlias = Any +type RawOptions = Any -JsonObject: TypeAlias = dict[str, Any] +type JsonObject = dict[str, Any] -MemberOrUser: TypeAlias = Member | User +type MemberOrUser = Member | User -Channel: TypeAlias = ( +type Channel = ( TextChannel | ForumChannel | Thread @@ -77,17 +77,17 @@ | GroupChannel | CategoryChannel ) -GuildChannel: TypeAlias = ( +type GuildChannel = ( TextChannel | ForumChannel | VoiceChannel | StageChannel | CategoryChannel ) -MessageableChannel: TypeAlias = ( +type MessageableChannel = ( TextChannel | VoiceChannel | StageChannel | DMChannel | GroupChannel ) -MessageableGuildChannel: TypeAlias = TextChannel | VoiceChannel | StageChannel -ThreadableChannel: TypeAlias = TextChannel | ForumChannel -ConnectableChannel: TypeAlias = VoiceChannel | StageChannel +type MessageableGuildChannel = TextChannel | VoiceChannel | StageChannel +type ThreadableChannel = TextChannel | ForumChannel +type ConnectableChannel = VoiceChannel | StageChannel -ChannelTypeNames: TypeAlias = Literal[ +type ChannelTypeNames = Literal[ "text", "news", "forum", @@ -107,7 +107,7 @@ https://discordpy.readthedocs.io/en/latest/api.html#discord.ChannelType """ -MemberFlagsNames: TypeAlias = Literal[ +type MemberFlagsNames = Literal[ "automod_quarantined_guild_tag", "automod_quarantined_username", "bypasses_verification", @@ -125,7 +125,7 @@ https://discordpy.readthedocs.io/en/latest/api.html#memberflags """ -PublicUserFlagsNames: TypeAlias = Literal[ +type PublicUserFlagsNames = Literal[ "active_developer", "bot_http_interactions", "bug_hunter", @@ -151,7 +151,7 @@ """ -Timedelta = Annotated[ +type Timedelta = Annotated[ timedelta, WithJsonSchema( { @@ -181,11 +181,11 @@ An alias for `datetime.timedelta`, but with a Json schema for Pydantic. """ -UnicodeNormalizationForms: TypeAlias = Literal["NFC", "NFD", "NFKC", "NFKD"] +type UnicodeNormalizationForms = Literal["NFC", "NFD", "NFKC", "NFKD"] # fmt: off -TesseractLanguages: TypeAlias = Literal[ +type TesseractLanguages = Literal[ "afr", "amh", "ara", "asm", "aze", "aze_cyrl", "bel", "ben", "bod", "bos", "bre", "bul", "cat", "ceb", "ces", "chi_sim", "chi_sim_vert", "chi_tra", "chi_tra_vert", "chr", "cos", "cym", "dan", "deu", "deu_latf", "div", "dzo", "ell", "eng", "enm", @@ -206,11 +206,15 @@ # fmt: on # fmt: off -TesseractScripts: TypeAlias = Literal[ - "arab", "armn", "beng", "cans", "cher", "cyrl", "deva", "ethi", "frak", "geor", - "grek", "gujr", "guru", "hans", "hans-vert", "hant", "hant-vert", "hang", "hang-vert", "hebr", - "jpan", "jpan-vert", "knda", "khmr", "laoo", "latn", "mlym", "mymr", "orya", "sinh", - "syrc", "taml", "telu", "thaa", "thai", "tibt", "viet", +type TesseractScripts = Literal[ + "script/Arabic", "script/Armenian", "script/Bengali", "script/Canadian_Aboriginal", "script/Cherokee", + "script/Cyrillic", "script/Devanagari", "script/Ethiopic", "script/Fraktur", "script/Georgian", + "script/Greek", "script/Gujarati", "script/Gurmukhi", "script/Hangul", "script/Hangul_vert", + "script/HanS", "script/HanS_vert", "script/HanT", "script/HanT_vert", "script/Hebrew", + "script/Japanese", "script/Japanese_vert", "script/Kannada", "script/Khmer", "script/Lao", + "script/Latin", "script/Malayalam", "script/Myanmar", "script/Oriya", "script/Sinhala", + "script/Syriac", "script/Tamil", "script/Telugu", "script/Thaana", "script/Thai", + "script/Tibetan", "script/Vietnamese", ] """ All scripts that Tesseract supports (https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html). diff --git a/commanderbot/lib/utils/channels.py b/commanderbot/lib/utils/channels.py index 6b74d32..c813ee0 100644 --- a/commanderbot/lib/utils/channels.py +++ b/commanderbot/lib/utils/channels.py @@ -7,10 +7,10 @@ __all__ = ( "format_forum_tag", - "try_get_forum_tag", "require_forum_tag", "require_forum_tag_id", "thread_has_forum_tag_with_id", + "try_get_forum_tag", ) diff --git a/commanderbot/lib/utils/datetimes.py b/commanderbot/lib/utils/datetimes.py index 07d828a..4ea2064 100644 --- a/commanderbot/lib/utils/datetimes.py +++ b/commanderbot/lib/utils/datetimes.py @@ -5,10 +5,10 @@ from commanderbot.lib.types import JsonObject __all__ = ( - "try_datetime_from_data", "datetime_from_data", "datetime_from_field", "datetime_from_field_optional", + "try_datetime_from_data", ) diff --git a/commanderbot/lib/utils/json_path.py b/commanderbot/lib/utils/json_path.py index de37e07..ea02d04 100644 --- a/commanderbot/lib/utils/json_path.py +++ b/commanderbot/lib/utils/json_path.py @@ -9,8 +9,8 @@ __all__ = ( "JsonPath", "JsonPathOp", - "parse_json_path_op", "parse_json_path", + "parse_json_path_op", "query_json_path", "update_json_with_path", ) diff --git a/commanderbot/lib/utils/timedeltas.py b/commanderbot/lib/utils/timedeltas.py index 753125d..81f9fa8 100644 --- a/commanderbot/lib/utils/timedeltas.py +++ b/commanderbot/lib/utils/timedeltas.py @@ -5,12 +5,12 @@ from commanderbot.lib.types import JsonObject __all__ = ( - "timedelta_from_dict", - "timedelta_to_dict", - "try_timedelta_from_data", "timedelta_from_data", + "timedelta_from_dict", "timedelta_from_field", "timedelta_from_field_optional", + "timedelta_to_dict", + "try_timedelta_from_data", ) @@ -19,11 +19,11 @@ def timedelta_from_dict(d: dict[str, Any]) -> timedelta: def timedelta_to_dict(td: timedelta) -> dict[str, Any]: - return dict( - days=td.days, - seconds=td.seconds, - microseconds=td.microseconds, - ) + return { + "days": td.days, + "seconds": td.seconds, + "microseconds": td.microseconds, + } def try_timedelta_from_data(data: Any) -> Optional[timedelta]: diff --git a/commanderbot/lib/utils/utils.py b/commanderbot/lib/utils/utils.py index ec3a4f9..8486aca 100644 --- a/commanderbot/lib/utils/utils.py +++ b/commanderbot/lib/utils/utils.py @@ -4,8 +4,9 @@ import os import re import traceback +from collections.abc import AsyncIterable, Callable, Coroutine, Mapping from enum import Enum -from typing import Any, AsyncIterable, Callable, Coroutine, Mapping, Optional, cast +from typing import Any, Optional from discord import ( AllowedMentions, From 5307370312299e3c7bae840eff071e2bfae0fe82 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 01:14:38 -0700 Subject: [PATCH 094/158] Changed `pass` to `...` in `@abstractmethod`s --- commanderbot/lib/from_data_mixin.py | 1 + commanderbot/lib/json_serializable.py | 1 + 2 files changed, 2 insertions(+) diff --git a/commanderbot/lib/from_data_mixin.py b/commanderbot/lib/from_data_mixin.py index b2d1a17..e88cfbe 100644 --- a/commanderbot/lib/from_data_mixin.py +++ b/commanderbot/lib/from_data_mixin.py @@ -12,6 +12,7 @@ class FromDataMixin(ABC): @abstractmethod def try_from_data(cls, data: Any) -> Optional[Self]: """Override this to return an instance of the class given valid input.""" + ... @classmethod def from_data(cls, data: Any) -> Self: diff --git a/commanderbot/lib/json_serializable.py b/commanderbot/lib/json_serializable.py index 9631f80..9d06e4c 100644 --- a/commanderbot/lib/json_serializable.py +++ b/commanderbot/lib/json_serializable.py @@ -8,3 +8,4 @@ class JsonSerializable(ABC): @abstractmethod def to_json(self) -> Any: """Turn the object into JSON-serializable data.""" + ... From 993b9f493af6230f53f27ab56883b595e6581672 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 01:30:39 -0700 Subject: [PATCH 095/158] Fixed issues found by Ruff --- commanderbot/ext/automod/action/action_base.py | 2 +- .../automod/actions/abc/add_roles_to_target.py | 2 +- .../automod/actions/abc/delete_target_messages.py | 4 ++-- .../actions/abc/remove_roles_from_target.py | 2 +- commanderbot/ext/automod/actions/throw_error.py | 3 ++- commanderbot/ext/automod/automod_context.py | 3 ++- commanderbot/ext/automod/bucket/bucket_base.py | 4 ++-- commanderbot/ext/automod/bucket/bucket_ref.py | 2 -- .../ext/automod/buckets/message_history.py | 7 ++++--- .../ext/automod/condition/condition_base.py | 2 +- .../automod/conditions/abc/target_account_age.py | 2 +- .../conditions/abc/target_has_all_flags.py | 2 +- .../conditions/abc/target_has_any_flags.py | 2 +- .../ext/automod/conditions/abc/target_is_bot.py | 2 +- .../automod/conditions/abc/target_is_not_bot.py | 2 +- .../automod/conditions/abc/target_is_not_self.py | 2 +- .../ext/automod/conditions/abc/target_is_self.py | 2 +- .../automod/conditions/abc/target_member_for.py | 2 +- .../ext/automod/conditions/abc/target_roles.py | 2 +- .../conditions/image_attachments_contain.py | 15 ++++++++------- .../ext/automod/conditions/throw_error.py | 3 ++- commanderbot/ext/automod/events/message_edited.py | 2 +- commanderbot/ext/automod/guards/flags.py | 5 +++-- commanderbot/ext/automod/guards/roles.py | 3 ++- commanderbot/ext/automod/trigger/trigger_base.py | 2 +- .../triggers/mentions_removed_from_message.py | 3 +-- commanderbot/ext/automod/types.py | 4 ++-- 27 files changed, 45 insertions(+), 41 deletions(-) diff --git a/commanderbot/ext/automod/action/action_base.py b/commanderbot/ext/automod/action/action_base.py index dee680b..8c57bc4 100644 --- a/commanderbot/ext/automod/action/action_base.py +++ b/commanderbot/ext/automod/action/action_base.py @@ -29,4 +29,4 @@ class AutomodAction(ABC, BaseModel): @abstractmethod async def apply(self, context: AutomodContext): """Apply the action.""" - pass + ... diff --git a/commanderbot/ext/automod/actions/abc/add_roles_to_target.py b/commanderbot/ext/automod/actions/abc/add_roles_to_target.py index 79f9925..ce7a38c 100644 --- a/commanderbot/ext/automod/actions/abc/add_roles_to_target.py +++ b/commanderbot/ext/automod/actions/abc/add_roles_to_target.py @@ -20,7 +20,7 @@ class AddRolesToTarget(AutomodAction): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[Member]: - pass + ... @override async def apply(self, context: AutomodContext): diff --git a/commanderbot/ext/automod/actions/abc/delete_target_messages.py b/commanderbot/ext/automod/actions/abc/delete_target_messages.py index 05c33b5..5b07b14 100644 --- a/commanderbot/ext/automod/actions/abc/delete_target_messages.py +++ b/commanderbot/ext/automod/actions/abc/delete_target_messages.py @@ -31,7 +31,7 @@ class DeleteTargetMessages(AutomodAction): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[User]: - pass + ... @override async def apply(self, context: AutomodContext): @@ -55,4 +55,4 @@ async def apply(self, context: AutomodContext): try: await channel.delete_messages(to_delete) except: - pass + pass diff --git a/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py b/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py index b341502..9d72604 100644 --- a/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py +++ b/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py @@ -20,7 +20,7 @@ class RemoveRolesFromTarget(AutomodAction): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[Member]: - pass + ... @override async def apply(self, context: AutomodContext): diff --git a/commanderbot/ext/automod/actions/throw_error.py b/commanderbot/ext/automod/actions/throw_error.py index fe9c3f7..58cc347 100644 --- a/commanderbot/ext/automod/actions/throw_error.py +++ b/commanderbot/ext/automod/actions/throw_error.py @@ -2,6 +2,7 @@ from commanderbot.ext.automod.action import AutomodAction from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.automod_exceptions import AutomodException __all__ = ("ThrowError",) @@ -20,4 +21,4 @@ class ThrowError(AutomodAction): @override async def apply(self, context: AutomodContext): - raise Exception(self.error) + raise AutomodException(self.error) diff --git a/commanderbot/ext/automod/automod_context.py b/commanderbot/ext/automod/automod_context.py index 432e29c..843e2cf 100644 --- a/commanderbot/ext/automod/automod_context.py +++ b/commanderbot/ext/automod/automod_context.py @@ -1,9 +1,10 @@ import string from collections import defaultdict +from collections.abc import Iterable from dataclasses import asdict, dataclass, field from itertools import chain from logging import Logger -from typing import TYPE_CHECKING, Any, Iterable, Optional, cast +from typing import TYPE_CHECKING, Any, Optional, cast import imagehash import numpy as np diff --git a/commanderbot/ext/automod/bucket/bucket_base.py b/commanderbot/ext/automod/bucket/bucket_base.py index f2dc9ad..37c1c9d 100644 --- a/commanderbot/ext/automod/bucket/bucket_base.py +++ b/commanderbot/ext/automod/bucket/bucket_base.py @@ -32,9 +32,9 @@ class AutomodBucket(ABC, BaseModel): @abstractmethod async def add(self, context: AutomodContext): """Modify the bucket using context data.""" - pass + ... @abstractmethod async def clear(self): """Clear all data in the bucket.""" - pass + ... diff --git a/commanderbot/ext/automod/bucket/bucket_ref.py b/commanderbot/ext/automod/bucket/bucket_ref.py index d116449..d53f594 100644 --- a/commanderbot/ext/automod/bucket/bucket_ref.py +++ b/commanderbot/ext/automod/bucket/bucket_ref.py @@ -22,7 +22,6 @@ async def resolve(self, context: AutomodContext) -> AutomodBucket: """ Resolve the bucket. Will throw an expection if it's not found. """ - ... @overload async def resolve[BucketType = AutomodBucket]( @@ -31,7 +30,6 @@ async def resolve[BucketType = AutomodBucket]( """ Resolve the bucket and require it to have a specific type. Will throw an expection if it's not found. """ - ... async def resolve[BucketType = AutomodBucket]( self, context: AutomodContext, bucket_type: Optional[type[BucketType]] = None diff --git a/commanderbot/ext/automod/buckets/message_history.py b/commanderbot/ext/automod/buckets/message_history.py index 01693d2..fee3ba8 100644 --- a/commanderbot/ext/automod/buckets/message_history.py +++ b/commanderbot/ext/automod/buckets/message_history.py @@ -1,7 +1,8 @@ from collections import defaultdict +from collections.abc import Iterable from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import Iterable, Literal, override +from datetime import UTC, datetime +from typing import Literal, override from discord import Member, Message, User from discord.utils import utcnow @@ -126,7 +127,7 @@ def _clean_up(self): def _to_interval(self, dt: datetime) -> datetime: ts = int(dt.timestamp()) interval_ts = (ts // self.interval_seconds) * self.interval_seconds - interval_dt = datetime.fromtimestamp(interval_ts, timezone.utc) + interval_dt = datetime.fromtimestamp(interval_ts, UTC) return interval_dt def yield_partitions_since(self, since_interval: datetime) -> Iterable[Partition]: diff --git a/commanderbot/ext/automod/condition/condition_base.py b/commanderbot/ext/automod/condition/condition_base.py index 9947ad1..575a72b 100644 --- a/commanderbot/ext/automod/condition/condition_base.py +++ b/commanderbot/ext/automod/condition/condition_base.py @@ -29,4 +29,4 @@ class AutomodCondition(ABC, BaseModel): @abstractmethod async def check(self, context: AutomodContext) -> bool: """Check whether the condition passes.""" - pass + ... diff --git a/commanderbot/ext/automod/conditions/abc/target_account_age.py b/commanderbot/ext/automod/conditions/abc/target_account_age.py index 25675db..65f9403 100644 --- a/commanderbot/ext/automod/conditions/abc/target_account_age.py +++ b/commanderbot/ext/automod/conditions/abc/target_account_age.py @@ -20,7 +20,7 @@ class TargetAccountAge(AutomodCondition): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[User]: - pass + ... @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/abc/target_has_all_flags.py b/commanderbot/ext/automod/conditions/abc/target_has_all_flags.py index 5e3f58f..6771a40 100644 --- a/commanderbot/ext/automod/conditions/abc/target_has_all_flags.py +++ b/commanderbot/ext/automod/conditions/abc/target_has_all_flags.py @@ -16,7 +16,7 @@ class TargetHasAllFlags(AutomodCondition): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[User | Member]: - pass + ... @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/abc/target_has_any_flags.py b/commanderbot/ext/automod/conditions/abc/target_has_any_flags.py index bd13306..b93b14e 100644 --- a/commanderbot/ext/automod/conditions/abc/target_has_any_flags.py +++ b/commanderbot/ext/automod/conditions/abc/target_has_any_flags.py @@ -20,7 +20,7 @@ class TargetHasAnyFlags(AutomodCondition): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[User | Member]: - pass + ... @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/abc/target_is_bot.py b/commanderbot/ext/automod/conditions/abc/target_is_bot.py index b6a9de7..583878d 100644 --- a/commanderbot/ext/automod/conditions/abc/target_is_bot.py +++ b/commanderbot/ext/automod/conditions/abc/target_is_bot.py @@ -12,7 +12,7 @@ class TargetIsBot(AutomodCondition): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[User]: - pass + ... @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/abc/target_is_not_bot.py b/commanderbot/ext/automod/conditions/abc/target_is_not_bot.py index b6bc734..97a4558 100644 --- a/commanderbot/ext/automod/conditions/abc/target_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/abc/target_is_not_bot.py @@ -12,7 +12,7 @@ class TargetIsNotBot(AutomodCondition): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[User]: - pass + ... @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/abc/target_is_not_self.py b/commanderbot/ext/automod/conditions/abc/target_is_not_self.py index 99ba97b..0471c27 100644 --- a/commanderbot/ext/automod/conditions/abc/target_is_not_self.py +++ b/commanderbot/ext/automod/conditions/abc/target_is_not_self.py @@ -12,7 +12,7 @@ class TargetIsNotSelf(AutomodCondition): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[User]: - pass + ... @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/abc/target_is_self.py b/commanderbot/ext/automod/conditions/abc/target_is_self.py index 724dac9..4ed4479 100644 --- a/commanderbot/ext/automod/conditions/abc/target_is_self.py +++ b/commanderbot/ext/automod/conditions/abc/target_is_self.py @@ -12,7 +12,7 @@ class TargetIsSelf(AutomodCondition): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[User]: - pass + ... @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/abc/target_member_for.py b/commanderbot/ext/automod/conditions/abc/target_member_for.py index 341d2a0..cf769b2 100644 --- a/commanderbot/ext/automod/conditions/abc/target_member_for.py +++ b/commanderbot/ext/automod/conditions/abc/target_member_for.py @@ -20,7 +20,7 @@ class TargetMemberFor(AutomodCondition): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[Member]: - pass + ... @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/abc/target_roles.py b/commanderbot/ext/automod/conditions/abc/target_roles.py index 75b48da..51f8024 100644 --- a/commanderbot/ext/automod/conditions/abc/target_roles.py +++ b/commanderbot/ext/automod/conditions/abc/target_roles.py @@ -16,7 +16,7 @@ class TargetRoles(AutomodCondition): @abstractmethod def get_target(self, context: AutomodContext) -> Optional[Member]: - pass + ... @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py index b950b09..ea17176 100644 --- a/commanderbot/ext/automod/conditions/image_attachments_contain.py +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -96,7 +96,7 @@ async def _ocr(self, context: AutomodContext) -> Optional[AttachmentID]: return # Create `lang` string from languages and scripts - lang: str = "+".join((v for v in chain(self.languages, self.scripts))) + lang: str = "+".join(v for v in chain(self.languages, self.scripts)) # Submit OCR tasks to the process pool assert is_commander_bot(context.bot) @@ -108,13 +108,14 @@ async def _ocr(self, context: AutomodContext) -> Optional[AttachmentID]: ] # Check OCR results as they come in + # This may raise a `TimeoutError`, but it *should* be caught in the guild state try: - for task in asyncio.as_completed(tasks, timeout=self.timeout): - if result := await task: - text, attachment_id = result - # Check if the text passes the condition and return the attachment - if self._check_ocr_result(text): - return attachment_id + async for text, attachment_id in asyncio.as_completed( + tasks, timeout=self.timeout + ): + # Check if the text passes the condition and return the attachment + if self._check_ocr_result(text): + return attachment_id finally: for task in tasks: if not task.done(): diff --git a/commanderbot/ext/automod/conditions/throw_error.py b/commanderbot/ext/automod/conditions/throw_error.py index a96d238..44d2172 100644 --- a/commanderbot/ext/automod/conditions/throw_error.py +++ b/commanderbot/ext/automod/conditions/throw_error.py @@ -1,6 +1,7 @@ from typing import Literal, override from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.automod_exceptions import AutomodException from commanderbot.ext.automod.condition import AutomodCondition __all__ = ("ThrowError",) @@ -20,4 +21,4 @@ class ThrowError(AutomodCondition): @override async def check(self, context: AutomodContext) -> bool: - raise Exception(self.error) + raise AutomodException(self.error) diff --git a/commanderbot/ext/automod/events/message_edited.py b/commanderbot/ext/automod/events/message_edited.py index 960f2d9..cd66ff2 100644 --- a/commanderbot/ext/automod/events/message_edited.py +++ b/commanderbot/ext/automod/events/message_edited.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Optional, override +from typing import override from discord import Attachment, Member, Message, Thread, User diff --git a/commanderbot/ext/automod/guards/flags.py b/commanderbot/ext/automod/guards/flags.py index 66fccfa..25ea91a 100644 --- a/commanderbot/ext/automod/guards/flags.py +++ b/commanderbot/ext/automod/guards/flags.py @@ -1,4 +1,5 @@ -from typing import Any, Generator, get_args +from collections.abc import Generator +from typing import Any, get_args from discord import Member, User from pydantic import BaseModel, Field, GetCoreSchemaHandler @@ -92,7 +93,7 @@ def __get_pydantic_core_schema__( [ core_schema.set_schema( core_schema.literal_schema( - [*get_args(PublicUserFlagsNames), *get_args(MemberFlagsNames)] + [*get_args(PublicUserFlagsNames.__value__), *get_args(MemberFlagsNames.__value__)] ), min_length=1, ), diff --git a/commanderbot/ext/automod/guards/roles.py b/commanderbot/ext/automod/guards/roles.py index 30e81ca..819b842 100644 --- a/commanderbot/ext/automod/guards/roles.py +++ b/commanderbot/ext/automod/guards/roles.py @@ -1,4 +1,5 @@ -from typing import Iterable, Optional +from collections.abc import Iterable +from typing import Optional from discord import Member, Role, User from pydantic import BaseModel, ConfigDict, Field diff --git a/commanderbot/ext/automod/trigger/trigger_base.py b/commanderbot/ext/automod/trigger/trigger_base.py index 2ed208c..7ef6727 100644 --- a/commanderbot/ext/automod/trigger/trigger_base.py +++ b/commanderbot/ext/automod/trigger/trigger_base.py @@ -27,7 +27,7 @@ class AutomodTrigger(ABC, BaseModel): disabled: bool = False """Is the trigger disabled?""" - event_types: ClassVar[tuple[type[AutomodEvent], ...]] = tuple() + event_types: ClassVar[tuple[type[AutomodEvent], ...]] = () async def poll(self, context: AutomodContext) -> bool: """Checks whether an event activates the trigger.""" diff --git a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py index cf78597..3b162f7 100644 --- a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py +++ b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py @@ -88,8 +88,7 @@ async def ignore(self, context: AutomodContext) -> bool: removed_role_mentions.add(role_mention) # Remove the author's own mention - if event.author in removed_user_mentions: - removed_user_mentions.remove(event.author) + removed_user_mentions.discard(event.author) # Remove any excluded mentions if self.victim_user_roles: diff --git a/commanderbot/ext/automod/types.py b/commanderbot/ext/automod/types.py index 4ffeea9..51a8e14 100644 --- a/commanderbot/ext/automod/types.py +++ b/commanderbot/ext/automod/types.py @@ -4,10 +4,10 @@ from imagehash import ImageHash __all__ = ( + "ContextAttachment", "ContextEventFields", - "ContextMetadataFields", "ContextFields", - "ContextAttachment", + "ContextMetadataFields", ) From 0abec6902ed94f01f494ffb03eb4d5cbaae93e76 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 01:31:04 -0700 Subject: [PATCH 096/158] Update automod options and exceptions --- .../ext/automod/automod_exceptions.py | 83 +++++++++++++++++++ commanderbot/ext/automod/automod_options.py | 19 +---- 2 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 commanderbot/ext/automod/automod_exceptions.py diff --git a/commanderbot/ext/automod/automod_exceptions.py b/commanderbot/ext/automod/automod_exceptions.py new file mode 100644 index 0000000..13facc4 --- /dev/null +++ b/commanderbot/ext/automod/automod_exceptions.py @@ -0,0 +1,83 @@ +from commanderbot.lib.exceptions import ResponsiveException + + +class AutomodException(ResponsiveException): + pass + + +class AutomodRuleAlreadyExists(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"🤷 Automod rule `{self.name}` already exists") + + +class AutomodRuleDoesNotExist(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Automod rule `{self.name}` does not exist") + + +class CannotModifyAutomodRuleName(AutomodException): + def __init__(self): + super().__init__( + "😬 Automod rule names can't be changed after the rule was created" + ) + + +class AutomodRuleAlreadyEnabled(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Automod rule `{name}` is already enabled") + + +class AutomodRuleAlreadyDisabled(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Automod rule `{name}` is already disabled") + + +class AutomodRuleMetadataAlreadyExists(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__( + f"😬 Automod rule metadata `{self.name}` already exists (This should never happen!!!)" + ) + + +class AutomodRuleMetadataDoesNotExist(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__( + f"🤷 Automod rule metadata `{self.name}` does not exist (This should never happen!!!)" + ) + + +class AutomodBucketAlreadyExists(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"🤷 Automod bucket `{self.name}` already exists") + + +class AutomodBucketDoesNotExist(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Automod bucket `{self.name}` does not exist") + + +class AutomodBucketAlreadyEnabled(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Automod bucket `{name}` is already enabled") + + +class AutomodBucketAlreadyDisabled(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Automod bucket `{name}` is already disabled") + + +class OCRNotSupported(AutomodException): + def __init__(self): + super().__init__( + "Automod does not support OCR. The feature is either not installed or misconfigured." + ) diff --git a/commanderbot/ext/automod/automod_options.py b/commanderbot/ext/automod/automod_options.py index 828bca7..0d26e98 100644 --- a/commanderbot/ext/automod/automod_options.py +++ b/commanderbot/ext/automod/automod_options.py @@ -1,18 +1,7 @@ -from dataclasses import dataclass, field -from typing import Any, Self +from pydantic import BaseModel -from commanderbot.lib.databases.json_db.v1 import ( - DatabaseOptions, - InMemoryDatabaseOptions, - make_database_options, -) +from commanderbot.lib.databases.json_db import JsonDBOptions -@dataclass -class AutomodOptions: - database: DatabaseOptions = field(default_factory=InMemoryDatabaseOptions) - - @classmethod - def from_dict(cls, options: dict[str, Any]) -> Self: - database_options = make_database_options(options.get("database")) - return cls(database=database_options) +class AutomodOptions(BaseModel): + database: JsonDBOptions From f009ba382167ccb168d6610abb661f376c429602 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 01:54:03 -0700 Subject: [PATCH 097/158] Added missing json schema input --- commanderbot/lib/image_hash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/lib/image_hash.py b/commanderbot/lib/image_hash.py index 290af83..defddb4 100644 --- a/commanderbot/lib/image_hash.py +++ b/commanderbot/lib/image_hash.py @@ -16,6 +16,6 @@ def _validate_image_hash(data: Any) -> imagehash.ImageHash: ImageHash = Annotated[ imagehash.ImageHash, - PlainValidator(_validate_image_hash), + PlainValidator(_validate_image_hash, json_schema_input_type=str), PlainSerializer(lambda value: str(value), return_type=str), ] From f358b43d6c36320b866919fb8d95f14a543cee81 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 01:54:35 -0700 Subject: [PATCH 098/158] Changed how task results are iterated over --- .../ext/automod/conditions/image_attachments_contain.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py index ea17176..fc231e9 100644 --- a/commanderbot/ext/automod/conditions/image_attachments_contain.py +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -110,12 +110,14 @@ async def _ocr(self, context: AutomodContext) -> Optional[AttachmentID]: # Check OCR results as they come in # This may raise a `TimeoutError`, but it *should* be caught in the guild state try: - async for text, attachment_id in asyncio.as_completed( + async for completed_task in asyncio.as_completed( tasks, timeout=self.timeout ): # Check if the text passes the condition and return the attachment - if self._check_ocr_result(text): - return attachment_id + if result := completed_task.result(): + text, attachment_id = result + if self._check_ocr_result(text): + return attachment_id finally: for task in tasks: if not task.done(): From 19200edc4e9053bbd33cf6fd53387e3a72d59fb1 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 01:56:15 -0700 Subject: [PATCH 099/158] Fixed an issue where phashes weren't being generated properly. --- commanderbot/ext/automod/automod_context.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/automod_context.py b/commanderbot/ext/automod/automod_context.py index 843e2cf..07154b5 100644 --- a/commanderbot/ext/automod/automod_context.py +++ b/commanderbot/ext/automod/automod_context.py @@ -6,6 +6,7 @@ from logging import Logger from typing import TYPE_CHECKING, Any, Optional, cast +import cv2 import imagehash import numpy as np from discord import Attachment, Member, User @@ -155,7 +156,10 @@ async def _fetch_attachment( # Calculate phash buffer = np.frombuffer(data, np.uint8) - image = Image.fromarray(buffer) + image = cv2.imdecode(buffer, cv2.IMREAD_COLOR_RGB) + assert image is not None + + image = Image.fromarray(image) phash = imagehash.phash(image) # Store image attachment phash From e4ef49d4db5e3db6d4f6c32afff0c569530c6ca2 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 03:28:57 -0700 Subject: [PATCH 100/158] Fixed issues found by Ruff --- .../core/application_emoji_manager.py | 3 +- commanderbot/ext/feeds/providers/utils.py | 23 +- commanderbot/ext/mcdoc/mcdoc_cog.py | 4 +- commanderbot/ext/mcdoc/mcdoc_exceptions.py | 8 +- commanderbot/ext/mcdoc/mcdoc_options.py | 1 + commanderbot/ext/mcdoc/mcdoc_symbols.py | 29 ++- commanderbot/ext/mcdoc/mcdoc_types.py | 207 ++++++++++++------ commanderbot/lib/predicates.py | 6 + pyproject.toml | 1 + 9 files changed, 187 insertions(+), 95 deletions(-) diff --git a/commanderbot/core/application_emoji_manager.py b/commanderbot/core/application_emoji_manager.py index 35fd814..3858f0f 100644 --- a/commanderbot/core/application_emoji_manager.py +++ b/commanderbot/core/application_emoji_manager.py @@ -5,6 +5,7 @@ from commanderbot.core.exceptions import ApplicationEmojiDoesNotExist from commanderbot.lib import EmojiID +from commanderbot.lib.predicates import is_emoji_id class ApplicationEmojiManager: @@ -19,7 +20,7 @@ def _get_from_cache(self, emoji: str | EmojiID) -> Optional[Emoji]: return e # Try to find the emoji by its ID - if isinstance(emoji, EmojiID): + if is_emoji_id(emoji): for e in self._cache.values(): if e.id == emoji: return e diff --git a/commanderbot/ext/feeds/providers/utils.py b/commanderbot/ext/feeds/providers/utils.py index 9c36130..170ebd3 100644 --- a/commanderbot/ext/feeds/providers/utils.py +++ b/commanderbot/ext/feeds/providers/utils.py @@ -1,6 +1,7 @@ +from collections.abc import Callable, Coroutine from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Any, Callable, Coroutine, Literal, Optional, Self +from datetime import UTC, datetime +from typing import Any, Literal, Optional, Self from commanderbot.lib import FromDataMixin @@ -28,7 +29,7 @@ class MinecraftJavaChangelog(FromDataMixin): @classmethod def try_from_data(cls, data: Any) -> Optional[Self]: if isinstance(data, dict): - date = datetime.strptime(data["date"], "%Y-%m-%dT%H:%M:%S.%fZ") + date = datetime.strptime(data["date"], "%Y-%m-%dT%H:%M:%S.%fZ") # noqa: DTZ007 - We need aware and naive datetimes return cls( id=data["id"], version=data["version"], @@ -38,7 +39,7 @@ def try_from_data(cls, data: Any) -> Optional[Self]: image_title=data["image"]["title"], image_url=data["image"]["url"], date=date, - date_utc=date.astimezone(timezone.utc), + date_utc=date.astimezone(UTC), content_url=data["contentPath"], ) @@ -87,7 +88,7 @@ def try_from_data(cls, data: Any) -> Optional[Self]: type=data["type"], time=datetime.fromisoformat(data["time"]), release_time=release_time, - release_time_utc=release_time.astimezone(timezone.utc), + release_time_utc=release_time.astimezone(UTC), java_version=data["javaVersion"]["majorVersion"], client_jar_url=data["downloads"]["client"]["url"], server_jar_url=data["downloads"]["server"]["url"], @@ -136,9 +137,9 @@ class ZendeskArticle(FromDataMixin): @classmethod def try_from_data(cls, data: Any) -> Optional[Self]: if isinstance(data, dict): - created_at = datetime.strptime(data["created_at"], "%Y-%m-%dT%H:%M:%SZ") - updated_at = datetime.strptime(data["updated_at"], "%Y-%m-%dT%H:%M:%SZ") - edited_at = datetime.strptime(data["edited_at"], "%Y-%m-%dT%H:%M:%SZ") + created_at = datetime.strptime(data["created_at"], "%Y-%m-%dT%H:%M:%SZ") # noqa: DTZ007 - We need aware and naive datetimes + updated_at = datetime.strptime(data["updated_at"], "%Y-%m-%dT%H:%M:%SZ") # noqa: DTZ007 - We need aware and naive datetimes + edited_at = datetime.strptime(data["edited_at"], "%Y-%m-%dT%H:%M:%SZ") # noqa: DTZ007 - We need aware and naive datetimes return cls( id=data["id"], section_id=data["section_id"], @@ -149,8 +150,8 @@ def try_from_data(cls, data: Any) -> Optional[Self]: created_at=created_at, updated_at=updated_at, edited_at=edited_at, - created_at_utc=created_at.astimezone(timezone.utc), - updated_at_utc=updated_at.astimezone(timezone.utc), - edited_at_utc=edited_at.astimezone(timezone.utc), + created_at_utc=created_at.astimezone(UTC), + updated_at_utc=updated_at.astimezone(UTC), + edited_at_utc=edited_at.astimezone(UTC), body=data["body"], ) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index 1bfde8a..3edaa5b 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -121,9 +121,9 @@ async def cmd_mcdoc( version[2:], float ): raise InvalidVersionError(version) - except Exception as ex: + except Exception: await interaction.delete_original_response() - raise ex + raise # Create a context object used for rendering ctx = McdocContext(version, symbols, self._get_emoji) diff --git a/commanderbot/ext/mcdoc/mcdoc_exceptions.py b/commanderbot/ext/mcdoc/mcdoc_exceptions.py index b5073aa..9991a42 100644 --- a/commanderbot/ext/mcdoc/mcdoc_exceptions.py +++ b/commanderbot/ext/mcdoc/mcdoc_exceptions.py @@ -7,18 +7,20 @@ class McdocException(ResponsiveException): class RequestSymbolsError(McdocException): def __init__(self): - super().__init__(f"😵 Unable to fetch vanilla-mcdoc symbol data") + super().__init__("😵 Unable to fetch vanilla-mcdoc symbol data") class RequestVersionError(McdocException): def __init__(self): - super().__init__(f"😵 Unable to fetch the latest version number") + super().__init__("😵 Unable to fetch the latest version number") class InvalidVersionError(McdocException): def __init__(self, version: str): self.version: str = version - super().__init__(f"😬 Invalid version format `{self.version}`. Only release versions are allowed.") + super().__init__( + f"😬 Invalid version format `{self.version}`. Only release versions are allowed." + ) class QueryReturnedNoResults(McdocException): diff --git a/commanderbot/ext/mcdoc/mcdoc_options.py b/commanderbot/ext/mcdoc/mcdoc_options.py index b9468f5..95b77e4 100644 --- a/commanderbot/ext/mcdoc/mcdoc_options.py +++ b/commanderbot/ext/mcdoc/mcdoc_options.py @@ -3,6 +3,7 @@ from commanderbot.lib import FromDataMixin + @dataclass class McdocOptions(FromDataMixin): symbols_url: str diff --git a/commanderbot/ext/mcdoc/mcdoc_symbols.py b/commanderbot/ext/mcdoc/mcdoc_symbols.py index 4c3e458..16c81b0 100644 --- a/commanderbot/ext/mcdoc/mcdoc_symbols.py +++ b/commanderbot/ext/mcdoc/mcdoc_symbols.py @@ -1,10 +1,14 @@ +import re from collections import defaultdict from dataclasses import dataclass from typing import Optional, Union -import re from commanderbot.ext.mcdoc.mcdoc_exceptions import QueryReturnedNoResults -from commanderbot.ext.mcdoc.mcdoc_types import McdocContext, McdocType, deserialize_mcdoc +from commanderbot.ext.mcdoc.mcdoc_types import ( + McdocContext, + McdocType, + deserialize_mcdoc, +) @dataclass @@ -15,7 +19,7 @@ class SymbolResult: def title(self, ctx: McdocContext): name = ctx.symbols.compact_path(self.identifier) return self.typeDef.title(name, ctx) - + def body(self, ctx: McdocContext): return self.typeDef.render(ctx) @@ -27,9 +31,9 @@ class DispatchResult: typeDef: McdocType def title(self, ctx: McdocContext): - name = f"{self.registry.removeprefix("minecraft:")} [{self.identifier}]" + name = f"{self.registry.removeprefix('minecraft:')} [{self.identifier}]" return self.typeDef.title(name, ctx) - + def body(self, ctx: McdocContext): return self.typeDef.render(ctx) @@ -41,7 +45,7 @@ class DisambiguationResult: def title(self, ctx: McdocContext): return f"{len(self.identifiers)} results for {self.query}" - + def body(self, ctx: McdocContext): return "\n".join([f"* {ctx.symbols.compact_path(i)}" for i in self.identifiers]) @@ -54,8 +58,7 @@ def __init__(self, data: dict): } self.dispatchers = { str(registry): { - str(key): deserialize_mcdoc(typeDef) - for key, typeDef in members.items() + str(key): deserialize_mcdoc(typeDef) for key, typeDef in members.items() } for registry, members in data.get("mcdoc/dispatcher", {}).items() } @@ -74,13 +77,17 @@ def __init__(self, data: dict): continue for key in keys: parts = key.split("::") - for i in reversed(range(len(parts)-1)): + for i in reversed(range(len(parts) - 1)): suffix = "::".join(parts[i:]) - if not [k for k in keys if k is not key and k.endswith(f"::{suffix}")]: + if not [ + k for k in keys if k is not key and k.endswith(f"::{suffix}") + ]: self.unique_suffixes[key] = suffix break - def search(self, query: str) -> Union[SymbolResult, DispatchResult, DisambiguationResult]: + def search( + self, query: str + ) -> Union[SymbolResult, DispatchResult, DisambiguationResult]: if query in self.symbols: return SymbolResult(query, self.symbols[query]) diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py index 83707cc..c9b1fef 100644 --- a/commanderbot/ext/mcdoc/mcdoc_types.py +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -1,6 +1,8 @@ import json +from collections.abc import Callable, Mapping from dataclasses import dataclass, field -from typing import Callable, Mapping, Protocol, Optional, Union +from typing import Optional, Protocol, Union + from discord import Emoji @@ -9,14 +11,11 @@ def cmp_version(a: str, b: str): class McdocLookup(Protocol): - def compact_path(self, path: str) -> str: - ... + def compact_path(self, path: str) -> str: ... - def get(self, path: str) -> Optional["McdocType"]: - ... + def get(self, path: str) -> Optional[McdocType]: ... - def dispatch(self, registry: str, identifier: str) -> Optional["McdocType"]: - ... + def dispatch(self, registry: str, identifier: str) -> Optional[McdocType]: ... @dataclass @@ -26,10 +25,10 @@ class McdocContext: emojis: Callable[[str], Emoji] compact: bool = False depth: int = 0 - type_mapping: Mapping[str, Union[str, "McdocType"]] = field(default_factory=dict) - type_args: list["McdocType"] = field(default_factory=list) + type_mapping: Mapping[str, Union[str, McdocType]] = field(default_factory=dict) + type_args: list[McdocType] = field(default_factory=list) - def filter(self, attributes: Optional[list["Attribute"]]): + def filter(self, attributes: Optional[list[Attribute]]): if not attributes: return True since = next((a.value for a in attributes if a.name == "since"), None) @@ -43,17 +42,51 @@ def filter(self, attributes: Optional[list["Attribute"]]): def allow_body(self): return self.depth <= 2 - def make_compact(self) -> "McdocContext": - return McdocContext(self.version, self.symbols, self.emojis, True, self.depth, self.type_mapping, self.type_args) + def make_compact(self) -> McdocContext: + return McdocContext( + self.version, + self.symbols, + self.emojis, + True, + self.depth, + self.type_mapping, + self.type_args, + ) - def nested(self, diff=1) -> "McdocContext": - return McdocContext(self.version, self.symbols, self.emojis, self.compact, self.depth + diff, self.type_mapping, self.type_args) + def nested(self, diff=1) -> McdocContext: + return McdocContext( + self.version, + self.symbols, + self.emojis, + self.compact, + self.depth + diff, + self.type_mapping, + self.type_args, + ) - def with_type_mapping(self, mapping: Mapping[str, Union[str, "McdocType"]]) -> "McdocContext": - return McdocContext(self.version, self.symbols, self.emojis, self.compact, self.depth, mapping, self.type_args) + def with_type_mapping( + self, mapping: Mapping[str, Union[str, McdocType]] + ) -> McdocContext: + return McdocContext( + self.version, + self.symbols, + self.emojis, + self.compact, + self.depth, + mapping, + self.type_args, + ) - def with_type_args(self, type_args: list["McdocType"]) -> "McdocContext": - return McdocContext(self.version, self.symbols, self.emojis, self.compact, self.depth, self.type_mapping, type_args) + def with_type_args(self, type_args: list[McdocType]) -> McdocContext: + return McdocContext( + self.version, + self.symbols, + self.emojis, + self.compact, + self.depth, + self.type_mapping, + type_args, + ) @dataclass @@ -75,7 +108,7 @@ def has_attr(self, name: str): def get_attr(self, name: str) -> Optional[dict]: for a in self.attributes or []: if a.name == name: - return a.value or dict() + return a.value or {} return None def title(self, name: str, ctx: McdocContext) -> str: @@ -153,7 +186,7 @@ def render(self, ctx): @dataclass class IndexedType(McdocBaseType): - child: "McdocType" + child: McdocType parallelIndices: list[DynamicIndex | StaticIndex] def suffix(self, ctx): @@ -164,8 +197,8 @@ def suffix(self, ctx): @dataclass class StructTypePairField: - key: Union[str, "McdocType"] - type: "McdocType" + key: Union[str, McdocType] + type: McdocType optional: bool = False deprecated: bool = False desc: Optional[str] = None @@ -174,7 +207,9 @@ class StructTypePairField: def is_deprecated(self, ctx: McdocContext): if self.deprecated: return True - deprecated = next((a.value for a in self.attributes or [] if a.name == "deprecated"), None) + deprecated = next( + (a.value for a in self.attributes or [] if a.name == "deprecated"), None + ) if deprecated: return cmp_version(ctx.version, deprecated["value"]["value"]) >= 0 return False @@ -183,7 +218,7 @@ def render(self, ctx: McdocContext): if isinstance(self.key, str): key = f"`{self.key}`" elif isinstance(self.key, LiteralType): - key = f"`{str(self.key.value)}`" + key = f"`{self.key.value!s}`" else: key = f"**[** {self.key.suffix(ctx) or '*a key*'} **]**" self.optional = False @@ -198,7 +233,9 @@ def render(self, ctx: McdocContext): result += "**?**" desc = self.desc.strip() if self.desc else "" if desc: - result += "".join(f"\n-# {d.strip()}" for d in desc.split("\n") if d.strip()) + result += "".join( + f"\n-# {d.strip()}" for d in desc.split("\n") if d.strip() + ) if ctx.allow_body(): body = self.type.body(ctx.make_compact()) if body: @@ -208,7 +245,7 @@ def render(self, ctx: McdocContext): @dataclass class StructTypeSpreadField: - type: "McdocType" + type: McdocType attributes: Optional[list[Attribute]] = None def render(self, ctx: McdocContext): @@ -246,8 +283,8 @@ def body_flat(self, ctx: McdocContext): if not fields: return "" results = [] - for field in fields: - result = field.render(ctx) + for struct_field in fields: + result = struct_field.render(ctx) if result: results.append(result) joiner = "\n" if ctx.compact else "\n\n" @@ -298,13 +335,15 @@ def body_flat(self, ctx: McdocContext): if not ctx.allow_body(): return "" results = [] - for field in self.filtered_values(ctx): + for enum_field in self.filtered_values(ctx): result = self.prefix(ctx) - result += f" `{field.identifier}`" - result += f" = {json.dumps(field.value)}" - desc = field.desc.strip() if field.desc else "" + result += f" `{enum_field.identifier}`" + result += f" = {json.dumps(enum_field.value)}" + desc = enum_field.desc.strip() if enum_field.desc else "" if desc: - result += "".join(f"\n-# {d.strip()}" for d in desc.split("\n") if d.strip()) + result += "".join( + f"\n-# {d.strip()}" for d in desc.split("\n") if d.strip() + ) results.append(result) if not results: return "*no options*" @@ -345,9 +384,10 @@ def render(self, ctx): return typeDef.render(ctx) return super().render(ctx) + @dataclass class UnionType(McdocBaseType): - members: list["McdocType"] + members: list[McdocType] def filtered_members(self, ctx: McdocContext): return [m for m in self.members if ctx.filter(m.attributes)] @@ -392,20 +432,47 @@ def body(self, ctx): return "\n".join(f"{'' if ctx.compact else '\n'}{r}" for r in results) -TEMPLATE_CHARS = ["🇦", "🇧", "🇨", "🇩", "🇪", "🇫", "🇬", "🇭", "🇮", "🇯", "🇰", "🇱", "🇲", "🇳", "🇴", "🇵", "🇶", "🇷", "🇸", "🇹", "🇺", "🇻", "🇼", "🇽", "🇾", "🇿"] +TEMPLATE_CHARS = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", +] @dataclass class TemplateType(McdocBaseType): - child: "McdocType" + child: McdocType typeParams: list[str] def abstract_mapping(self): mapping = dict[str, str]() used_chars = set[str]() for param in self.typeParams: - letter = param.split('::')[-1][0].upper() - preferred_char = TEMPLATE_CHARS[ord(letter) - ord('A')] + letter = param.split("::")[-1][0].upper() + preferred_char = TEMPLATE_CHARS[ord(letter) - ord("A")] if preferred_char in used_chars: for char in TEMPLATE_CHARS: if char not in used_chars: @@ -416,7 +483,9 @@ def abstract_mapping(self): def nest_context(self, ctx: McdocContext) -> McdocContext: if ctx.type_args: - return ctx.with_type_mapping(dict(zip(self.typeParams, ctx.type_args))).with_type_args([]) + return ctx.with_type_mapping( + dict(zip(self.typeParams, ctx.type_args)) + ).with_type_args([]) else: return ctx.with_type_mapping(self.abstract_mapping()) @@ -442,8 +511,8 @@ def render(self, ctx): @dataclass class ConcreteType(McdocBaseType): - child: "McdocType" - typeArgs: list["McdocType"] + child: McdocType + typeArgs: list[McdocType] def nest_context(self, ctx: McdocContext) -> McdocContext: return ctx.with_type_args(self.typeArgs) @@ -515,7 +584,7 @@ def suffix(self, ctx): if id["kind"] == "literal": registry = id["value"]["value"] elif id["kind"] == "tree": - registry = id["values"]["registry"]["value"]["value"] # TODO + registry = id["values"]["registry"]["value"]["value"] # TODO if registry: result = f"a {registry}" elif self.has_attr("text_component"): @@ -731,7 +800,7 @@ def suffix(self, ctx): @dataclass class ListType(McdocBaseType): - item: "McdocType" + item: McdocType lengthRange: Optional[NumericRange] = None def icons(self, ctx): @@ -761,7 +830,7 @@ def body(self, ctx): @dataclass class TupleType(McdocBaseType): - items: list["McdocType"] + items: list[McdocType] def icons(self, ctx): return ["list"] @@ -812,7 +881,7 @@ def deserialize_attributes(data: dict) -> list[Attribute]: for attr in data.get("attributes", []): name = attr["name"] value = attr.get("value", None) - result.append(Attribute(name=name,value=value)) + result.append(Attribute(name=name, value=value)) return result @@ -866,29 +935,35 @@ def deserialize_mcdoc(data: dict) -> McdocType: key = f["key"] if isinstance(key, dict): key = deserialize_mcdoc(key) - fields.append(StructTypePairField( - key=key, - type=deserialize_mcdoc(f["type"]), - optional=f.get("optional", False), - deprecated=f.get("deprecated", False), - desc=f.get("desc"), - attributes=deserialize_attributes(f), - )) + fields.append( + StructTypePairField( + key=key, + type=deserialize_mcdoc(f["type"]), + optional=f.get("optional", False), + deprecated=f.get("deprecated", False), + desc=f.get("desc"), + attributes=deserialize_attributes(f), + ) + ) else: - fields.append(StructTypeSpreadField( - type=deserialize_mcdoc(f.get("type")), - attributes=deserialize_attributes(f), - )) + fields.append( + StructTypeSpreadField( + type=deserialize_mcdoc(f.get("type")), + attributes=deserialize_attributes(f), + ) + ) return StructType(fields, attributes=deserialize_attributes(data)) if kind == "enum": values = [] for v in data.get("values", []): - values.append(EnumTypeField( - identifier=v["identifier"], - value=v["value"], - desc=v.get("desc"), - attributes=deserialize_attributes(v), - )) + values.append( + EnumTypeField( + identifier=v["identifier"], + value=v["value"], + desc=v.get("desc"), + attributes=deserialize_attributes(v), + ) + ) return EnumType( enumKind=data["enumKind"], values=values, @@ -898,7 +973,7 @@ def deserialize_mcdoc(data: dict) -> McdocType: return LiteralType( kind=data["value"]["kind"], value=data["value"]["value"], - attributes=deserialize_attributes(data) + attributes=deserialize_attributes(data), ) if kind == "any": return AnyType(attributes=deserialize_attributes(data)) @@ -992,6 +1067,4 @@ def deserialize_mcdoc(data: dict) -> McdocType: typeArgs=[deserialize_mcdoc(t) for t in data["typeArgs"]], attributes=deserialize_attributes(data), ) - return UnknownType( - kind=f"{kind}" - ) + return UnknownType(kind=f"{kind}") diff --git a/commanderbot/lib/predicates.py b/commanderbot/lib/predicates.py index 49191d8..31a497d 100644 --- a/commanderbot/lib/predicates.py +++ b/commanderbot/lib/predicates.py @@ -21,6 +21,7 @@ from commanderbot.lib.types import ( ConnectableChannel, + EmojiID, GuildChannel, MessageableChannel, MessageableGuildChannel, @@ -35,6 +36,7 @@ "is_convertable_to", "is_custom_emoji", "is_dm_channel", + "is_emoji_id", "is_forum_channel", "is_group_dm_channel", "is_guild", @@ -184,3 +186,7 @@ def is_connectable_channel(obj: object) -> TypeIs[ConnectableChannel]: def is_partial_messagable(obj: object) -> TypeIs[PartialMessageable]: return isinstance(obj, PartialMessageable) + + +def is_emoji_id(obj: object) -> TypeIs[EmojiID]: + return isinstance(obj, int) diff --git a/pyproject.toml b/pyproject.toml index 8846c9e..fec22fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dev = ["ruff>=0.16.0"] ignore = [ "UP034", # https://docs.astral.sh/ruff/rules/extraneous-parentheses/ "UP045", # https://docs.astral.sh/ruff/rules/non-pep604-annotation-optional/ + "UP007", # https://docs.astral.sh/ruff/rules/non-pep604-annotation-union/ "E722", # https://docs.astral.sh/ruff/rules/bare-except/ "S110", # https://docs.astral.sh/ruff/rules/try-except-pass/ "BLE001", # https://docs.astral.sh/ruff/rules/blind-except/ From 0f993ba075050ede334e14989ad391a717173eca Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 22:19:40 -0700 Subject: [PATCH 101/158] Fixed issues found by Ty --- commanderbot/core/command_tree.py | 12 ++++---- commanderbot/core/commander_bot.py | 24 ++++++++-------- commanderbot/core/config.py | 1 - commanderbot/core/help_command.py | 4 ++- pyproject.toml | 7 ++++- uv.lock | 44 +++++++++++++++++++++++++++++- 6 files changed, 70 insertions(+), 22 deletions(-) diff --git a/commanderbot/core/command_tree.py b/commanderbot/core/command_tree.py index 26e3b65..e918157 100644 --- a/commanderbot/core/command_tree.py +++ b/commanderbot/core/command_tree.py @@ -2,7 +2,7 @@ from collections.abc import Iterable from dataclasses import dataclass, field from logging import Logger, getLogger -from typing import Optional +from typing import Optional, override from discord import AppCommandType from discord.abc import Snowflake @@ -48,7 +48,7 @@ def update(self, commands: list[AppCommand], *, override: bool = False): def traverse_options(options: list[AppCommandGroup | Argument]): for option in options: if isinstance(option, AppCommandGroup): - self._cache[option.qualified_name] = option # type: ignore + self._cache[option.qualified_name] = option # type: ignore[ty:invalid-assignment] traverse_options(option.options) # Add commands and their options @@ -155,7 +155,7 @@ async def build_guild_cache(self, guilds: Iterable[Snowflake]): self._log.info("Finished building guild command cache.") - # @overrides CommandTree + @override async def fetch_command( self, command_id: int, @@ -175,7 +175,7 @@ async def fetch_command( self.update_cache([command], guild=guild) return command - # @overrides CommandTree + @override async def fetch_commands( self, *, guild: Optional[Snowflake] = None, use_cache: bool = True ) -> list[AppCommand]: @@ -189,7 +189,7 @@ async def fetch_commands( self.update_cache(commands, guild=guild, override=True) return commands - # @overrides CommandTree + @override async def sync(self, *, guild: Optional[Snowflake] = None) -> list[AppCommand]: # Create log messages sync_msg: str = "global app commands" @@ -216,7 +216,7 @@ async def sync(self, *, guild: Optional[Snowflake] = None) -> list[AppCommand]: return commands - # @overrides CommandTree + @override def clear_commands( self, *, diff --git a/commanderbot/core/commander_bot.py b/commanderbot/core/commander_bot.py index 76919da..727a9cf 100644 --- a/commanderbot/core/commander_bot.py +++ b/commanderbot/core/commander_bot.py @@ -3,7 +3,7 @@ from concurrent.futures import ProcessPoolExecutor from datetime import datetime, timedelta from logging import Logger, getLogger -from typing import Any, Optional +from typing import Any, Optional, override from discord import AppInfo, Asset, Attachment, User from discord.ext.commands import Bot, Cog, Context, ExtensionNotFound @@ -44,7 +44,7 @@ def __init__(self, config: Config, sync_tree_on_login: bool = False): # Create an error handling component. self.error_handling = ErrorHandling(log=self.log) - self.tree.on_error = self.on_app_command_error + self.tree.on_error = self.on_app_command_error # type: ignore[ty:invalid-assignment] - Maybe get rid of this in the future? #enhance # Create application emoji manager. self.application_emojis = ApplicationEmojiManager(self) @@ -63,8 +63,8 @@ def uptime(self) -> Optional[timedelta]: @property def command_tree(self) -> CachingCommandTree: - # A hack to get the actual app command tree type for type checkers - return self.tree # type: ignore + assert isinstance(self.tree, CachingCommandTree) + return self.tree def add_event_error_handler(self, handler: EventErrorHandler): self.error_handling.add_event_error_handler(handler) @@ -166,7 +166,7 @@ def _resolve_extension_name(self, name: str, package: Optional[str]) -> str: except ImportError: raise ExtensionNotFound(name) - # @overrides Bot + @override async def load_extension(self, name: str, *, package: Optional[str] = None): try: # Resolve the extension name and get the extension. @@ -181,7 +181,7 @@ async def load_extension(self, name: str, *, package: Optional[str] = None): self.log.exception(f"Failed to load extension: {name}") raise - # @overrides Bot + @override async def unload_extension(self, name: str, *, package: Optional[str] = None): try: # Resolve the extension name and get the extension @@ -196,7 +196,7 @@ async def unload_extension(self, name: str, *, package: Optional[str] = None): self.log.exception(f"Failed to unload extension: {name}") raise - # @overrides Bot + @override async def reload_extension(self, name: str, *, package: Optional[str] = None): try: # Resolve the extension name and get the extension @@ -210,7 +210,7 @@ async def reload_extension(self, name: str, *, package: Optional[str] = None): self.log.exception(f"Failed to reload extension: {name}") raise - # @overrides Bot + @override async def setup_hook(self): # Build application emoji cache before we process extensions. self.log.info("Building application emoji cache...") @@ -237,16 +237,16 @@ async def setup_hook(self): await self.command_tree.build_global_cache() await self.command_tree.build_guild_cache(self.guilds) - # @overrides Bot + # Event handler for `Bot` async def on_connect(self): self.log.warning("Connected to Discord.") self._connected_since = utcnow() - # @overrides Bot + # Event handler for `Bot` async def on_disconnect(self): self.log.warning("Disconnected from Discord.") - # @overrides Bot + # Event handler for `Bot` async def on_error(self, event_method: str, *args: Any, **kwargs: Any): _, ex, _ = sys.exc_info() if isinstance(ex, Exception): @@ -255,7 +255,7 @@ async def on_error(self, event_method: str, *args: Any, **kwargs: Any): else: await super().on_error(event_method, *args, **kwargs) - # @overrides Bot + # Event handler for `Bot` async def on_command_error(self, ctx: Context, ex: Exception): await self.error_handling.on_command_error(ex, ctx) diff --git a/commanderbot/core/config.py b/commanderbot/core/config.py index d0db731..145f880 100644 --- a/commanderbot/core/config.py +++ b/commanderbot/core/config.py @@ -146,7 +146,6 @@ def _serialize_model(self, handler: SerializerFunctionWrapHandler) -> dict: result: dict = handler(self) return {k: v for k, v in result.items() if v} - # @overrides BaseModel @override def model_post_init(self, context: Any): self._extensions_by_name.clear() diff --git a/commanderbot/core/help_command.py b/commanderbot/core/help_command.py index 0a42faa..584a72c 100644 --- a/commanderbot/core/help_command.py +++ b/commanderbot/core/help_command.py @@ -1,8 +1,10 @@ +from typing import override + from discord.ext.commands import DefaultHelpCommand class HelpCommand(DefaultHelpCommand): - # @overrides DefaultHelpCommand + @override def get_ending_note(self) -> str: command_name = self.invoked_with prefix = self.context.clean_prefix diff --git a/pyproject.toml b/pyproject.toml index fec22fa..08ce38a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "allay>=1.3.0", "mccq>=1.0.0", "psutil>=7.0.0", + "types-psutil>=7.2.0", "numpy>=2.5.0", "opencv-python-headless>=5.0.0.0", "pillow>=12.3.0", @@ -55,7 +56,10 @@ Changelog = "https://github.com/MinecraftCommands/commanderbot-py/blob/main/CHAN commanderbot = "commanderbot:__main__" [dependency-groups] -dev = ["ruff>=0.16.0"] +dev = [ + "ruff>=0.16.0", + "ty>=0.0.69", +] [tool.ruff.lint] ignore = [ @@ -72,6 +76,7 @@ ignore = [ "B009", # https://docs.astral.sh/ruff/rules/get-attr-with-constant/ "FLY002", # https://docs.astral.sh/ruff/rules/static-join-to-f-string/ "PIE796", # https://docs.astral.sh/ruff/rules/non-unique-enums/ + "PYI041", # https://docs.astral.sh/ruff/rules/redundant-numeric-union/ ] [tool.uv.sources] diff --git a/uv.lock b/uv.lock index f7f76be..9e34308 100644 --- a/uv.lock +++ b/uv.lock @@ -199,6 +199,8 @@ dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "pyyaml" }, + { name = "ty" }, + { name = "types-psutil" }, ] [package.optional-dependencies] @@ -210,6 +212,7 @@ ocr = [ [package.dev-dependencies] dev = [ { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -233,11 +236,16 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.0" }, { name = "tesserocr", marker = "sys_platform == 'win32' and extra == 'ocr'", url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl" }, { name = "tesserocr", marker = "sys_platform != 'win32' and extra == 'ocr'", specifier = ">=2.10.0" }, + { name = "ty", specifier = ">=0.0.69" }, + { name = "types-psutil", specifier = ">=7.2.0" }, ] provides-extras = ["ocr"] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.16.0" }] +dev = [ + { name = "ruff", specifier = ">=0.16.0" }, + { name = "ty", specifier = ">=0.0.69" }, +] [[package]] name = "cysignals" @@ -773,6 +781,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/e3/ed2623ff7a54bbd45f59c536f9b8923cc797a1125d9d087ea51cc9424e99/tokenstream-1.7.0-py3-none-any.whl", hash = "sha256:fdbb20e8a99b07e94ce88016e5f243a1582630a7459bd2e7a1c786f33543fcf9", size = 16857, upload-time = "2023-08-03T00:21:00.394Z" }, ] +[[package]] +name = "ty" +version = "0.0.69" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5b/7a618632dfe9373b7df572ecd7a08c8f799d772fbc317da82dd3aa363207/ty-0.0.69.tar.gz", hash = "sha256:b65106e9ff24fa76e25e1142fb09c85244e815c40450e3021d2bf652c231bb43", size = 6565094, upload-time = "2026-08-06T10:04:25.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/60/6534092f4d2c15e2491807edd609c2e50d527c1fed957acf40b9f110b64a/ty-0.0.69-py3-none-linux_armv6l.whl", hash = "sha256:98bfd383b273540829af673e7f98b9c1c4bcc8547d12a1a3806cd0bec7f0e087", size = 12364185, upload-time = "2026-08-06T10:03:47.137Z" }, + { url = "https://files.pythonhosted.org/packages/34/2b/5c29689bd4f74c2e3394d983d85e4011b629f2ce3730c9442553b8554bf8/ty-0.0.69-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:964621ddd05771660017c51b4e74078d861d9fc863c21ef2a500db1ab62c9ccf", size = 12042510, upload-time = "2026-08-06T10:03:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/09/46/fa085bde4d23516d7ef14b24736fc5dd7dc498f60f52b3d077e59ffdea20/ty-0.0.69-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3ffea4048dd0da4c9c97393b4be0901098a9065b06fa81be2477cbde65d8a151", size = 11549397, upload-time = "2026-08-06T10:03:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/97b9efb2061dcab6fef1e94a4ad99df0bb45bd2cc15d4f5794c787ee0552/ty-0.0.69-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8684d4a70aadd1eab0f41bdba835e3288ef49db8402a8e6ca81bab52ed5d610", size = 12115567, upload-time = "2026-08-06T10:03:53.79Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c1/a5e0404965093835f3e62544e661784ec0aa8ef0b006ed50af50b19c107e/ty-0.0.69-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:afaaba240ab4122e2069a796836d10be81b4ddb053ae268b3dff962a0b4ca5c7", size = 12149770, upload-time = "2026-08-06T10:03:55.993Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8cad6b205a4abe8a044ca0c84aea71e8ccda29b07a75a5f090e310605580/ty-0.0.69-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ea63ef07d4e33aeb1a775cf5f2c736b3ed22fa6f8b1b608591612c36795044", size = 12941278, upload-time = "2026-08-06T10:03:58.324Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/8766d96b732c2a060d70dc8ccafcc4d6a54109a2a95f1deb0705de88892b/ty-0.0.69-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb3730b1268e92a2907d7aea3afe8dd1b360ae65862f0557080cf479d481b424", size = 13426509, upload-time = "2026-08-06T10:04:00.621Z" }, + { url = "https://files.pythonhosted.org/packages/02/1f/e991b2cde953ea5b94d6a9a4c45c87937bd916bc09235f764407bf471c0a/ty-0.0.69-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a544ff57a752ef186ed40b5a2f44c17402af4cdefeb74a311ca02ebd57c4fca0", size = 13106582, upload-time = "2026-08-06T10:04:02.818Z" }, + { url = "https://files.pythonhosted.org/packages/ea/bb/73538f1b99e3558fd9db87b98698426f0f60fc8666da0b1efd0e70e275eb/ty-0.0.69-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87ed2cbca20caddfdf8e3e14d213ce91b67e75feed78900f4aaf3ef884954028", size = 12708931, upload-time = "2026-08-06T10:04:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/484a5208d74c4ad1155933906295ccdce9aa81a257d8df2ab9e41bd60133/ty-0.0.69-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2684efcbce5b6fe45045faf610b377b50781b6d2aa7e61ea23ecf5b3d2bce421", size = 12985322, upload-time = "2026-08-06T10:04:07.587Z" }, + { url = "https://files.pythonhosted.org/packages/6e/81/b75003f0d4da9ab3bc8fd4f4802f836cb9921ff7e70f460604f7b769a0b5/ty-0.0.69-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da9aeb26fdac1d2214937542b59e0d4d1ba94ec7a3f45444f33c846de1eb1d63", size = 12063910, upload-time = "2026-08-06T10:04:09.835Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/088469f547ef63dceefc4a75826aedee5014f9371dc5171cde931896a82c/ty-0.0.69-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:00e7677cd14ede381f705f71104ea7b8ea0ce217a8634e19a89781953de0e9ad", size = 12166823, upload-time = "2026-08-06T10:04:12.114Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c9/ce88a0bec0d46d8ae180b99c6ec014866fecc4cba1727b5feec8877b2765/ty-0.0.69-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d91965eb799649833d0d6042db09cd03d15289125245337cc46a2606effb7bda", size = 12483136, upload-time = "2026-08-06T10:04:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/63/9e/6fae0ff225a0012642cf72c077e20f8f448c0a80771bc3360e8178fe2f32/ty-0.0.69-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f03359cd8e5c412aa0c181118fa9b9061a4dddaedbb61bac0a424fb0814d402", size = 12799025, upload-time = "2026-08-06T10:04:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/e4/43/78a658d18b2a4ccf35b053392f2213bf12e3c63b2abea512d3b6751d1f4c/ty-0.0.69-py3-none-win32.whl", hash = "sha256:ec460e01586b1eb91894c4a8403bee3e045a47e7a4ada943cc27ce8e348e88cf", size = 11787774, upload-time = "2026-08-06T10:04:18.622Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/88db1f674403f2b81316a853a44a81ed220621fa96f8f7ae586fb6ca7513/ty-0.0.69-py3-none-win_amd64.whl", hash = "sha256:18976ca26a4e28fc3249477f79a695d5502e670803f2e080d89ac905baef3c6e", size = 12864038, upload-time = "2026-08-06T10:04:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/4d/7b/6fc6efd00c69103d70f2bdbe824343089cd70b17b3079170057d3e5a3ac0/ty-0.0.69-py3-none-win_arm64.whl", hash = "sha256:7d4ca3bb74d91cb9947ba3f3b4cb131ad6a2b3ecc76d34040c4ec6092d2e411d", size = 12196693, upload-time = "2026-08-06T10:04:22.902Z" }, +] + +[[package]] +name = "types-psutil" +version = "7.2.2.20260518" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/f1/6901857281d4e8d792492e1495eef6f4f01318a3b6a066486d81000a4511/types_psutil-7.2.2.20260518.tar.gz", hash = "sha256:9f825f631463a5b4d26f19f63aebc9ec25f01140d655026f3ad8a67841f9b331", size = 26660, upload-time = "2026-05-18T06:05:09.389Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/eb/f726339668879819599c74c2e1f0cab760912a4159046942bdae2ad37bd6/types_psutil-7.2.2.20260518-py3-none-any.whl", hash = "sha256:6a3d697665754a60d7b5a41d5a2cff12b53f5e0676d77810cd28ba5e14cb4049", size = 32820, upload-time = "2026-05-18T06:05:08.321Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From cef2f9f8a7593657e943227432770e33cf48c464 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 22:22:03 -0700 Subject: [PATCH 102/158] Fixed issues found by Ty --- .../conditions/image_attachments_contain.py | 7 +- .../automod/conditions/ocr_utils/pipeline.py | 4 +- commanderbot/ext/faq/faq_data.py | 2 +- commanderbot/ext/feeds/feeds_state.py | 2 +- .../ext/feeds/providers/feed_provider_base.py | 6 +- commanderbot/ext/mccq/mccq_manager.py | 2 +- commanderbot/ext/mcdoc/mcdoc_types.py | 154 +++++++++--------- commanderbot/ext/moderation/moderation_cog.py | 11 +- commanderbot/ext/roles/roles_data.py | 2 +- commanderbot/ext/sudo/sudo_cog.py | 34 ++-- commanderbot/ext/vote/vote_cog.py | 7 +- 11 files changed, 123 insertions(+), 108 deletions(-) diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py index fc231e9..8427336 100644 --- a/commanderbot/ext/automod/conditions/image_attachments_contain.py +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -1,7 +1,7 @@ import asyncio import unicodedata from itertools import chain -from typing import Any, Literal, Optional, override +from typing import Any, Literal, Optional, cast, override from pydantic import Field, PositiveInt @@ -31,7 +31,9 @@ class ImageAttachmentsContain(AutomodCondition): type: Literal["image_attachments_contain"] - languages: set[TesseractLanguages] = Field(default_factory=lambda: {"eng"}) + languages: set[TesseractLanguages] = Field( + default_factory=lambda: cast(set[TesseractLanguages], {"eng"}) + ) """ The languages to recognize. If not set, `eng` will be recognized by default. """ @@ -115,6 +117,7 @@ async def _ocr(self, context: AutomodContext) -> Optional[AttachmentID]: ): # Check if the text passes the condition and return the attachment if result := completed_task.result(): + assert isinstance(result, tuple) text, attachment_id = result if self._check_ocr_result(text): return attachment_id diff --git a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py index ada652b..6159261 100644 --- a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py +++ b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py @@ -39,7 +39,7 @@ def get_text( bytes_per_pixel = 1 bytes_per_line = width * bytes_per_pixel api.SetImageBytes( - imagedata=processed.tobytes(), # type: ignore + imagedata=processed.tobytes(), # type: ignore[ty:invalid-argument-type] - Why is this even expecting a string width=width, height=height, bytes_per_pixel=bytes_per_pixel, @@ -144,7 +144,7 @@ def reorient(image: MatLike) -> MatLike: bytes_per_pixel = 1 bytes_per_line = width * bytes_per_pixel api.SetImageBytes( - imagedata=image.tobytes(), # type: ignore + imagedata=image.tobytes(), # type: ignore[ty:invalid-argument-type] - Why is this even expecting a string width=width, height=height, bytes_per_pixel=bytes_per_pixel, diff --git a/commanderbot/ext/faq/faq_data.py b/commanderbot/ext/faq/faq_data.py index 3dcb82e..5eb2475 100644 --- a/commanderbot/ext/faq/faq_data.py +++ b/commanderbot/ext/faq/faq_data.py @@ -714,7 +714,7 @@ async def get_categorized_faqs( maybe_sorted_entries = ( sorted(entries, key=lambda e: e.key) if sort else entries ) - yield (display, maybe_sorted_entries) # type: ignore + yield (display, maybe_sorted_entries) # @implements FaqStore async def get_uncategorized_faqs( diff --git a/commanderbot/ext/feeds/feeds_state.py b/commanderbot/ext/feeds/feeds_state.py index 9ef4350..daaac22 100644 --- a/commanderbot/ext/feeds/feeds_state.py +++ b/commanderbot/ext/feeds/feeds_state.py @@ -115,7 +115,7 @@ async def _send_to_subscriber( # Send the embed msg: Optional[Message] = None try: - msg = await channel.send(content=content, embed=embed, view=view) # type: ignore + msg = await channel.send(content, embed=embed, view=view) # type: ignore[ty:no-matching-overload] - This does work, but we should update this extension to use components v2 #enhance except: pass diff --git a/commanderbot/ext/feeds/providers/feed_provider_base.py b/commanderbot/ext/feeds/providers/feed_provider_base.py index 401a20a..dfd85ac 100644 --- a/commanderbot/ext/feeds/providers/feed_provider_base.py +++ b/commanderbot/ext/feeds/providers/feed_provider_base.py @@ -50,17 +50,17 @@ def cached_items(self) -> int: def start(self): # Assumes that `self._on_poll` is a task from `discord.ext.tasks` self._log.info("Started polling!") - self._on_poll.start() # type: ignore + self._on_poll.start() # type: ignore[ty:unresolved-attribute] - This will work on subclasses def stop(self): # Assumes that `self._on_poll` is a task from `discord.ext.tasks` self._log.info("Stopped polling!") - self._on_poll.stop() # type: ignore + self._on_poll.stop() # type: ignore[ty:unresolved-attribute] - This will work on subclasses def restart(self): # Assumes that `self._on_poll` is a task from `discord.ext.tasks` self._log.info("Restarting...") - self._on_poll.restart() # type: ignore + self._on_poll.restart() # type: ignore[ty:unresolved-attribute] - This will work on subclasses @abstractmethod async def _on_poll(self): diff --git a/commanderbot/ext/mccq/mccq_manager.py b/commanderbot/ext/mccq/mccq_manager.py index ab0e31c..b47b400 100644 --- a/commanderbot/ext/mccq/mccq_manager.py +++ b/commanderbot/ext/mccq/mccq_manager.py @@ -16,7 +16,7 @@ ) from commanderbot.ext.mccq.mccq_options import MCCQManagerOptions -type MCCQQueryResults = QueryResults | tuple[dict[str, list[str]]] +type MCCQQueryResults = QueryResults | dict[str, list[str]] class MCCQManager: diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py index c9b1fef..d331dd8 100644 --- a/commanderbot/ext/mcdoc/mcdoc_types.py +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -172,10 +172,10 @@ def title(self, name, ctx): return typeDef.title(name, ctx) return super().title(name, ctx) - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): return f"{self.registry}[{','.join(i.render() for i in self.parallelIndices)}]" - def render(self, ctx): + def render(self, ctx: McdocContext): match self.parallelIndices: case [StaticIndex(value)]: typeDef = ctx.symbols.dispatch(self.registry, value) @@ -189,7 +189,7 @@ class IndexedType(McdocBaseType): child: McdocType parallelIndices: list[DynamicIndex | StaticIndex] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = self.child.suffix(ctx) result += f"[{','.join(i.render() for i in self.parallelIndices)}]" return result @@ -269,10 +269,10 @@ class StructType(McdocBaseType): def filtered_fields(self, ctx: McdocContext): return [f for f in self.fields if ctx.filter(f.attributes)] - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["struct"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): fields = self.filtered_fields(ctx) if not fields: return "an empty object" @@ -290,14 +290,14 @@ def body_flat(self, ctx: McdocContext): joiner = "\n" if ctx.compact else "\n\n" return joiner.join(results) - def body(self, ctx): + def body(self, ctx: McdocContext): lines = self.body_flat(ctx.nested()) if not lines: return "" start = "" if ctx.compact else "\n" return start + "\n".join(f"> {line}" for line in lines.split("\n")) - def render(self, ctx): + def render(self, ctx: McdocContext): return self.body_flat(ctx) @@ -320,10 +320,10 @@ def filtered_values(self, ctx: McdocContext): def title(self, name, ctx): return f"enum {name}" - def icons(self, ctx): + def icons(self, ctx: McdocContext): return [self.enumKind] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): values = self.filtered_values(ctx) if ctx.allow_body(): return "*one of:*" @@ -349,11 +349,11 @@ def body_flat(self, ctx: McdocContext): return "*no options*" return "\n".join(results) - def body(self, ctx): + def body(self, ctx: McdocContext): lines = self.body_flat(ctx) return "\n".join(f"> {line}" for line in lines.split("\n")) - def render(self, ctx): + def render(self, ctx: McdocContext): return self.body_flat(ctx) @@ -364,7 +364,7 @@ class ReferenceType(McdocBaseType): def title(self, name, ctx): return f"{name} · {ctx.symbols.compact_path(self.path)}" - def icons(self, ctx): + def icons(self, ctx: McdocContext): if self.path in ctx.type_mapping: mapped = ctx.type_mapping[self.path] return mapped if isinstance(mapped, str) else mapped.icons(ctx) @@ -373,12 +373,12 @@ def icons(self, ctx): return typeDef.icons(ctx) return super().icons(ctx) - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): if self.path in ctx.type_mapping: return "" return f"__{ctx.symbols.compact_path(self.path)}__" - def render(self, ctx): + def render(self, ctx: McdocContext): typeDef = ctx.symbols.get(self.path) if typeDef: return typeDef.render(ctx) @@ -392,13 +392,13 @@ class UnionType(McdocBaseType): def filtered_members(self, ctx: McdocContext): return [m for m in self.members if ctx.filter(m.attributes)] - def icons(self, ctx): + def icons(self, ctx: McdocContext): all_icons: list[str] = [] for member in self.filtered_members(ctx): all_icons.extend(member.icons(ctx)) return list(dict.fromkeys(all_icons)) - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): members = self.filtered_members(ctx) if not members: return "*nothing*" @@ -408,7 +408,7 @@ def suffix(self, ctx): return "*one of:*" return f"*one of {len(members)} types*" - def body(self, ctx): + def body(self, ctx: McdocContext): if not ctx.allow_body(): return "" members = self.filtered_members(ctx) @@ -493,19 +493,19 @@ def title(self, name, ctx) -> str: mapping = self.abstract_mapping() return f"{name} < {', '.join(mapping.values())} >" - def icons(self, ctx): + def icons(self, ctx: McdocContext): return self.child.icons(self.nest_context(ctx)) - def prefix(self, ctx): + def prefix(self, ctx: McdocContext): return self.child.prefix(self.nest_context(ctx)) - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): return self.child.suffix(self.nest_context(ctx)) - def body(self, ctx): + def body(self, ctx: McdocContext): return self.child.body(self.nest_context(ctx)) - def render(self, ctx): + def render(self, ctx: McdocContext): return self.child.render(self.nest_context(ctx)) @@ -517,13 +517,13 @@ class ConcreteType(McdocBaseType): def nest_context(self, ctx: McdocContext) -> McdocContext: return ctx.with_type_args(self.typeArgs) - def icons(self, ctx): + def icons(self, ctx: McdocContext): return self.child.icons(self.nest_context(ctx)) - def prefix(self, ctx): + def prefix(self, ctx: McdocContext): return self.child.prefix(self.nest_context(ctx)) - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = self.child.suffix(self.nest_context(ctx)) if isinstance(self.child, ReferenceType): arg_ctx = self.nest_context(ctx).nested(99) @@ -537,7 +537,7 @@ def suffix(self, ctx): result += f" < {', '.join(args)} >" return result - def body(self, ctx): + def body(self, ctx: McdocContext): return self.child.body(self.nest_context(ctx)) @@ -548,7 +548,7 @@ class NumericRange(McdocBaseType): minExcl: bool maxExcl: bool - def render(self): + def render(self, ctx: McdocContext): if self.min is None: return f"below {self.max}" if self.maxExcl else f"at most {self.max}" if self.max is None: @@ -563,21 +563,21 @@ def render(self): return f"exactly {self.min}" return f"between {self.min} and {self.max} (inclusive)" - def render_length(self): + def render_length(self, ctx: McdocContext): if self.min == self.max: return f"of length {self.min}" else: - return f"with length {self.render()}" + return f"with length {self.render(ctx)}" @dataclass class StringType(McdocBaseType): lengthRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["string"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "a string" id = self.get_attr("id") if id is not None: @@ -614,7 +614,7 @@ def suffix(self, ctx): elif self.has_attr("command"): result = "a command" if self.lengthRange: - result += f" with length {self.lengthRange.render()}" + result += f" with length {self.lengthRange.render(ctx)}" return result @@ -623,37 +623,37 @@ class LiteralType(McdocBaseType): kind: str value: bool | str | float - def icons(self, ctx): + def icons(self, ctx: McdocContext): return [self.kind] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): return json.dumps(self.value) @dataclass class AnyType(McdocBaseType): - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["any"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): return "*anything*" @dataclass class UnsafeType(McdocBaseType): - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["any"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): return "*anything*" @dataclass class BooleanType(McdocBaseType): - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["boolean"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): return "a boolean" @@ -661,13 +661,13 @@ def suffix(self, ctx): class ByteType(McdocBaseType): valueRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["byte"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "a byte" if self.valueRange: - result += f" {self.valueRange.render()}" + result += f" {self.valueRange.render(ctx)}" return result @@ -675,13 +675,13 @@ def suffix(self, ctx): class ShortType(McdocBaseType): valueRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["short"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "a short" if self.valueRange: - result += f" {self.valueRange.render()}" + result += f" {self.valueRange.render(ctx)}" return result @@ -689,13 +689,13 @@ def suffix(self, ctx): class IntType(McdocBaseType): valueRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["int"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "an int" if self.valueRange: - result += f" {self.valueRange.render()}" + result += f" {self.valueRange.render(ctx)}" return result @@ -703,13 +703,13 @@ def suffix(self, ctx): class LongType(McdocBaseType): valueRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["long"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "a long" if self.valueRange: - result += f" {self.valueRange.render()}" + result += f" {self.valueRange.render(ctx)}" return result @@ -717,13 +717,13 @@ def suffix(self, ctx): class FloatType(McdocBaseType): valueRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["float"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "a float" if self.valueRange: - result += f" {self.valueRange.render()}" + result += f" {self.valueRange.render(ctx)}" return result @@ -731,13 +731,13 @@ def suffix(self, ctx): class DoubleType(McdocBaseType): valueRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["double"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "a double" if self.valueRange: - result += f" {self.valueRange.render()}" + result += f" {self.valueRange.render(ctx)}" return result @@ -746,17 +746,17 @@ class ByteArrayType(McdocBaseType): valueRange: Optional[NumericRange] = None lengthRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["byte_array"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "a byte array" if self.lengthRange: - result += f" {self.lengthRange.render_length()}" + result += f" {self.lengthRange.render_length(ctx)}" if self.valueRange: if self.lengthRange: result += ", and" - result += f" with values {self.valueRange.render()}" + result += f" with values {self.valueRange.render(ctx)}" return result @@ -765,17 +765,17 @@ class IntArrayType(McdocBaseType): valueRange: Optional[NumericRange] = None lengthRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["int_array"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "an int array" if self.lengthRange: - result += f" {self.lengthRange.render_length()}" + result += f" {self.lengthRange.render_length(ctx)}" if self.valueRange: if self.lengthRange: result += ", and" - result += f" with values {self.valueRange.render()}" + result += f" with values {self.valueRange.render(ctx)}" return result @@ -784,17 +784,17 @@ class LongArrayType(McdocBaseType): valueRange: Optional[NumericRange] = None lengthRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["long_array"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "a long array" if self.lengthRange: - result += f" {self.lengthRange.render_length()}" + result += f" {self.lengthRange.render_length(ctx)}" if self.valueRange: if self.lengthRange: result += ", and" - result += f" with values {self.valueRange.render()}" + result += f" with values {self.valueRange.render(ctx)}" return result @@ -803,18 +803,18 @@ class ListType(McdocBaseType): item: McdocType lengthRange: Optional[NumericRange] = None - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["list"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): result = "a list" if self.lengthRange: - result += f" {self.lengthRange.render_length()}" + result += f" {self.lengthRange.render_length(ctx)}" else: result += " of" return f"{result}:" - def body(self, ctx): + def body(self, ctx: McdocContext): result = self.item.prefix(ctx) suffix = self.item.suffix(ctx) if suffix: @@ -832,10 +832,10 @@ def body(self, ctx): class TupleType(McdocBaseType): items: list[McdocType] - def icons(self, ctx): + def icons(self, ctx: McdocContext): return ["list"] - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): return f"a tuple of length {len(self.items)}" @@ -843,7 +843,7 @@ def suffix(self, ctx): class UnknownType(McdocBaseType): kind: str - def suffix(self, ctx): + def suffix(self, ctx: McdocContext): return f"UNKNOWN (`{self.kind}`)" diff --git a/commanderbot/ext/moderation/moderation_cog.py b/commanderbot/ext/moderation/moderation_cog.py index 12403b8..4f5ed8a 100644 --- a/commanderbot/ext/moderation/moderation_cog.py +++ b/commanderbot/ext/moderation/moderation_cog.py @@ -22,7 +22,7 @@ UserIsNotAMember, ) from commanderbot.ext.moderation.moderation_views import ModerationResponse -from commanderbot.lib import AllowedMentions +from commanderbot.lib import AllowedMentions, is_guild KICK_COMPROMISED_REASON = "Your account is compromised and is sending scam messages. Feel free to rejoin once you've changed your password." @@ -89,7 +89,8 @@ async def cmd_kick( # We do this before kicking in case this is the only mutual server if reason: try: - guild_name: str = interaction.guild.name # type: ignore + assert is_guild(interaction.guild) + guild_name: str = interaction.guild.name kick_dm_view = ModerationResponse( f"👢 You were kicked from `{guild_name}`", reason ) @@ -153,7 +154,8 @@ async def cmd_ban( # We do this before banning in case this is the only mutual server if reason: try: - guild_name: str = interaction.guild.name # type: ignore + assert is_guild(interaction.guild) + guild_name: str = interaction.guild.name ban_dm_view = ModerationResponse( f"🔨 You were banned from `{guild_name}`", reason ) @@ -197,7 +199,8 @@ async def cmd_kick_compromised(self, interaction: Interaction, user: Member): # Attempt to DM # We do this before banning in case this is the only mutual server try: - guild_name: str = interaction.guild.name # type: ignore + assert is_guild(interaction.guild) + guild_name: str = interaction.guild.name dm_view = ModerationResponse( f"👢 You were kicked from `{guild_name}`", KICK_COMPROMISED_REASON ) diff --git a/commanderbot/ext/roles/roles_data.py b/commanderbot/ext/roles/roles_data.py index 190f11c..5cc2ba1 100644 --- a/commanderbot/ext/roles/roles_data.py +++ b/commanderbot/ext/roles/roles_data.py @@ -182,7 +182,7 @@ async def get_role_entries( # @implements RolesStore async def get_all_role_entries(self, guild: Guild) -> list[RoleEntry]: - return self.guilds[guild.id].get_all_role_entries() # type: ignore + return self.guilds[guild.id].get_all_role_entries() # type: ignore[ty:invalid-return-type] # @implements RolesStore async def get_role_entry(self, role: Role) -> Optional[RoleEntry]: diff --git a/commanderbot/ext/sudo/sudo_cog.py b/commanderbot/ext/sudo/sudo_cog.py index 31f9860..f2327ba 100644 --- a/commanderbot/ext/sudo/sudo_cog.py +++ b/commanderbot/ext/sudo/sudo_cog.py @@ -1,7 +1,7 @@ import importlib.util import platform from enum import Enum -from typing import Optional +from typing import Optional, override import psutil from discord import ( @@ -28,7 +28,6 @@ from discord.utils import format_dt from pydantic import BaseModel -from commanderbot.core.commander_bot import CommanderBot from commanderbot.core.config import Config, ConfiguredExtension from commanderbot.core.exceptions import ExtensionIsRequired, ExtensionNotInConfig from commanderbot.core.utils import is_commander_bot @@ -78,9 +77,11 @@ class ExtensionTransformer(Transformer): A transformer that resolves `value` into a `ConfiguredExtension` from the config. """ + @override async def transform( - self, interaction: Interaction[CommanderBot], value: str + self, interaction: Interaction, value: str ) -> ConfiguredExtension: + assert is_commander_bot(interaction.client) resolved_name: str = "" try: resolved_name = importlib.util.resolve_name(value, "commanderbot") @@ -98,9 +99,12 @@ class EnabledExtensionTransformer(ExtensionTransformer): Extends `ExtensionTransformer` to provide autocomplete for enabled extensions. """ + @override async def autocomplete( - self, interaction: Interaction[CommanderBot], value: str - ) -> list[Choice[str]]: + self, interaction: Interaction, value: int | float | str + ) -> list[Choice[int | float | str]]: + assert is_commander_bot(interaction.client) + assert isinstance(value, str) config: Config = interaction.client.config choices: list[Choice] = [] for ext in (ext for ext in config.enabled_extensions if not ext.required): @@ -117,9 +121,12 @@ class DisabledExtensionTransformer(ExtensionTransformer): Extends `ExtensionTransformer` to provide autocomplete for disabled extensions. """ + @override async def autocomplete( - self, interaction: Interaction[CommanderBot], value: str - ) -> list[Choice[str]]: + self, interaction: Interaction, value: int | float | str + ) -> list[Choice[int | float | str]]: + assert is_commander_bot(interaction.client) + assert isinstance(value, str) config: Config = interaction.client.config choices: list[Choice] = [] for ext in (ext for ext in config.disabled_extensions if not ext.required): @@ -136,16 +143,19 @@ class ApplicationEmojiTransformer(Transformer): A transformer that resolves `value` into an application emoji. """ - async def transform( - self, interaction: Interaction[CommanderBot], value: str - ) -> Emoji: + @override + async def transform(self, interaction: Interaction, value: str) -> Emoji: + assert is_commander_bot(interaction.client) if emoji := interaction.client.application_emojis.get(value): return emoji raise CannotFindApplicationEmoji(value) + @override async def autocomplete( - self, interaction: Interaction[CommanderBot], value: str - ) -> list[Choice[str]]: + self, interaction: Interaction, value: int | float | str + ) -> list[Choice[int | float | str]]: + assert is_commander_bot(interaction.client) + assert isinstance(value, str) emojis = interaction.client.application_emojis.get_all() choices: list[Choice] = [] for emoji in emojis: diff --git a/commanderbot/ext/vote/vote_cog.py b/commanderbot/ext/vote/vote_cog.py index 512c95d..07bdc74 100644 --- a/commanderbot/ext/vote/vote_cog.py +++ b/commanderbot/ext/vote/vote_cog.py @@ -1,6 +1,5 @@ import re from collections.abc import Iterable -from typing import cast import emoji from discord import Message @@ -18,7 +17,7 @@ def __init__(self, bot: Bot): @staticmethod def get_emojis(message: Message) -> Iterable[str]: # Get message content and cast it to a string - message_content: str = str(message.clean_content) + message_content: str = message.clean_content # Find unicode emoji in the message found_emojis: list = emoji.emoji_list(message_content) @@ -67,8 +66,8 @@ def get_emojis(message: Message) -> Iterable[str]: async def cmd_vote(self, ctx: Context): # Determine which emoji reactions to seed the message with, silently ignoring # errors raised by any individual emoji. - for message_emoji in self.get_emojis(cast(Message, ctx.message)): + for message_emoji in self.get_emojis(ctx.message): try: - await ctx.message.add_reaction(emoji) + await ctx.message.add_reaction(message_emoji) except: pass From 6a8a0e1db56bceb3b9c84ca4287c3b9a773a3169 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 22:25:16 -0700 Subject: [PATCH 103/158] Fixed issues found by Ty --- commanderbot/lib/allowed_mentions.py | 1 - commanderbot/lib/app_commands/transformers.py | 14 +++++++--- commanderbot/lib/cogs/views.py | 14 +++++----- commanderbot/lib/color.py | 2 -- commanderbot/lib/commands/converters.py | 4 ++- commanderbot/lib/dialogs.py | 6 ++-- commanderbot/lib/intents.py | 11 +++----- commanderbot/lib/json.py | 28 +++++++++---------- 8 files changed, 41 insertions(+), 39 deletions(-) diff --git a/commanderbot/lib/allowed_mentions.py b/commanderbot/lib/allowed_mentions.py index ced150e..69d85e5 100644 --- a/commanderbot/lib/allowed_mentions.py +++ b/commanderbot/lib/allowed_mentions.py @@ -55,7 +55,6 @@ def only_roles(cls) -> Self: # @@ INSTANCE - # @overrides discord.AllowedMentions @override def merge(self, other: discord.AllowedMentions) -> Self: return cast(Self, super().merge(other)) diff --git a/commanderbot/lib/app_commands/transformers.py b/commanderbot/lib/app_commands/transformers.py index acf82b4..497a9c7 100644 --- a/commanderbot/lib/app_commands/transformers.py +++ b/commanderbot/lib/app_commands/transformers.py @@ -1,4 +1,5 @@ from itertools import islice +from typing import override import emoji from discord import Interaction, Message @@ -58,6 +59,7 @@ class EmojiTransformer(Transformer): A transformer that validates that a string is a valid Unicode or Discord emoji """ + @override async def transform(self, interaction: Interaction, value: str) -> str: if emoji.is_emoji(value): return value @@ -70,7 +72,8 @@ class MessageTransformer(Transformer): """ Transforms a valid Discord message link into a `discord.Message` """ - + + @override async def transform(self, interaction: Interaction, value: str) -> Message: # Return early if the string we're trying to transform isn't a valid Discord message link if not is_message_link(value): @@ -80,7 +83,7 @@ async def transform(self, interaction: Interaction, value: str) -> Message: try: ctx = await Context.from_interaction(interaction) return await MessageConverter().convert(ctx, value) - except (CommandError, BadArgument): + except CommandError, BadArgument: raise UnableToFindMessage(value) @@ -91,15 +94,18 @@ class ColorTransformer(Transformer): Also provides autocomplete suggestions """ + @override async def transform(self, interaction: Interaction, value: str) -> Color: try: return Color.from_str(value) except ValueError: raise InvalidColor(value) + @override async def autocomplete( - self, interaction: Interaction, value: str - ) -> list[Choice[str]]: + self, interaction: Interaction, value: int | float |str + ) -> list[Choice[int | float |str]]: + assert isinstance(value, str) colors: list[Choice] = [] for name, color in islice( Color.presets(color_filter=value).items(), MAX_AUTOCOMPLETE_CHOICES diff --git a/commanderbot/lib/cogs/views.py b/commanderbot/lib/cogs/views.py index 151194f..b49335a 100644 --- a/commanderbot/lib/cogs/views.py +++ b/commanderbot/lib/cogs/views.py @@ -1,4 +1,4 @@ -from typing import Generic, Optional, TypeVar +from typing import Generic, Optional, TypeVar, override from discord import Interaction, ui from discord.ext.commands import Bot, Cog @@ -51,15 +51,15 @@ def __init__( super().__init__(title=title, custom_id=custom_id, timeout=timeout) - # @overrides View + @override async def interaction_check(self, interaction: Interaction) -> bool: # Check if this interaction and the original interaction are from the same user return interaction.user == self.original_interaction.user - # @overrides Modal + @override async def on_error(self, interaction: Interaction, error: Exception): # Pipe this error through the command tree's error handler - await self.bot.tree.on_error(interaction, error) # type: ignore + await self.bot.tree.on_error(interaction, error) # type: ignore[ty:missing-argument, ty:invalid-argument-type] - Maybe fix this in the future? #enhance class CogStateView(Generic[CogStateType, CogStoreType], ui.View): # noqa: PYI059, UP046 - Discord.py has issues with the newer generic syntax @@ -102,12 +102,12 @@ def __init__( super().__init__(timeout=timeout) - # @overrides View + @override async def interaction_check(self, interaction: Interaction) -> bool: # Check if this interaction and the original interaction are from the same user return interaction.user == self.original_interaction.user - # @overrides View + @override async def on_error(self, interaction: Interaction, error: Exception, item: ui.Item): # Pipe this error through the command tree's error handler - await self.bot.tree.on_error(interaction, error) # type: ignore + await self.bot.tree.on_error(interaction, error) # type: ignore[ty:missing-argument, ty:invalid-argument-type] - Maybe fix this in the future? #enhance \ No newline at end of file diff --git a/commanderbot/lib/color.py b/commanderbot/lib/color.py index 97c9c0a..b3f538c 100644 --- a/commanderbot/lib/color.py +++ b/commanderbot/lib/color.py @@ -24,7 +24,6 @@ class Color(discord.Colour, FromDataMixin, JsonSerializable): _factories: ClassVar[dict[str, Callable[[], Self]]] = {} - # @overrides discord.Colour @override @classmethod def from_str(cls, value: str) -> Self: @@ -309,7 +308,6 @@ def minecraft_material_resin(cls) -> Self: def to_hex(self) -> str: return str(self) - # @overrides discord.Colour @override def __repr__(self) -> str: return f"0x{self.value:X}" diff --git a/commanderbot/lib/commands/converters.py b/commanderbot/lib/commands/converters.py index 1ea4347..768ef90 100644 --- a/commanderbot/lib/commands/converters.py +++ b/commanderbot/lib/commands/converters.py @@ -1,5 +1,6 @@ import difflib import re +from typing import override import discord from discord.ext import commands @@ -35,6 +36,7 @@ class LenientRoleConverter(commands.RoleConverter): Raises `CannotDisambiguateRole` if multiple roles are matched. """ + @override async def convert(self, ctx: Context, argument: str) -> discord.Role: # Throw exception if we aren't in a guild. if not is_guild(ctx.guild): @@ -105,7 +107,7 @@ def rate_matches( class ColorConverter(commands.ColorConverter): """Extends `commands.ColorConverter`.""" - # @overrides commands.ColorConverter + @override async def convert(self, ctx: Context, argument: str) -> Color: temp: discord.Color = await super().convert(ctx, argument) return Color(temp.value) diff --git a/commanderbot/lib/dialogs.py b/commanderbot/lib/dialogs.py index 24fd5ef..3dcb930 100644 --- a/commanderbot/lib/dialogs.py +++ b/commanderbot/lib/dialogs.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Optional +from typing import Optional, override import discord from discord import ButtonStyle, Interaction, Member, Message, Reaction, ui @@ -87,7 +87,7 @@ def __init__(self, interaction: Interaction, timeout: Optional[float] = None): self.result = ConfirmationResult.NO_RESPONSE super().__init__(timeout=timeout) - # @overrides View + @override async def interaction_check(self, interaction: Interaction) -> bool: if interaction.user != self.original_interaction.user: await interaction.response.send_message( @@ -96,7 +96,7 @@ async def interaction_check(self, interaction: Interaction) -> bool: return False return True - # @overrides View + @override async def on_timeout(self): # Disable all buttons and set their color to gray for button in self.children: diff --git a/commanderbot/lib/intents.py b/commanderbot/lib/intents.py index 96f2f0d..5e1854e 100644 --- a/commanderbot/lib/intents.py +++ b/commanderbot/lib/intents.py @@ -1,6 +1,6 @@ import inspect from collections.abc import Callable -from typing import Any, ClassVar, Optional, Self, cast, override +from typing import Any, ClassVar, Optional, Self, override import discord from pydantic import GetCoreSchemaHandler, TypeAdapter @@ -24,25 +24,22 @@ class Intents(discord.Intents, FromDataMixin, JsonSerializable): _factories: ClassVar[dict[str, Callable[[], Self]]] = {} - # @overrides discord.Intents @override @classmethod def all(cls) -> Self: """ A factory method that creates a :class:`Intents` with everything enabled. """ - return cast(Self, super().all()) + return super().all() # type: ignore[ty:invalid-return-type] - The return type in the parent class is `discord.Intents` instead of `Self` - # @overrides discord.Intents @override @classmethod def none(cls) -> Self: """ A factory method that creates a :class:`Intents` with everything disabled. """ - return cast(Self, super().none()) + return super().none() # type: ignore[ty:invalid-return-type] - The return type in the parent class is `discord.Intents` instead of `Self` - # @overrides discord.Intents @override @classmethod def default(cls) -> Self: @@ -50,7 +47,7 @@ def default(cls) -> Self: A factory method that creates a :class:`Intents` with everything enabled except :attr:`presences`, :attr:`members`, and :attr:`message_content`. """ - return cast(Self, super().default()) + return super().default() # type: ignore[ty:invalid-return-type] - The return type in the parent class is `discord.Intents` instead of `Self` @classmethod def privileged(cls) -> Self: diff --git a/commanderbot/lib/json.py b/commanderbot/lib/json.py index 4293ac3..c6cb94e 100644 --- a/commanderbot/lib/json.py +++ b/commanderbot/lib/json.py @@ -36,20 +36,20 @@ class ExtendedJsonEncoder(json.JSONEncoder): 6. A `discord.Color` is converted into hex format `#FFFFFF` """ - def default(self, obj: Any) -> Any: - if isinstance(obj, JsonSerializable): - return obj.to_json() - if isinstance(obj, set): - return self.convert_set(obj) - if isinstance(obj, datetime): - return self.convert_datetime(obj) - if isinstance(obj, timedelta): - return self.convert_timedelta(obj) - if dataclasses.is_dataclass(obj): - return self.convert_dataclass(obj) - if isinstance(obj, discord.Color): - return self.convert_color(obj) - return super().default(obj) + def default(self, o: Any) -> Any: + if isinstance(o, JsonSerializable): + return o.to_json() + if isinstance(o, set): + return self.convert_set(o) + if isinstance(o, datetime): + return self.convert_datetime(o) + if isinstance(o, timedelta): + return self.convert_timedelta(o) + if dataclasses.is_dataclass(o): + return self.convert_dataclass(o) + if isinstance(o, discord.Color): + return self.convert_color(o) + return super().default(o) def convert_set(self, obj: set[Any]) -> list[Any]: return list(obj) From 18ce7923928c28831d4eafd0481f35f04860e09b Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 23:41:36 -0700 Subject: [PATCH 104/158] Added range validation --- commanderbot/ext/automod/automod_exceptions.py | 6 +++++- .../ext/automod/guards/integer_range.py | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/commanderbot/ext/automod/automod_exceptions.py b/commanderbot/ext/automod/automod_exceptions.py index 13facc4..0cee7de 100644 --- a/commanderbot/ext/automod/automod_exceptions.py +++ b/commanderbot/ext/automod/automod_exceptions.py @@ -79,5 +79,9 @@ def __init__(self, name: str): class OCRNotSupported(AutomodException): def __init__(self): super().__init__( - "Automod does not support OCR. The feature is either not installed or misconfigured." + "Automod does not support OCR. The feature is either not installed or misconfigured" ) + + +class AutomodValidationError(ValueError): + pass diff --git a/commanderbot/ext/automod/guards/integer_range.py b/commanderbot/ext/automod/guards/integer_range.py index 56a539d..56529d0 100644 --- a/commanderbot/ext/automod/guards/integer_range.py +++ b/commanderbot/ext/automod/guards/integer_range.py @@ -1,8 +1,10 @@ -from typing import Any, Optional +from typing import Any, Optional, Self from pydantic import BaseModel, ConfigDict, GetCoreSchemaHandler from pydantic_core import core_schema +from commanderbot.ext.automod.automod_exceptions import AutomodValidationError + __all__ = ("IntegerRangeGuard",) @@ -35,7 +37,16 @@ def excludes(self, value: int) -> bool: def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: + def validate_min_max(guard: Self) -> Self: + if guard.min and guard.max and guard.min > guard.max: + raise AutomodValidationError("'min' is greater than 'max'") + return guard + default_schema = handler(source_type) + from_dict_schema = core_schema.no_info_after_validator_function( + validate_min_max, default_schema + ) + from_int_schema = core_schema.chain_schema( steps=[ core_schema.int_schema(), @@ -52,14 +63,14 @@ def __get_pydantic_core_schema__( json_schema=core_schema.union_schema( [ from_int_schema, - default_schema, + from_dict_schema, ] ), python_schema=core_schema.union_schema( [ core_schema.is_instance_schema(cls), from_int_schema, - default_schema, + from_dict_schema, ] ), serialization=core_schema.plain_serializer_function_ser_schema( From 2c07022ae0dfab534e01b80f85a99c68caed286d Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 6 Aug 2026 23:42:15 -0700 Subject: [PATCH 105/158] Added an action that makes the bot type in the channel in context --- commanderbot/ext/automod/action/types.py | 1 + commanderbot/ext/automod/actions/__init__.py | 1 + .../ext/automod/actions/type_in_channel.py | 48 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 commanderbot/ext/automod/actions/type_in_channel.py diff --git a/commanderbot/ext/automod/action/types.py b/commanderbot/ext/automod/action/types.py index f2fca7f..f9b27d0 100644 --- a/commanderbot/ext/automod/action/types.py +++ b/commanderbot/ext/automod/action/types.py @@ -31,6 +31,7 @@ | actions.ThrowError | actions.TimeoutActor | actions.TimeoutAuthor + | actions.TypeInChannel | actions.Wait ) diff --git a/commanderbot/ext/automod/actions/__init__.py b/commanderbot/ext/automod/actions/__init__.py index 78fdb75..78f4cb0 100644 --- a/commanderbot/ext/automod/actions/__init__.py +++ b/commanderbot/ext/automod/actions/__init__.py @@ -22,4 +22,5 @@ from .throw_error import * from .timeout_actor import * from .timeout_author import * +from .type_in_channel import * from .wait import * diff --git a/commanderbot/ext/automod/actions/type_in_channel.py b/commanderbot/ext/automod/actions/type_in_channel.py new file mode 100644 index 0000000..87f5ce6 --- /dev/null +++ b/commanderbot/ext/automod/actions/type_in_channel.py @@ -0,0 +1,48 @@ +import asyncio +import random +from typing import Literal, Self, override + +from pydantic import Field, PositiveFloat, model_validator + +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.automod_exceptions import AutomodValidationError +from commanderbot.lib.predicates import is_messagable_guild_channel + +__all__ = ("TypeInChannel",) + + +class TypeInChannel(AutomodAction): + """ + Make the bot type in the channel in context for a certain amount of time. + """ + + type: Literal["start_typing"] + + min_time: PositiveFloat = Field(default=1) + """The minimum amount of time to type for, in seconds. Defaults to 1 second.""" + + max_time: PositiveFloat = Field(default=1, le=10) + """The maximum amount of time to type for, in seconds. Defaults to 1 second.""" + + @model_validator(mode="after") + def _validate_model(self) -> Self: + if self.min_time > self.max_time: + raise AutomodValidationError("'min_time' is greater than 'max_time'") + return self + + @override + async def apply(self, context: AutomodContext): + # We need to have a channel in context + channel = context.event.channel + if not channel: + return + + # The channel must be messageable too + if not is_messagable_guild_channel(channel): + return + + # Type in the channel + duration = random.uniform(self.min_time, self.max_time) + async with channel.typing(): + await asyncio.sleep(duration) From 5abf53ab9b0ce338d1447f65069d7a6769677521 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 11 Aug 2026 19:17:30 -0700 Subject: [PATCH 106/158] Updated `uv_build` version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 08ce38a..ef661db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,5 +87,5 @@ module-name = "commanderbot" module-root = "" [build-system] -requires = ["uv_build<0.12"] +requires = ["uv_build<0.13"] build-backend = "uv_build" From 5b14a88c7cfe34487b0a440368dd20013c9c5744 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 12 Aug 2026 04:17:21 -0700 Subject: [PATCH 107/158] Added `LogChannel` --- commanderbot/lib/__init__.py | 1 + commanderbot/lib/log_channel.py | 124 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 commanderbot/lib/log_channel.py diff --git a/commanderbot/lib/__init__.py b/commanderbot/lib/__init__.py index 1e20a29..59afafa 100644 --- a/commanderbot/lib/__init__.py +++ b/commanderbot/lib/__init__.py @@ -10,6 +10,7 @@ from .intents import * from .json import * from .json_serializable import * +from .log_channel import * from .log_options import * from .predicates import * from .role_set import * diff --git a/commanderbot/lib/log_channel.py b/commanderbot/lib/log_channel.py new file mode 100644 index 0000000..baeda16 --- /dev/null +++ b/commanderbot/lib/log_channel.py @@ -0,0 +1,124 @@ +from typing import Optional + +from discord import File, Thread, ui +from discord.ext.commands import Bot +from pydantic import BaseModel, ConfigDict, Field + +from commanderbot.lib.allowed_mentions import AllowedMentions +from commanderbot.lib.color import Color +from commanderbot.lib.constants import MAX_MESSAGE_LENGTH +from commanderbot.lib.exceptions import ResponsiveException +from commanderbot.lib.predicates import is_messagable_guild_channel, is_thread +from commanderbot.lib.types import ChannelID, MessageableGuildChannel +from commanderbot.lib.utils import sanitize_stacktrace, str_to_file + +__all__ = ("LogChannel",) + + +class LogChannel(BaseModel): + """ + Configures a channel to be used for logging. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + channel: ChannelID + """The channel to send log messages in.""" + + emoji: Optional[str] = None + """The emoji used to represent the type of log message.""" + + color: Optional[Color] = None + """The color used to represent the type of log message.""" + + stacktrace: Optional[bool] = None + """Whether to print exception stacktraces.""" + + allowed_mentions: AllowedMentions = Field(default_factory=AllowedMentions.none) + """ + The types of mentions allowed in the log message. Unless otherwise specified, all + mentions will be suppressed. + """ + + async def _require_channel(self, bot: Bot) -> MessageableGuildChannel | Thread: + if channel := bot.get_channel(self.channel): + if is_messagable_guild_channel(channel) or is_thread(channel): + return channel + raise ResponsiveException( + f"Resolved a log channel that isn't messageable: '{self.channel}'" + ) + raise ResponsiveException( + f"Failed to resolve a log channel with ID '{self.channel}'" + ) + + def _format_error(self, exception: Exception) -> str: + msg: Optional[str] = None + if self.stacktrace: + msg = sanitize_stacktrace(exception) + else: + msg = str(exception) + return f"```python\n{msg}\n```" + + async def send( + self, + bot: Bot, + title: str, + content: str, + *, + file_name: str = "log.txt", + allowed_mentions: Optional[AllowedMentions] = None, + ): + """ + Sends a message to the configured log channel. + If the message is too big, it will be sent as a file instead. + """ + channel = await self._require_channel(bot) + allowed_mentions = self.allowed_mentions or AllowedMentions.none() + + # Create log message view + log_view = ui.LayoutView() + log_container = ui.Container(accent_color=self.color) + log_view.add_item(log_container) + + # Add title + formatted_title: Optional[str] = None + if self.emoji: + formatted_title = f"### {self.emoji} {title}" + else: + formatted_title = f"### {title}" + log_container.add_item(ui.TextDisplay(formatted_title)) + + # Add content + files: list[File] = [] + if len(content) > MAX_MESSAGE_LENGTH: + file = str_to_file(content, file_name) + files.append(file) + log_container.add_item(ui.File(file)) + else: + log_container.add_item(ui.TextDisplay(content)) + + # Send the log message + await channel.send( + view=log_view, files=files, allowed_mentions=allowed_mentions + ) + + async def send_error( + self, + bot: Bot, + title: str, + error: Exception, + *, + file_name: str = "error.txt", + allowed_mentions: Optional[AllowedMentions] = None, + ): + """ + Sends an error to the configured log channel. + If the error message is too big, it will be sent as a file instead. + """ + await self.send( + bot, + title, + self._format_error(error), + file_name=file_name, + allowed_mentions=allowed_mentions, + ) From af019e65817af049c9fbb8e92e19285e4a1d0e57 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 12 Aug 2026 04:17:34 -0700 Subject: [PATCH 108/158] Changed formatting --- commanderbot/ext/automod/actions/log_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/actions/log_message.py b/commanderbot/ext/automod/actions/log_message.py index 9571b7b..d240f04 100644 --- a/commanderbot/ext/automod/actions/log_message.py +++ b/commanderbot/ext/automod/actions/log_message.py @@ -119,7 +119,7 @@ def _build_log_view( context_fields = context.get_fields() for field_name, field_title in self.fields.items(): if field_value := context_fields.get(field_name): - field_lines.append(f"- **{field_title}**: `{field_value}`") + field_lines.append(f"- **{field_title}** **-** `{field_value}`") if field_lines: log_container.add_item(ui.Separator()) From 38b7fabbfbfe57f281d92cec0a266388d5e157c9 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 12 Aug 2026 04:18:54 -0700 Subject: [PATCH 109/158] Added default log channel override to rules --- commanderbot/ext/automod/rule/rule.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index 6969c71..503ea52 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -6,6 +6,7 @@ from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.condition import AutomodConditionType from commanderbot.ext.automod.trigger import AutomodTriggerType +from commanderbot.lib.log_channel import LogChannel __all__ = ("AutomodRule",) @@ -26,6 +27,9 @@ class AutomodRule(BaseModel): disabled: Optional[bool] = None """Is the rule disabled?""" + log: Optional[LogChannel] = None + """Override the log channel for this rule.""" + triggers: list[AutomodTriggerType] = Field(min_length=1) """A list of triggers that cause the rule to run.""" From 8ec3f2ef608a574496ba084baa7a4d3d126ee34b Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 12 Aug 2026 04:19:13 -0700 Subject: [PATCH 110/158] Added more exceptions --- .../ext/automod/automod_exceptions.py | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/commanderbot/ext/automod/automod_exceptions.py b/commanderbot/ext/automod/automod_exceptions.py index 0cee7de..e2e6303 100644 --- a/commanderbot/ext/automod/automod_exceptions.py +++ b/commanderbot/ext/automod/automod_exceptions.py @@ -5,6 +5,40 @@ class AutomodException(ResponsiveException): pass +class CouldNotValidateNewAutomodRule(AutomodException): + def __init__(self, exception: ValueError): + self.exception = exception + super().__init__( + f"😵 An error occurred while validating the new automod rule:\n```\n{exception}\n```" + ) + + +class CouldNotValidateModifiedAutomodRule(AutomodException): + def __init__(self, exception: ValueError): + self.exception = exception + super().__init__( + f"😵 An error occurred while validating the modified automod rule:\n```\n{exception}\n```" + ) + + +class CouldNotValidateUploadedAutomodRule(AutomodException): + def __init__(self, exception: ValueError): + self.exception = exception + super().__init__( + f"😵 An error occurred while validating the uploaded automod rule:\n```\n{exception}\n```" + ) + + +class UploadIsNotJsonFile(AutomodException): + def __init__(self): + super().__init__("🤔 The uploaded automod rule is not a Json file") + + +class DefaultLogChannelNotConfigured(AutomodException): + def __init__(self): + super().__init__("🤔 The default log channel has not been configured") + + class AutomodRuleAlreadyExists(AutomodException): def __init__(self, name: str): self.name: str = name @@ -17,13 +51,6 @@ def __init__(self, name: str): super().__init__(f"😬 Automod rule `{self.name}` does not exist") -class CannotModifyAutomodRuleName(AutomodException): - def __init__(self): - super().__init__( - "😬 Automod rule names can't be changed after the rule was created" - ) - - class AutomodRuleAlreadyEnabled(AutomodException): def __init__(self, name: str): self.name: str = name From 9ef53c9781685f3336266cc4608a8f4a1cf74e41 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 12 Aug 2026 04:22:21 -0700 Subject: [PATCH 111/158] Rewrote logic that dispatches events and manages rules --- commanderbot/ext/automod/automod_cog.py | 813 ++++++++---------- commanderbot/ext/automod/automod_data.py | 544 ++++++------ .../ext/automod/automod_guild_state.py | 699 +++++++-------- commanderbot/ext/automod/automod_store.py | 219 ++++- 4 files changed, 1159 insertions(+), 1116 deletions(-) diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index 91b3b0d..1580e38 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -1,101 +1,66 @@ +import json from datetime import datetime -from typing import Optional, cast +from typing import Optional from discord import ( + Attachment, + AutoModAction, + AutoModRuleActionType, Guild, + Interaction, Member, Message, - RawMessageDeleteEvent, - RawMessageUpdateEvent, - RawReactionActionEvent, Reaction, - Role, - TextChannel, Thread, ThreadMember, User, ) -from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context +from discord.abc import Messageable +from discord.app_commands import ( + Group, + allowed_contexts, + allowed_installs, + command, + default_permissions, + describe, +) +from discord.ext.commands import Bot, Cog, GroupCog from commanderbot.ext.automod.automod_data import AutomodData from commanderbot.ext.automod.automod_guild_state import AutomodGuildState -from commanderbot.ext.automod.automod_json_store import AutomodJsonStore from commanderbot.ext.automod.automod_options import AutomodOptions from commanderbot.ext.automod.automod_state import AutomodState from commanderbot.ext.automod.automod_store import AutomodStore -from commanderbot.lib import ( - GuildChannel, - MessageableChannel, - TextMessage, - TextReaction, +from commanderbot.ext.automod.rule.rule import AutomodRule +from commanderbot.lib import is_guild +from commanderbot.lib.cogs import CogGuildStateManager +from commanderbot.lib.databases.json_db import JsonDB +from commanderbot.lib.predicates import ( is_bot, - is_guild, + is_guild_channel, is_member, - is_text_channel, + is_messagable_guild_channel, is_thread, - utils, -) -from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.databases.json_db.v1 import ( - InMemoryDatabaseOptions, - JsonFileDatabaseAdapter, - JsonFileDatabaseOptions, - UnsupportedDatabaseOptions, ) -from commanderbot.lib.commands import ColorConverter, checks - - -def make_automod_store(bot: Bot, cog: Cog, options: AutomodOptions) -> AutomodStore: - db_options = options.database - if isinstance(db_options, InMemoryDatabaseOptions): - return AutomodData() - if isinstance(db_options, JsonFileDatabaseOptions): - return AutomodJsonStore( - bot=bot, - cog=cog, - db=JsonFileDatabaseAdapter( - options=db_options, - serializer=lambda cache: cache.to_data(), - deserializer=AutomodData.from_data, - ), - ) - raise UnsupportedDatabaseOptions(db_options) - - -def member_has_permission(): - async def predicate(ctx: Context): - cog = cast(AutomodCog, ctx.cog) - return ( - is_guild(ctx.guild) - and is_member(ctx.author) - and await cog.state[ctx.guild].member_has_permission(ctx.author) - ) - - return commands.check(predicate) - - -class AutomodCog(Cog, name="commanderbot.ext.automod"): - """ - Automate a variety of moderation tasks. - - Attributes - ---------- - bot - The bot/client instance this cog is attached to. - options - Immutable, pre-defined settings that define core cog behavior. - store - Abstracts the data storage and persistence of this cog. - state - Encapsulates the state and logic of this cog, for each guild. - """ - +from commanderbot.lib.types import GuildChannel +from commanderbot.lib.utils import str_to_file + + +@allowed_installs(guilds=True) +@allowed_contexts(guilds=True) +@default_permissions(administrator=True) +class AutomodCog( + GroupCog, + name="commanderbot.ext.automod", + group_name="automod", + description="Automate a variety of moderation tasks", +): def __init__(self, bot: Bot, **options): self.bot: Bot = bot - self.bot = bot - self.options = AutomodOptions.from_dict(options) - self.store: AutomodStore = make_automod_store(bot, self, self.options) + self.options = AutomodOptions.model_validate(options) + self.store = AutomodStore( + bot=self.bot, cog=self, db=JsonDB(self.options.database, AutomodData) + ) self.state = AutomodState( bot=self.bot, cog=self, @@ -103,456 +68,436 @@ def __init__(self, bot: Bot, **options): bot=self.bot, cog=self, factory=lambda guild: AutomodGuildState( - bot=bot, cog=self, guild=guild, store=self.store + bot=self.bot, cog=self, guild=guild, store=self.store ), ), store=self.store, ) - def _guild_state_for_message(self, message: Message) -> Optional[AutomodGuildState]: - if (is_text_channel(message.channel) or is_thread(message.channel)) and ( - not is_bot(self.bot, message.author) - ): - return self.state[message.channel.guild] + # @@ COMMANDS - def _guild_state_for_member(self, member: Member) -> Optional[AutomodGuildState]: - if not is_bot(self.bot, member): - return self.state[member.guild] + # @@ automod schema + @command(name="schema", description="Export the Json schema for automod rules") + async def cmd_automod_schema(self, interaction: Interaction): + schema = json.dumps(AutomodRule.model_json_schema()) + file = str_to_file(schema, "schema.json") + await interaction.response.send_message( + "Exported Json schema for automod rules", file=file, ephemeral=True + ) - def _guild_state_for_channel( - self, channel: GuildChannel - ) -> Optional[AutomodGuildState]: - if is_text_channel(channel) or is_thread(channel): - return self.state[channel.guild] + # @@ automod log + cmd_automod_log = Group(name="log", description="Configure the default log channel") - def _guild_state_for_thread(self, thread: Thread) -> Optional[AutomodGuildState]: - if (parent := thread.parent) and (guild := parent.guild): - return self.state[guild] + # @@ automod log set - def _guild_state_for_thread_member( - self, member: ThreadMember - ) -> Optional[AutomodGuildState]: - return self._guild_state_for_thread(member.thread) + # @@ automod log modify - def _guild_state_for_channel_user( - self, channel: MessageableChannel, user: User - ) -> Optional[AutomodGuildState]: - if ( - (is_text_channel(channel) or is_thread(channel)) - and is_member(user) - and (not is_bot(self.bot, user)) - ): - return self.state[channel.guild] - - def _guild_state_for_reaction_actor( - self, reaction: Reaction, actor: User - ) -> Optional[AutomodGuildState]: - if ( - ( - is_text_channel(reaction.message.channel) - or is_thread(reaction.message.channel) - ) - and is_member(actor) - and (not is_bot(self.bot, actor)) - ): - return self.state[reaction.message.channel.guild] + # @@ automod log remove - # @@ EVENT LISTENERS + # @@ automod log details - @Cog.listener() - async def on_typing(self, channel: MessageableChannel, user: User, when: datetime): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_typing - if guild_state := self._guild_state_for_channel_user(channel, user): - await guild_state.on_typing( - channel=cast(TextChannel, channel), - member=cast(Member, user), - when=when, - ) + # @@ automod rules + cmd_automod_rules = Group(name="rules", description="Manage automod rules") + + # @@ automod rules add + @cmd_automod_rules.command(name="add", description="Add a new automod rule") + async def cmd_automod_rules_all(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].add_rule(interaction) + + # @@ automod rules modify + @cmd_automod_rules.command(name="modify", description="Modify an automod rule") + @describe(rule="The automod rule to modify") + async def cmd_automod_rules_modify(self, interaction: Interaction, rule: str): + assert is_guild(interaction.guild) + await self.state[interaction.guild].modify_rule(interaction, rule) + + # @@ automod rules upload + @cmd_automod_rules.command(name="upload", description="Upload an automod rule") + @describe( + file="A Json file containing an automod rule (If the rule already exists, it will be modified)" + ) + async def cmd_automod_rules_upload( + self, interaction: Interaction, file: Attachment + ): + assert is_guild(interaction.guild) + await self.state[interaction.guild].upload_rule(interaction, file) - @Cog.listener() - async def on_message(self, message: Message): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_message - if guild_state := self._guild_state_for_message(message): - await guild_state.on_message( - message=cast(TextMessage, message), - ) + # @@ automod rules remove + @cmd_automod_rules.command(name="remove", description="Remove an automod rule") + @describe(rule="The automod rule to remove") + async def cmd_automod_rules_remove(self, interaction: Interaction, rule: str): + assert is_guild(interaction.guild) + await self.state[interaction.guild].remove_rule(interaction, rule) - @Cog.listener() - async def on_message_delete(self, message: Message): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_message_delete - if guild_state := self._guild_state_for_message(message): - await guild_state.on_message_delete( - message=cast(TextMessage, message), - ) + # @@ automod rules details + @cmd_automod_rules.command( + name="details", description="Show the details about an automod rule" + ) + @describe(rule="The automod rule to show details about") + async def cmd_automod_rules_details(self, interaction: Interaction, rule: str): + assert is_guild(interaction.guild) + await self.state[interaction.guild].show_rule_details(interaction, rule) + + # @@ automod rules list + @cmd_automod_rules.command(name="list", description="List all automod rules") + async def cmd_automod_rules_list(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].list_rules(interaction) + + # @@ automod rules enable + @cmd_automod_rules.command(name="enable", description="Enable an automod rule") + @describe(rule="The automod rule to enable") + async def cmd_automod_rules_enable(self, interaction: Interaction, rule: str): + assert is_guild(interaction.guild) + await self.state[interaction.guild].enable_rule(interaction, rule) + + # @@ automod rules disable + @cmd_automod_rules.command(name="disable", description="disable an automod rule") + @describe(rule="The automod rule to disable") + async def cmd_automod_rules_disable(self, interaction: Interaction, rule: str): + assert is_guild(interaction.guild) + await self.state[interaction.guild].disable_rule(interaction, rule) + + # @@ automod rules enable-all + @cmd_automod_rules.command( + name="enable-all", description="Enable all automod rules" + ) + async def cmd_automod_rules_enable_all(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].enable_all_rule(interaction) - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_message_edit - if guild_state := self._guild_state_for_message(after): - await guild_state.on_message_edit( - before=cast(TextMessage, before), - after=cast(TextMessage, after), - ) + # @@ automod rules disable-all + @cmd_automod_rules.command( + name="disable-all", description="Disable all automod rules" + ) + async def cmd_automod_rules_disable_all(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].disable_all_rule(interaction) + + # @@ automod buckets + cmd_automod_buckets = Group(name="buckets", description="Manage automod buckets") + + # @@ automod buckets add + + # @@ automod buckets modify + + # @@ automod buckets remove + + # @@ automod buckets details + + # @@ automod buckets list + + # @@ automod buckets clear + + # @@ automod buckets enable + + # @@ automod buckets disable + + # @@ automod buckets clear-all + + # @@ automod buckets enable-all + + # @@ automod buckets disable-all + + # @@ LISTENERS + + # @@ DISCORD AUTOMOD @Cog.listener() - async def on_reaction_add(self, reaction: Reaction, user: User): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_reaction_add - if guild_state := self._guild_state_for_reaction_actor(reaction, user): - await guild_state.on_reaction_add( - reaction=cast(TextReaction, reaction), - member=cast(Member, user), - ) + async def on_automod_action(self, execution: AutoModAction): + guild_state = self.state[execution.guild] + match execution.action.type: + case AutoModRuleActionType.block_message: + await guild_state.on_discord_automod_action_block_message(execution) + case AutoModRuleActionType.timeout: + await guild_state.on_discord_automod_action_timeout(execution) + case AutoModRuleActionType.block_member_interactions: + await guild_state.on_discord_automod_action_block_member_interactions( + execution + ) + + # @@ CHANNELS @Cog.listener() - async def on_reaction_remove(self, reaction: Reaction, user: User): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_reaction_remove - if guild_state := self._guild_state_for_reaction_actor(reaction, user): - await guild_state.on_reaction_remove( - reaction=cast(TextReaction, reaction), - member=cast(Member, user), - ) + async def on_guild_channel_delete(self, channel: GuildChannel): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_delete + """ + + if is_guild_channel(channel): + await self.state[channel.guild].on_guild_channel_deleted(channel) @Cog.listener() async def on_guild_channel_create(self, channel: GuildChannel): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_guild_channel_create - if guild_state := self._guild_state_for_channel(channel): - await guild_state.on_channel_create( - channel=cast(TextChannel | Thread, channel), - ) + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_create + """ - @Cog.listener() - async def on_guild_channel_delete(self, channel: GuildChannel): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_guild_channel_delete - if guild_state := self._guild_state_for_channel(channel): - await guild_state.on_channel_delete( - channel=cast(TextChannel | Thread, channel), - ) + if is_guild_channel(channel): + await self.state[channel.guild].on_guild_channel_created(channel) @Cog.listener() async def on_guild_channel_update(self, before: GuildChannel, after: GuildChannel): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_guild_channel_update - if guild_state := self._guild_state_for_channel(after): - await guild_state.on_channel_update( - before=cast(TextChannel | Thread, before), - after=cast(TextChannel | Thread, after), - ) + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_update + """ - # @@ THREADS + if is_guild_channel(before) and is_guild_channel(after): + await self.state[after.guild].on_guild_channel_updated(before, after) @Cog.listener() - async def on_thread_create(self, thread: Thread): - # https://discordpy.readthedocs.io/en/stable/api.html#discord.on_thread_join - if guild_state := self._guild_state_for_thread(thread): - await guild_state.on_thread_create(thread) + async def on_guild_channel_pins_update( + self, channel: GuildChannel | Thread, last_pin: Optional[datetime] + ): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_channel_pins_update + """ - @Cog.listener() - async def on_thread_join(self, thread: Thread): - # https://discordpy.readthedocs.io/en/stable/api.html#discord.on_thread_join - if guild_state := self._guild_state_for_thread(thread): - await guild_state.on_thread_join(thread) + if is_messagable_guild_channel(channel) or is_thread(channel): + await self.state[channel.guild].on_guild_channel_pins_updated( + channel, last_pin + ) @Cog.listener() - async def on_thread_update(self, before: Thread, after: Thread): - # https://discordpy.readthedocs.io/en/stable/api.html#discord.on_thread_update - if guild_state := self._guild_state_for_thread(after): - await guild_state.on_thread_update(before, after) + async def on_typing(self, channel: Messageable, user: User, when: datetime): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_typing + """ - @Cog.listener() - async def on_thread_remove(self, thread: Thread): - # https://discordpy.readthedocs.io/en/stable/api.html#discord.on_thread_remove - if guild_state := self._guild_state_for_thread(thread): - await guild_state.on_thread_remove(thread) + if is_bot(self.bot, user): + return - @Cog.listener() - async def on_thread_delete(self, thread: Thread): - # https://discordpy.readthedocs.io/en/stable/api.html#discord.on_thread_delete - if guild_state := self._guild_state_for_thread(thread): - await guild_state.on_thread_delete(thread) + if not is_member(user): + return - @Cog.listener() - async def on_thread_member_join(self, member: ThreadMember): - # https://discordpy.readthedocs.io/en/stable/api.html#discord.on_thread_member_join - if guild_state := self._guild_state_for_thread_member(member): - await guild_state.on_thread_member_join(member) + if not (is_messagable_guild_channel(channel) or is_thread(channel)): + return - @Cog.listener() - async def on_thread_member_remove(self, member: ThreadMember): - # https://discordpy.readthedocs.io/en/stable/api.html#discord.on_thread_member_remove - if guild_state := self._guild_state_for_thread_member(member): - await guild_state.on_thread_member_remove(member) + await self.state[channel.guild].on_member_typing(channel, user, when) # @@ MEMBERS + def _guild_state_for_member(self, member: Member) -> Optional[AutomodGuildState]: + if not is_bot(self.bot, member): + return self.state[member.guild] + @Cog.listener() async def on_member_join(self, member: Member): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_member_join + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_join + """ + if guild_state := self._guild_state_for_member(member): - await guild_state.on_member_join(member) + await guild_state.on_member_joined(member) @Cog.listener() async def on_member_remove(self, member: Member): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_member_remove + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_remove + """ + if guild_state := self._guild_state_for_member(member): - await guild_state.on_member_remove(member) + await guild_state.on_member_left(member) @Cog.listener() async def on_member_update(self, before: Member, after: Member): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_member_update + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_update + """ + if guild_state := self._guild_state_for_member(after): - await guild_state.on_member_update(before, after) + await guild_state.on_member_updated(before, after) @Cog.listener() async def on_user_update(self, before: User, after: User): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_user_update - # Go through every guild we can see, and check if the user is a member there. - # For every guild the user is a member of, run the event handler. + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_user_update + """ + + # Go through every guild we can see, and check if the user is a member there + # For every guild the user is a member of, run the event handler for guild in self.bot.guilds: - guild: Guild if member := guild.get_member(after.id): if guild_state := self._guild_state_for_member(member): - await guild_state.on_user_update(before, after, member) + await guild_state.on_user_updated(before, after) @Cog.listener() async def on_member_ban(self, guild: Guild, user: User): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_member_ban - guild_state = self.state[guild] - await guild_state.on_user_ban(user) + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_ban + """ + + await self.state[guild].on_user_banned(user) @Cog.listener() async def on_member_unban(self, guild: Guild, user: User): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_member_unban - guild_state = self.state[guild] - await guild_state.on_user_unban(user) + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_unban + """ - # @@ RAW EVENT LISTENERS + await self.state[guild].on_user_unbanned(user) + + # @@ MESSAGES @Cog.listener() - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_raw_message_edit - if (guild_id := payload.guild_id) is not None: - await self.state[guild_id].on_raw_message_edit(payload) + async def on_message(self, message: Message): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_message + """ + + if is_bot(self.bot, message.author): + return + + if not is_member(message.author): + return + + channel = message.channel + if not (is_messagable_guild_channel(channel) or is_thread(channel)): + return + + await self.state[channel.guild].on_message_sent(message) @Cog.listener() - async def on_raw_message_delete(self, payload: RawMessageDeleteEvent): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_raw_message_delete - if (guild_id := payload.guild_id) is not None: - await self.state[guild_id].on_raw_message_delete(payload) + async def on_message_edit(self, before: Message, after: Message): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_message_edit + """ + + if is_bot(self.bot, after.author): + return + + if not is_member(after.author): + return + + channel = after.channel + if not (is_messagable_guild_channel(channel) or is_thread(channel)): + return + + await self.state[channel.guild].on_message_edited(before, after) @Cog.listener() - async def on_raw_reaction_add(self, payload: RawReactionActionEvent): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_raw_reaction_add - if (guild_id := payload.guild_id) is not None: - await self.state[guild_id].on_raw_reaction_add(payload) + async def on_message_delete(self, message: Message): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_message_delete + """ + + if is_bot(self.bot, message.author): + return + + channel = message.channel + if not (is_messagable_guild_channel(channel) or is_thread(channel)): + return + + await self.state[channel.guild].on_message_deleted(message) + + # @@ REACTIONS @Cog.listener() - async def on_raw_reaction_remove(self, payload: RawReactionActionEvent): - # https://discordpy.readthedocs.io/en/stable/api.html?highlight=events#discord.on_raw_reaction_remove - if (guild_id := payload.guild_id) is not None: - await self.state[guild_id].on_raw_reaction_remove(payload) + async def on_reaction_add(self, reaction: Reaction, user: User | Member): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_reaction_add + """ - # @@ COMMANDS + if is_bot(self.bot, user): + return - # @@ automod + if not is_member(user): + return - @commands.group( - name="automod", - brief="Manage automod features.", - aliases=["am"], - ) - @checks.guild_only() - @checks.any_of( - checks.is_administrator(), - member_has_permission(), - commands.is_owner(), - ) - async def cmd_automod(self, ctx: Context): - if not ctx.invoked_subcommand: - await ctx.send_help(self.cmd_automod) + channel = reaction.message.channel + if not (is_messagable_guild_channel(channel) or is_thread(channel)): + return - # @@ automod options + await self.state[channel.guild].on_reaction_added(reaction, user) - @cmd_automod.group( - name="options", - brief="Configure various automod options.", - ) - async def cmd_automod_options(self, ctx: Context): - if not ctx.invoked_subcommand: - await ctx.send_help(self.cmd_automod_options) + @Cog.listener() + async def on_reaction_remove(self, reaction: Reaction, user: User | Member): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_reaction_remove + """ - # @@ automod options log + if is_bot(self.bot, user): + return - @cmd_automod_options.group( - name="log", - brief="Configure the default logging behavior.", - ) - async def cmd_automod_options_log(self, ctx: Context): - if not ctx.invoked_subcommand: - if ctx.subcommand_passed: - await ctx.send_help(self.cmd_automod_options_log) - else: - assert is_guild(ctx.guild) - await self.state[ctx.guild].show_default_log_options(ctx) - - @cmd_automod_options_log.command( - name="show", - brief="Show the default logging behavior.", - ) - async def cmd_automod_options_log_show(self, ctx: Context): - assert is_guild(ctx.guild) - await self.state[ctx.guild].show_default_log_options(ctx) + if not is_member(user): + return - @cmd_automod_options_log.command( - name="set", - brief="Set the default logging behavior.", - ) - async def cmd_automod_options_log_set( - self, - ctx: Context, - channel: TextChannel, - stacktrace: Optional[bool], - emoji: Optional[str], - color: Optional[ColorConverter], - ): - assert is_guild(ctx.guild) - await self.state[ctx.guild].set_default_log_options( - ctx, - channel=channel, - stacktrace=stacktrace, - emoji=emoji, - color=color, - ) + channel = reaction.message.channel + if not (is_messagable_guild_channel(channel) or is_thread(channel)): + return - @cmd_automod_options_log.command( - name="remove", - brief="Remove the default logging behavior.", - ) - async def cmd_automod_options_log_remove(self, ctx: Context): - assert is_guild(ctx.guild) - await self.state[ctx.guild].remove_default_log_options(ctx) + await self.state[channel.guild].on_reaction_removed(reaction, user) - # @@ automod options permit + # @@ THREADS - # NOTE Only guild admins and bot owners can manage permitted roles. + def _guild_state_for_thread(self, thread: Thread) -> Optional[AutomodGuildState]: + if (parent := thread.parent) and (guild := parent.guild): + return self.state[guild] - @cmd_automod_options.group( - name="permit", - brief="Configure the set of roles permitted to manage automod.", - ) - @checks.is_guild_admin_or_bot_owner() - async def cmd_automod_options_permit(self, ctx: Context): - if not ctx.invoked_subcommand: - if ctx.subcommand_passed: - await ctx.send_help(self.cmd_automod_options_permit) - else: - assert is_guild(ctx.guild) - await self.state[ctx.guild].show_permitted_roles(ctx) - - @cmd_automod_options_permit.command( - name="show", - brief="Show the roles permitted to manage automod.", - ) - async def cmd_automod_options_permit_show(self, ctx: Context): - assert is_guild(ctx.guild) - await self.state[ctx.guild].show_permitted_roles(ctx) + def _guild_state_for_thread_member( + self, member: ThreadMember + ) -> Optional[AutomodGuildState]: + return self._guild_state_for_thread(member.thread) - @cmd_automod_options_permit.command( - name="set", - brief="Set the roles permitted to manage automod.", - ) - async def cmd_automod_options_permit_set(self, ctx: Context, *roles: Role): - assert is_guild(ctx.guild) - await self.state[ctx.guild].set_permitted_roles(ctx, *roles) + @Cog.listener() + async def on_thread_create(self, thread: Thread): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_create + """ - @cmd_automod_options_permit.command( - name="clear", - brief="Clear all roles permitted to manage automod.", - ) - async def cmd_automod_options_permit_clear(self, ctx: Context): - assert is_guild(ctx.guild) - await self.state[ctx.guild].clear_permitted_roles(ctx) + if guild_state := self._guild_state_for_thread(thread): + await guild_state.on_thread_created(thread) - # @@ automod rules + @Cog.listener() + async def on_thread_join(self, thread: Thread): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_join + """ + if guild_state := self._guild_state_for_thread(thread): + await guild_state.on_thread_joined(thread) - @cmd_automod.group( - name="rules", - brief="Browse and manage automod rules.", - ) - async def cmd_automod_rules(self, ctx: Context): - if not ctx.invoked_subcommand: - if ctx.subcommand_passed: - await ctx.send_help(self.cmd_automod_rules) - else: - assert is_guild(ctx.guild) - await self.state[ctx.guild].show_rules(ctx) + @Cog.listener() + async def on_thread_update(self, before: Thread, after: Thread): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_update + """ - @cmd_automod_rules.command( - name="show", - brief="List and show automod rules.", - ) - async def cmd_automod_rules_show(self, ctx: Context, query: str): - assert is_guild(ctx.guild) - await self.state[ctx.guild].show_rules(ctx, query) + if guild_state := self._guild_state_for_thread(after): + await guild_state.on_thread_updated(before, after) - @cmd_automod_rules.command( - name="print", - brief="Print the code of an automod rule.", - ) - async def cmd_automod_rules_print( - self, - ctx: Context, - query: str, - path: Optional[str], - ): - parsed_path = utils.parse_json_path(path) if path else None - assert is_guild(ctx.guild) - await self.state[ctx.guild].print_rule(ctx, query, parsed_path) + @Cog.listener() + async def on_thread_remove(self, thread: Thread): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_remove + """ - @cmd_automod_rules.command( - name="add", - brief="Add a new automod rule.", - ) - async def cmd_automod_rules_add(self, ctx: Context, *, body: str): - assert is_guild(ctx.guild) - await self.state[ctx.guild].add_rule(ctx, body) + if guild_state := self._guild_state_for_thread(thread): + await guild_state.on_thread_removed(thread) - @cmd_automod_rules.command( - name="remove", - brief="Remove an automod rule.", - ) - async def cmd_automod_rules_remove(self, ctx: Context, name: str): - assert is_guild(ctx.guild) - await self.state[ctx.guild].remove_rule(ctx, name) + @Cog.listener() + async def on_thread_delete(self, thread: Thread): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_delete + """ - @cmd_automod_rules.command( - name="modify", - brief="Modify an automod rule", - ) - async def cmd_automod_rules_modify( - self, - ctx: Context, - name: str, - path: str, - op: str, - *, - body: str, - ): - parsed_path = utils.parse_json_path(path) - parsed_op = utils.parse_json_path_op(op) - assert is_guild(ctx.guild) - await self.state[ctx.guild].modify_rule(ctx, name, parsed_path, parsed_op, body) + if guild_state := self._guild_state_for_thread(thread): + await guild_state.on_thread_deleted(thread) - @cmd_automod_rules.command( - name="enable", - brief="Enable an automod rule", - ) - async def cmd_automod_rules_enable(self, ctx: Context, name: str): - assert is_guild(ctx.guild) - await self.state[ctx.guild].enable_rule(ctx, name) + @Cog.listener() + async def on_thread_member_join(self, member: ThreadMember): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_member_join + """ - @cmd_automod_rules.command( - name="disable", - brief="Disable an automod rule", - ) - async def cmd_automod_rules_disable(self, ctx: Context, name: str): - assert is_guild(ctx.guild) - await self.state[ctx.guild].disable_rule(ctx, name) + if guild_state := self._guild_state_for_thread_member(member): + await guild_state.on_thread_member_joined(member) + + @Cog.listener() + async def on_thread_member_leave(self, member: ThreadMember): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_member_leave + """ + + if guild_state := self._guild_state_for_thread_member(member): + await guild_state.on_thread_member_left(member) diff --git a/commanderbot/ext/automod/automod_data.py b/commanderbot/ext/automod/automod_data.py index c6b9d08..bb01c27 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -1,323 +1,331 @@ from collections import defaultdict -from dataclasses import dataclass, field -from typing import Any, AsyncIterable, Iterable, Optional, Self, Type +from collections.abc import Iterable +from typing import Annotated, Any, Optional from discord import Guild from discord.utils import utcnow - -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_rule import AutomodRule -from commanderbot.lib import ( - GuildID, - JsonObject, - LogOptions, - ResponsiveException, - RoleSet, - to_data, - utils, +from pydantic import BaseModel, Field, PrivateAttr + +from commanderbot.ext.automod.automod_exceptions import ( + AutomodBucketAlreadyDisabled, + AutomodBucketAlreadyEnabled, + AutomodBucketDoesNotExist, + AutomodRuleAlreadyDisabled, + AutomodRuleAlreadyEnabled, + AutomodRuleAlreadyExists, + AutomodRuleDoesNotExist, + AutomodRuleMetadataAlreadyExists, + AutomodRuleMetadataDoesNotExist, + DefaultLogChannelNotConfigured, ) +from commanderbot.ext.automod.bucket import AutomodBucket +from commanderbot.ext.automod.bucket.types import AutomodBucketType +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.rule import AutomodRule, AutomodRuleMetadata +from commanderbot.lib.log_channel import LogChannel +from commanderbot.lib.types import GuildID, UserID + + +class AutomodGuildData(BaseModel): + default_log: Optional[LogChannel] = None + rules: dict[str, AutomodRule] = Field( + default_factory=dict, exclude_if=lambda v: not v + ) + rule_metadata: dict[str, AutomodRuleMetadata] = Field( + default_factory=dict, exclude_if=lambda v: not v + ) + buckets: dict[str, AutomodBucketType] = Field( + default_factory=dict, exclude_if=lambda v: not v + ) -RulesByEventType = defaultdict[Type[AutomodEvent], set[AutomodRule]] + _rules_by_event_type: defaultdict[type[AutomodEvent], set[AutomodRule]] = ( + PrivateAttr(default_factory=lambda: defaultdict(set)) + ) + def model_post_init(self, context: Any) -> None: + self._rebuild_mappings() -class AutomodRuleWithNameAlreadyExists(ResponsiveException): - def __init__(self, name: str): - self.name: str = name - super().__init__(f"A rule with the name `{name}` already exists") + def _rebuild_mappings(self): + self._rules_by_event_type.clear() + for rule in self.rules.values(): + for trigger in rule.triggers: + for event_type in trigger.event_types: + self._rules_by_event_type[event_type].add(rule) + def require_default_log_channel( + self, + ) -> LogChannel: + if log_channel := self.default_log: + return log_channel + raise DefaultLogChannelNotConfigured -class AutomodNoRuleWithName(ResponsiveException): - def __init__(self, name: str): - self.name: str = name - super().__init__(f"There is no rule with the name `{name}`") + def get_default_log_channel( + self, + ) -> Optional[LogChannel]: + return self.default_log + def set_default_log_channel(self, log_channel: LogChannel) -> LogChannel: + self.default_log = log_channel + return self.default_log -class AutomodRuleNotRegistered(ResponsiveException): - def __init__(self, rule: AutomodRule): - self.rule: AutomodRule = rule - super().__init__(f"Rule `{rule.name}` is not registered") + def modify_default_log_channel( + self, log_channel: LogChannel + ) -> tuple[LogChannel, LogChannel]: + old_log_channel = self.require_default_log_channel() + self.default_log = log_channel + return (old_log_channel, self.default_log) + def remove_default_log_channel( + self, + ) -> LogChannel: + old_log_channel = self.require_default_log_channel() + self.default_log = None + return old_log_channel + + def require_rule(self, name: str) -> AutomodRule: + if rule := self.rules.get(name): + return rule + raise AutomodRuleDoesNotExist(name) -class AutomodInvalidFields(ResponsiveException): - def __init__(self, names: set[str]): - self.names: set[str] = names - super().__init__("These fields are invalid: " + "`" + "` `".join(names) + "`") + def require_rule_metadata(self, name: str) -> AutomodRuleMetadata: + if metadata := self.rule_metadata.get(name): + return metadata + raise AutomodRuleMetadataDoesNotExist(name) + def rules_for_event(self, event: AutomodEvent) -> Iterable[AutomodRule]: + event_type = type(event) + if rules := self._rules_by_event_type.get(event_type): + yield from rules -class AutomodUnmodifiableFields(ResponsiveException): - def __init__(self, names: set[str]): - self.names: set[str] = names - super().__init__( - "These fields cannot be modified: " + "`" + "` `".join(names) + "`" + def add_rule( + self, rule: AutomodRule, user_id: UserID + ) -> tuple[AutomodRule, AutomodRuleMetadata]: + # The rule name needs to be available + if rule.name in self.rules: + raise AutomodRuleAlreadyExists(rule.name) + + # The rule name needs to be available for metadata + if rule.name in self.rule_metadata: + raise AutomodRuleMetadataAlreadyExists(rule.name) + + # Create metadata + metadata = AutomodRuleMetadata( + name=rule.name, + hits=0, + added_by_id=user_id, + modified_by_id=user_id, + added_on=utcnow(), + modified_on=utcnow(), ) + # Add rule + self.rules[rule.name] = rule + self.rule_metadata[rule.name] = metadata -@dataclass -class AutomodGuildData: - # Default logging configuration for this guild. - default_log_options: Optional[LogOptions] = None + self._rebuild_mappings() + return (rule, metadata) - # Roles that are permitted to manage the extension within this guild. - permitted_roles: Optional[RoleSet] = None + def modify_rule( + self, rule: AutomodRule, user_id: UserID + ) -> tuple[AutomodRule, AutomodRule, AutomodRuleMetadata]: + # The rule and metadata need to exist + old_rule = self.require_rule(rule.name) + metadata = self.require_rule_metadata(rule.name) - # Index rules by name for faster look-up in commands. - rules: dict[str, AutomodRule] = field(init=False, default_factory=dict) + # Replace the old rule with the modified rule + self.rules[rule.name] = rule - # Group rules by event type for faster look-up during event dispatch. - rules_by_event_type: RulesByEventType = field( - init=False, default_factory=lambda: defaultdict(lambda: set()) - ) + # Update metadata + metadata.modified_by_id = user_id + metadata.modified_on = utcnow() - @classmethod - def from_data(cls, data: JsonObject) -> Self: - default_log_options = LogOptions.from_field_optional(data, "log") - permitted_roles = RoleSet.from_field_optional(data, "permitted_roles") - guild_data = cls( - default_log_options=default_log_options, - permitted_roles=permitted_roles, - ) - for rule_data in data.get("rules", []): - rule = AutomodRule.from_data(rule_data) - guild_data.add_rule(rule) - return guild_data - - def to_data(self) -> JsonObject: - return utils.dict_without_ellipsis( - log=self.default_log_options or ..., - permitted_roles=self.permitted_roles or ..., - rules=list(self.rules.values()) or ..., - ) + self._rebuild_mappings() + return (old_rule, rule, metadata) - def set_default_log_options( - self, log_options: Optional[LogOptions] - ) -> Optional[LogOptions]: - old_value = self.default_log_options - self.default_log_options = log_options - return old_value + def remove_rule(self, name: str) -> tuple[AutomodRule, AutomodRuleMetadata]: + # The rule and metadata need to exist + rule = self.require_rule(name) + metadata = self.require_rule_metadata(name) - def set_permitted_roles( - self, permitted_roles: Optional[RoleSet] - ) -> Optional[RoleSet]: - old_value = self.permitted_roles - self.permitted_roles = permitted_roles - return old_value + # Delete rule and metadata + del self.rules[rule.name] + del self.rule_metadata[rule.name] - def all_rules(self) -> Iterable[AutomodRule]: - yield from self.rules.values() + self._rebuild_mappings() + return (rule, metadata) - def rules_for_event(self, event: AutomodEvent) -> Iterable[AutomodRule]: - event_type = type(event) - # Start with the initial set of possible rules, based on the event type. - for rule in self.rules_by_event_type[event_type]: - # Yield the rule if the event activates any of its triggers. - if rule.poll_triggers(event): - yield rule - - def query_rules(self, query: str) -> Iterable[AutomodRule]: - # If there's an exact match, yield just that. - if rule := self.rules.get(query): - yield rule - else: - # Otherwise, yield any rules that match the query. - query_lower = query.lower() - for rule_name, rule in self.rules.items(): - if query_lower in rule_name.lower(): - yield rule - - def get_rule(self, name: str) -> Optional[AutomodRule]: - return self.rules.get(name) + def rule_count(self) -> int: + return len(self.rules) - def require_rule(self, name: str) -> AutomodRule: - if rule := self.get_rule(name): + def enable_rule(self, name: str) -> AutomodRule: + rule = self.require_rule(name) + if rule.disabled: + rule.disabled = False return rule - raise AutomodNoRuleWithName(name) + raise AutomodRuleAlreadyEnabled(name) - def _add_rule_to_cache(self, rule: AutomodRule): - for trigger in rule.triggers: - for event_type in trigger.event_types: - self.rules_by_event_type[event_type].add(rule) + def disable_rule(self, name: str) -> AutomodRule: + rule = self.require_rule(name) + if not rule.disabled: + rule.disabled = True + return rule + raise AutomodRuleAlreadyDisabled(name) + + def enable_all_rules(self): + for rule in self.rules.values(): + rule.disabled = False + + def disable_all_rules(self): + for rule in self.rules.values(): + rule.disabled = True + + def require_bucket(self, name: str) -> AutomodBucket: + if bucket := self.buckets.get(name): + return bucket + raise AutomodBucketDoesNotExist(name) + + def require_bucket_with_type[BucketType = AutomodBucket]( + self, name: str, bucket_type: type[BucketType] + ) -> BucketType: + if bucket := self.buckets.get(name): + if isinstance(bucket, bucket_type): + return bucket + raise AutomodBucketDoesNotExist(name) + + def bucket_count(self) -> int: + return len(self.buckets) + + def enable_bucket(self, name: str): + bucket = self.require_bucket(name) + if bucket.disabled: + bucket.disabled = False + return bucket + raise AutomodBucketAlreadyEnabled(name) + + def disable_bucket(self, name: str): + bucket = self.require_bucket(name) + if not bucket.disabled: + bucket.disabled = True + return bucket + raise AutomodBucketAlreadyDisabled(name) + + def enable_all_buckets(self): + for bucket in self.buckets.values(): + bucket.disabled = False + + def disable_all_buckets(self): + for bucket in self.buckets.values(): + bucket.disabled = True + + +class AutomodData(BaseModel): + guilds: defaultdict[ + GuildID, + Annotated[AutomodGuildData, Field(default_factory=AutomodGuildData)], + ] = Field( + default_factory=lambda: defaultdict(AutomodGuildData), + exclude_if=lambda v: not v, + ) - def add_rule(self, rule: AutomodRule): - if rule.name in self.rules: - raise AutomodRuleWithNameAlreadyExists(rule.name) - self.rules[rule.name] = rule - self._add_rule_to_cache(rule) - - def add_rule_from_data(self, data: JsonObject) -> AutomodRule: - rule = AutomodRule.from_data(data) - self.add_rule(rule) - return rule - - def _remove_rule_from_cache(self, rule: AutomodRule): - for rules in self.rules_by_event_type.values(): - if rule in rules: - rules.remove(rule) - - def remove_rule(self, rule: AutomodRule): - existing_rule = self.rules.get(rule.name) - if not (existing_rule and (existing_rule is rule)): - raise AutomodRuleNotRegistered(rule) - self._remove_rule_from_cache(rule) - del self.rules[rule.name] + def require_default_log_channel( + self, + guild: Guild, + ) -> LogChannel: + return self.guilds[guild.id].remove_default_log_channel() - def remove_rule_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - self.remove_rule(rule) - return rule + def get_default_log_channel( + self, + guild: Guild, + ) -> Optional[LogChannel]: + return self.guilds[guild.id].get_default_log_channel() - def modify_rule_raw( + def set_default_log_channel( + self, guild: Guild, log_channel: LogChannel + ) -> LogChannel: + return self.guilds[guild.id].set_default_log_channel(log_channel) + + def modify_default_log_channel( + self, guild: Guild, log_channel: LogChannel + ) -> tuple[LogChannel, LogChannel]: + return self.guilds[guild.id].modify_default_log_channel(log_channel) + + def remove_default_log_channel( self, - name: str, - path: utils.JsonPath, - op: utils.JsonPathOp, - data: Any, - ) -> AutomodRule: - # Start with the serialized form of the original rule. - old_rule = self.require_rule(name) - new_data = to_data(old_rule) + guild: Guild, + ) -> LogChannel: + return self.guilds[guild.id].remove_default_log_channel() - # Update the modification timestamp. Note that it may still be overidden. - new_data["modified_on"] = utcnow().isoformat() + def require_rule(self, guild: Guild, name: str) -> AutomodRule: + return self.guilds[guild.id].require_rule(name) - # Update the new rule data using the given changes. - utils.update_json_with_path(new_data, path, op, data) + def require_rule_metadata(self, guild: Guild, name: str) -> AutomodRuleMetadata: + return self.guilds[guild.id].require_rule_metadata(name) - # Create a new rule out of the modified data. - new_rule = AutomodRule.from_data(new_data) + def has_rule(self, guild: Guild, name: str) -> bool: + return name in self.guilds[guild.id].rules - # Remove the old rule, and then add the new one. - self.remove_rule(old_rule) - self.add_rule(new_rule) + def rules_for_event( + self, guild: Guild, event: AutomodEvent + ) -> Iterable[AutomodRule]: + yield from self.guilds[guild.id].rules_for_event(event) - # Return the new rule. - return new_rule + def add_rule( + self, guild: Guild, rule: AutomodRule, user_id: UserID + ) -> tuple[AutomodRule, AutomodRuleMetadata]: + return self.guilds[guild.id].add_rule(rule, user_id) - def enable_rule_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - rule.disabled = False - return rule + def modify_rule( + self, guild: Guild, rule: AutomodRule, user_id: UserID + ) -> tuple[AutomodRule, AutomodRule, AutomodRuleMetadata]: + return self.guilds[guild.id].modify_rule(rule, user_id) - def disable_rule_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - rule.disabled = True - return rule + def remove_rule( + self, guild: Guild, name: str + ) -> tuple[AutomodRule, AutomodRuleMetadata]: + return self.guilds[guild.id].remove_rule(name) - def increment_rule_hits_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - rule.hits += 1 - return rule + def increment_rule_hits(self, guild: Guild, name: str): + metadata = self.guilds[guild.id].require_rule_metadata(name) + metadata.hits += 1 + def yield_rules(self, guild: Guild, sort: bool) -> Iterable[AutomodRule]: + rules = self.guilds[guild.id].rules.values() + yield from sorted(rules, key=lambda rule: rule.name) if sort else rules -def _guilds_defaultdict_factory() -> defaultdict[GuildID, AutomodGuildData]: - return defaultdict(lambda: AutomodGuildData()) + def rule_count(self, guild: Guild) -> int: + return self.guilds[guild.id].rule_count() + def enable_rule(self, guild: Guild, name: str) -> AutomodRule: + return self.guilds[guild.id].enable_rule(name) -# @implements AutomodStore -@dataclass -class AutomodData: - """ - Implementation of `AutomodStore` using an in-memory object hierarchy. - """ + def disable_rule(self, guild: Guild, name: str) -> AutomodRule: + return self.guilds[guild.id].disable_rule(name) - guilds: defaultdict[GuildID, AutomodGuildData] = field( - default_factory=_guilds_defaultdict_factory - ) + def enable_all_rules(self, guild: Guild): + self.guilds[guild.id].enable_all_rules() - @classmethod - def from_data(cls, data: JsonObject) -> Self: - guilds = _guilds_defaultdict_factory() - guilds.update( - { - int(guild_id): AutomodGuildData.from_data(raw_guild_data) - for guild_id, raw_guild_data in data.get("guilds", {}).items() - } - ) - return cls(guilds=guilds) - - def to_data(self) -> JsonObject: - # Omit empty guilds, as well as an empty list of guilds. - return utils.dict_without_ellipsis( - guilds=utils.dict_without_ellipsis( - { - str(guild_id): (guild_data.to_data() or ...) - for guild_id, guild_data in self.guilds.items() - } - ) - or ... - ) + def disable_all_rules(self, guild: Guild): + self.guilds[guild.id].disable_all_rules() - # @implements AutomodStore - async def get_default_log_options(self, guild: Guild) -> Optional[LogOptions]: - return self.guilds[guild.id].default_log_options - - # @implements AutomodStore - async def set_default_log_options( - self, guild: Guild, log_options: Optional[LogOptions] - ) -> Optional[LogOptions]: - return self.guilds[guild.id].set_default_log_options(log_options) - - # @implements AutomodStore - async def get_permitted_roles(self, guild: Guild) -> Optional[RoleSet]: - return self.guilds[guild.id].permitted_roles - - # @implements AutomodStore - async def set_permitted_roles( - self, guild: Guild, permitted_roles: Optional[RoleSet] - ) -> Optional[RoleSet]: - return self.guilds[guild.id].set_permitted_roles(permitted_roles) - - # @implements AutomodStore - async def all_rules(self, guild: Guild) -> AsyncIterable[AutomodRule]: - for rule in self.guilds[guild.id].all_rules(): - yield rule - - # @implements AutomodStore - async def rules_for_event( - self, guild: Guild, event: AutomodEvent - ) -> AsyncIterable[AutomodRule]: - for rule in self.guilds[guild.id].rules_for_event(event): - yield rule + def require_bucket(self, guild: Guild, name: str) -> AutomodBucket: + return self.guilds[guild.id].require_bucket(name) - # @implements AutomodStore - async def query_rules(self, guild: Guild, query: str) -> AsyncIterable[AutomodRule]: - for rule in self.guilds[guild.id].query_rules(query): - yield rule + def require_bucket_with_type[BucketType = AutomodBucket]( + self, guild: Guild, name: str, bucket_type: type[BucketType] + ) -> BucketType: + return self.guilds[guild.id].require_bucket_with_type(name, bucket_type) - # @implements AutomodStore - async def get_rule(self, guild: Guild, name: str) -> Optional[AutomodRule]: - return self.guilds[guild.id].get_rule(name) + def bucket_count(self, guild: Guild) -> int: + return self.guilds[guild.id].bucket_count() - # @implements AutomodStore - async def require_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].require_rule(name) + def enable_bucket(self, guild: Guild, name: str) -> AutomodBucket: + return self.guilds[guild.id].enable_bucket(name) - # @implements AutomodStore - async def add_rule(self, guild: Guild, data: JsonObject) -> AutomodRule: - return self.guilds[guild.id].add_rule_from_data(data) + def disable_bucket(self, guild: Guild, name: str) -> AutomodBucket: + return self.guilds[guild.id].disable_bucket(name) - # @implements AutomodStore - async def remove_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].remove_rule_by_name(name) + def enable_all_buckets(self, guild: Guild): + self.guilds[guild.id].enable_all_buckets() - # @implements AutomodStore - async def modify_rule( - self, - guild: Guild, - name: str, - path: utils.JsonPath, - op: utils.JsonPathOp, - data: Any, - ) -> AutomodRule: - return self.guilds[guild.id].modify_rule_raw(name, path, op, data) - - # @implements AutomodStore - async def enable_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].enable_rule_by_name(name) - - # @implements AutomodStore - async def disable_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].disable_rule_by_name(name) - - # @implements AutomodStore - async def increment_rule_hits(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].increment_rule_hits_by_name(name) + def disable_all_buckets(self, guild: Guild): + self.guilds[guild.id].disable_all_buckets() diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index af1411d..d3bf52a 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -1,449 +1,412 @@ import asyncio -import json from dataclasses import dataclass from datetime import datetime -from json import JSONDecodeError -from typing import Any, Optional, cast +from typing import Optional -import yaml +import discord from discord import ( - AllowedMentions, + Attachment, + Interaction, Member, - RawMessageDeleteEvent, - RawMessageUpdateEvent, - RawReactionActionEvent, - Role, - TextChannel, + Message, + Reaction, + TextStyle, Thread, ThreadMember, User, + ui, ) -from discord.ext.commands import Context -from discord.utils import utcnow -from yaml import YAMLError +from discord.utils import format_dt +from mime_enum import MimeType +from commanderbot.core.utils import get_app_command from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEventBase -from commanderbot.ext.automod.automod_rule import AutomodRule -from commanderbot.ext.automod.automod_store import AutomodStore -from commanderbot.lib import ( - Color, - ConfirmationResult, - LogOptions, - ResponsiveException, - RoleSet, - TextMessage, - TextReaction, - confirm_with_reaction, - to_data, - utils, +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.automod_exceptions import ( + CouldNotValidateModifiedAutomodRule, + CouldNotValidateNewAutomodRule, + CouldNotValidateUploadedAutomodRule, + UploadIsNotJsonFile, ) +from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.rule import AutomodRule +from commanderbot.lib.allowed_mentions import AllowedMentions from commanderbot.lib.cogs import CogGuildState +from commanderbot.lib.cogs.views import CogStateModal +from commanderbot.lib.dialogs import ConfirmationResult, respond_with_confirmation +from commanderbot.lib.types import GuildChannel, MessageableGuildChannel +from commanderbot.lib.utils import str_to_file @dataclass class AutomodGuildState(CogGuildState): """ Encapsulates the state and logic of the automod cog, at the guild level. - - Attributes - ----------- - store - The store used to interface with persistent data in a database-agnostic way. """ store: AutomodStore - async def _get_log_options_for_rule( - self, rule: AutomodRule - ) -> Optional[LogOptions]: - # First try to grab the rule's specific logging configuration, if any. - if rule.log: - return rule.log - # If it doesn't have any, return the guild-wide logging configuration. This may - # also not exist, hence why it's optional. - return await self.store.get_default_log_options(self.guild) + # @@ COMMANDS - async def _handle_rule_error(self, rule: AutomodRule, error: Exception): - error_message = f"Rule `{rule.name}` caused an error" + async def add_rule(self, interaction: Interaction): + await interaction.response.send_modal(AddRuleModal(interaction, self)) - # Re-raise the error so that it can be printed to the console. - try: - raise error - except: - self.log.exception(error_message) - - # Attempt to print the error to the log channel, if any. - if log_options := await self._get_log_options_for_rule(rule): - try: - error_codeblock = log_options.format_error_codeblock(error) - await log_options.send_embed( - self.bot, - title=error_message, - description=error_codeblock, - file_callback=lambda: ( - error_message, - "", - error_codeblock, - "error.txt", - ), - ) - except: - # If something went wrong here, print another exception to the console. - self.log.exception("Failed to log message to error channel") + async def modify_rule(self, interaction: Interaction, name: str): + rule = await self.store.require_rule(self.guild, name) + await interaction.response.send_modal(ModifyRuleModal(interaction, self, rule)) - async def _do_event_for_rule(self, event: AutomodEventBase, rule: AutomodRule): + async def upload_rule(self, interaction: Interaction, file: Attachment): + # The uploaded file needs to be a Json file + if not (file.content_type and MimeType.APPLICATION_JSON in file.content_type): + raise UploadIsNotJsonFile + + # Try to validate the uploaded file try: - if await rule.run(event): - await self.store.increment_rule_hits(self.guild, rule.name) - except Exception as error: - await self._handle_rule_error(rule, error) + file_data = await file.read() + rule = AutomodRule.model_validate_json(file_data) + except ValueError as ex: + raise CouldNotValidateUploadedAutomodRule(ex) + + # Modify the rule if it already exists + if await self.store.has_rule(self.guild, rule.name): + await self.store.modify_rule(self.guild, rule, interaction.user.id) + await interaction.response.send_message( + f"Modified automod rule `{rule.name}`" + ) + else: + await self.store.add_rule(self.guild, rule, interaction.user.id) + await interaction.response.send_message(f"Added automod rule `{rule.name}`") - async def _do_event(self, event: AutomodEventBase): - # Run rules in parallel so that they don't need to wait for one another. They - # run separately so that when a rule fails it doesn't stop the others. - rules = await utils.async_expand(self.store.rules_for_event(self.guild, event)) - tasks = [self._do_event_for_rule(event, rule) for rule in rules] - await asyncio.gather(*tasks) - - def _parse_body(self, body: str) -> Any: - content = body.strip("\n").strip("`") - kind, _, content = content.partition("\n") - if kind == "json": - try: - return json.loads(content) - except JSONDecodeError as ex: - raise ResponsiveException(str(ex)) from ex - if kind == "yaml": - try: - return yaml.safe_load(content) - except YAMLError as ex: - raise ResponsiveException(str(ex)) from ex - raise ResponsiveException("Missing code block declared as `json` or `yaml`") - - async def reply(self, ctx: Context, content: str): - """Wraps `Context.reply()` with some extension-default boilerplate.""" - await ctx.message.reply( - content, - allowed_mentions=AllowedMentions.none(), + async def remove_rule(self, interaction: Interaction, name: str): + # Get the rule + rule = await self.store.require_rule(self.guild, name) + + # Respond to this interaction with a confirmation dialog + result: ConfirmationResult = await respond_with_confirmation( + interaction, + f"Are you sure you want to remove the automod rule `{rule.name}`?", + timeout=10.0, ) - async def show_default_log_options(self, ctx: Context): - log_options = await self.store.get_default_log_options(self.guild) - if log_options: - channel = cast(TextChannel, self.bot.get_channel(log_options.channel)) - await self.reply( - ctx, - f"Default logging is configured for {channel.mention}" - + f"\n```\n{log_options!r}\n```", - ) - else: - await self.reply(ctx, f"No default logging is configured") - - async def set_default_log_options( - self, - ctx: Context, - channel: TextChannel, - stacktrace: Optional[bool], - emoji: Optional[str], - color: Optional[Color], - ): - new_log_options = LogOptions( - channel=channel.id, - stacktrace=stacktrace, - emoji=emoji, - color=color, + # Handle response to dialog + match result: + case ConfirmationResult.YES: + try: + await self.store.remove_rule(self.guild, rule.name) + await interaction.followup.send( + content=f"Removed the automod rule `{rule.name}`" + ) + except Exception: + await interaction.delete_original_response() + raise + case _: + await interaction.followup.send( + f"Did not remove the automod rule `{rule.name}`" + ) + + async def show_rule_details(self, interaction: Interaction, name: str): + # Get rule and metadata + rule = await self.store.require_rule(self.guild, name) + metadata = await self.store.require_rule_metadata(self.guild, name) + + # Turn the rule into a Json file + rule_json = rule.model_dump_json(indent=4, exclude_defaults=True) + rule_file = str_to_file(rule_json, f"{rule.name}.json") + + # Create fields + fields: dict[str, str] = { + "Name": f"`{rule.name}`", + "Enabled": "❌" if rule.disabled else "✅", + "Hits": f"`{metadata.hits}`", + "Added by": f"<@{metadata.added_by_id}>({format_dt(metadata.added_on, style='R')})", + "Modified by": f"<@{metadata.modified_by_id}>({format_dt(metadata.modified_on, style='R')})", + } + + # Create the rule details view + rule_details_view = ui.LayoutView() + container = ui.Container(accent_color=0x00ACED) + rule_details_view.add_item(container) + + container.add_item( + ui.TextDisplay(f"### 📜 Details for automod rule `{rule.name}`") ) - old_log_options = await self.store.set_default_log_options( - self.guild, new_log_options + container.add_item(ui.Separator()) + container.add_item(ui.File(rule_file)) + container.add_item(ui.Separator()) + for field_name, field_value in fields.items(): + container.add_item(ui.TextDisplay(f"**{field_name}** **-** {field_value}")) + + # Respond with the view + await interaction.response.send_message( + view=rule_details_view, + file=rule_file, + allowed_mentions=AllowedMentions.none(), ) - if old_log_options: - old_log_channel = cast( - TextChannel, self.bot.get_channel(old_log_options.channel) - ) - await self.reply( - ctx, - f"Moved default logging from {old_log_channel.mention}" - + f" to {channel.mention}", - ) - else: - await self.reply(ctx, f"Configured default logging for {channel.mention}") - async def remove_default_log_options(self, ctx: Context): - old_log_options = await self.store.set_default_log_options(self.guild, None) - if old_log_options: - channel = cast(TextChannel, self.bot.get_channel(old_log_options.channel)) - await self.reply(ctx, f"Removed default logging from {channel.mention}") + async def list_rules(self, interaction: Interaction): + # Get info about rules + formatted_rules: list[str] = [] + enabled_rules: int = 0 + disabled_rules: int = 0 + async for rule in self.store.yield_rules(self.guild): + if not rule.disabled: + formatted_rules.append(rule.name) + enabled_rules += 1 + else: + formatted_rules.append(f"{rule.name} (Disabled)") + disabled_rules += 1 + + # Create rule list view + rule_list_view = ui.LayoutView() + container = ui.Container(accent_color=0x00ACED) + rule_list_view.add_item(container) + + container.add_item(ui.TextDisplay("### 📜 All automod rules")) + container.add_item(ui.Separator()) + + if formatted_rules: + lines = "\n".join(("```", "\n".join(formatted_rules), "```")) + container.add_item(ui.TextDisplay(lines)) else: - await self.reply(ctx, f"No default logging is configured") - - async def show_permitted_roles(self, ctx: Context): - permitted_roles = await self.store.get_permitted_roles(self.guild) - if permitted_roles: - count_permitted_roles = len(permitted_roles) - role_mentions = permitted_roles.to_mentions(self.guild) - await self.reply( - ctx, - f"There are {count_permitted_roles} roles permitted to manage" - + f" automod: {role_mentions}", - ) - else: - await self.reply(ctx, f"No roles are permitted to manage automod") + container.add_item(ui.TextDisplay("**None!**")) - async def set_permitted_roles(self, ctx: Context, *roles: Role): - new_permitted_roles = RoleSet(set(role.id for role in roles)) - new_role_mentions = new_permitted_roles.to_mentions(self.guild) - old_permitted_roles = await self.store.set_permitted_roles( - self.guild, new_permitted_roles + container.add_item(ui.Separator()) + container.add_item( + ui.TextDisplay(f"-# Enabled: {enabled_rules} | Disabled: {disabled_rules}") ) - if old_permitted_roles: - old_role_mentions = old_permitted_roles.to_mentions(self.guild) - await self.reply( - ctx, - f"Changed permitted roles from {old_role_mentions}" - + f" to {new_role_mentions}", - ) - else: - await self.reply(ctx, f"Changed permitted roles to {new_role_mentions}") - - async def clear_permitted_roles(self, ctx: Context): - old_permitted_roles = await self.store.set_permitted_roles(self.guild, None) - if old_permitted_roles: - role_mentions = old_permitted_roles.to_mentions(self.guild) - await self.reply(ctx, f"Cleared all permitted roles: {role_mentions}") - else: - await self.reply(ctx, f"No roles are permitted to manage automod") - async def show_rules(self, ctx: Context, query: str = ""): - if query: - rules = await utils.async_expand(self.store.query_rules(self.guild, query)) - else: - rules = await utils.async_expand(self.store.all_rules(self.guild)) - count_rules = len(rules) - if count_rules > 1: - lines = ["```"] - sorted_rules = sorted(rules, key=lambda rule: (rule.disabled, rule.name)) - for rule in sorted_rules: - lines.append(rule.build_title()) - lines.append("```") - content = "\n".join(lines) - await self.reply(ctx, content) - elif count_rules == 1: - rule = rules[0] - now = utcnow() - added_on_timestamp = rule.added_on.isoformat() - added_on_delta = now - rule.added_on - added_on_str = f"{added_on_timestamp} ({added_on_delta})" - modified_on_delta = now - rule.modified_on - modified_on_timestamp = rule.modified_on.isoformat() - modified_on_str = f"{modified_on_timestamp} ({modified_on_delta})" - name_line = rule.build_title() - lines = [ - "```", - name_line, - f" Hits: {rule.hits}", - f" Added on: {added_on_str}", - f" Modified on: {modified_on_str}", - " Triggers:", - ] - for i, trigger in enumerate(rule.triggers): - description = trigger.description or "(No description)" - lines.append(f" {i+1}. {description}") - lines.append(" Conditions:") - for i, condition in enumerate(rule.conditions): - description = condition.description or "(No description)" - lines.append(f" {i+1}. {description}") - lines.append(" Actions:") - for i, action in enumerate(rule.actions): - description = action.description or "(No description)" - lines.append(f" {i+1}. {description}") - lines.append("```") - content = "\n".join(lines) - await self.reply(ctx, content) - elif query: - await self.reply(ctx, f"No rules matching `{query}`") - else: - await self.reply(ctx, f"No rules available") + # Respond with the view + await interaction.response.send_message(view=rule_list_view) - async def print_rule( - self, - ctx: Context, - query: str, - path: Optional[utils.JsonPath] = None, - ): - rules = await utils.async_expand(self.store.query_rules(self.guild, query)) - if rules: - # If multiple rules were found, just use the first. - rule = rules[0] - - # Turn the rule into raw data. - rule_data = to_data(rule) - - # Take a sub-section of the data, if necessary. - output_data = rule_data - if path: - output_data = utils.query_json_path(output_data, path) - - # Turn the data into a YAML string. - output_yaml = yaml.safe_dump(output_data, sort_keys=False) - - # If the output can fit into a code block, just send a response. Otherwise, - # stuff it into a file and send it as an attachment. - content = f"```yaml\n{output_yaml}\n```" - file_callback = lambda: ("", output_yaml, f"{rule.name}.yaml") - return await utils.send_message_or_file( - ctx.channel, - content, - file_callback=file_callback, - allowed_mentions=AllowedMentions.none(), - ) + async def enable_rule(self, interaction: Interaction, name: str): + rule = await self.store.enable_rule(self.guild, name) + await interaction.response.send_message(f"Enabled automod rule `{rule.name}`") - else: - await self.reply(ctx, f"No rule found matching `{query}`") + async def disable_rule(self, interaction: Interaction, name: str): + rule = await self.store.disable_rule(self.guild, name) + await interaction.response.send_message(f"Disabled automod rule `{rule.name}`") - async def add_rule(self, ctx: Context, body: str): - data = self._parse_body(body) - rule = await self.store.add_rule(self.guild, data) - await self.reply(ctx, f"Added automod rule `{rule.name}`") + async def enable_all_rule(self, interaction: Interaction): + rule_count = await self.store.rule_count(self.guild) + await self.store.enable_all_rules(self.guild) + await interaction.response.send_message( + f"Enabled all `{rule_count}` automod rules" + ) - async def remove_rule(self, ctx: Context, name: str): - # Get the corresponding rule. - rule = await self.store.require_rule(self.guild, name) - # Then ask for confirmation to actually remove it. - conf = await confirm_with_reaction( - self.bot, - ctx, - f"Are you sure you want to remove automod rule `{rule.name}`?", + async def disable_all_rule(self, interaction: Interaction): + rule_count = await self.store.rule_count(self.guild) + await self.store.disable_all_rules(self.guild) + await interaction.response.send_message( + f"Disabled all `{rule_count}` automod rules" ) - # If the answer was yes, attempt to remove the rule and send a response. - if conf == ConfirmationResult.YES: - removed_rule = await self.store.remove_rule(self.guild, rule.name) - await self.reply(ctx, f"Removed automod rule `{removed_rule.name}`") - # If the answer was no, send a response. - elif conf == ConfirmationResult.NO: - await self.reply(ctx, f"Did not remove automod rule `{rule.name}`") - - async def modify_rule( - self, - ctx: Context, - name: str, - path: utils.JsonPath, - op: utils.JsonPathOp, - body: str, - ): - data = self._parse_body(body) - rule = await self.store.modify_rule(self.guild, name, path, op, data) - await self.reply(ctx, f"Modified automod rule `{rule.name}`") - async def enable_rule(self, ctx: Context, name: str): - rule = await self.store.enable_rule(self.guild, name) - await self.reply(ctx, f"Enabled automod rule `{rule.name}`") + # @@ UTILITIES - async def disable_rule(self, ctx: Context, name: str): - rule = await self.store.disable_rule(self.guild, name) - await self.reply(ctx, f"Disabled automod rule `{rule.name}`") + def get_schema_command(self) -> str: + if cmd := get_app_command(self.bot, "automod schema"): + return cmd.mention + return "/automod schema" + + # @@ EVENTS + + async def _handle_rule_error(self, rule: AutomodRule, error: Exception): + # Re-raise the error so that it can be printed to the console + try: + raise error + except: + self.log.exception(f"Automod rule '{rule.name}' caused an error") + + # Try to get the log channel + log_channel = rule.log or await self.store.get_default_log_channel(self.guild) + if not log_channel: + return + + # Attempt to print the error to the log channel + try: + await log_channel.send_error( + self.bot, f"Automod rule `{rule.name}` caused an error", error + ) + except: + self.log.exception("Failed to send error message to log channel") - async def member_has_permission(self, member: Member) -> bool: - permitted_roles = await self.store.get_permitted_roles(self.guild) - if permitted_roles is None: - return False - has_permission = permitted_roles.member_has_some(member) - return has_permission + async def _run_rule(self, rule: AutomodRule, event: AutomodEvent): + try: + context = AutomodContext(self.bot, self.log, self, rule, event) + if await rule.run(context): + await self.store.increment_rule_hits(self.guild, rule.name) + except Exception as error: + await self._handle_rule_error(rule, error) - # @@ EVENT HANDLERS + async def dispatch_event(self, event: AutomodEvent): + async with asyncio.TaskGroup() as tg: + async for rule in self.store.rules_for_event(self.guild, event): + tg.create_task(self._run_rule(rule, event)) - async def on_typing( - self, channel: TextChannel | Thread, member: Member, when: datetime + # @@ DISCORD AUTOMOD + async def on_discord_automod_action_block_message( + self, execution: discord.AutoModAction ): - await self._do_event( - events.MemberTyping(self.bot, self.log, channel, member, when) + await self.dispatch_event(events.DiscordAutomodBlockedMessage(execution)) + + async def on_discord_automod_action_timeout(self, execution: discord.AutoModAction): + await self.dispatch_event(events.DiscordAutomodTimedOut(execution)) + + async def on_discord_automod_action_block_member_interactions( + self, execution: discord.AutoModAction + ): + await self.dispatch_event( + events.DiscordAutomodBlockedMemberInteractions(execution) ) - async def on_message(self, message: TextMessage): - await self._do_event(events.MessageSent(self.bot, self.log, message)) + # @@ CHANNELS - async def on_message_delete(self, message: TextMessage): - await self._do_event(events.MessageDeleted(self.bot, self.log, message)) + async def on_guild_channel_created(self, channel: GuildChannel): + await self.dispatch_event(events.GuildChannelCreated(channel)) - async def on_message_edit(self, before: TextMessage, after: TextMessage): - await self._do_event(events.MessageEdited(self.bot, self.log, before, after)) + async def on_guild_channel_deleted(self, channel: GuildChannel): + await self.dispatch_event(events.GuildChannelDeleted(channel)) - async def on_reaction_add(self, reaction: TextReaction, member: Member): - await self._do_event(events.ReactionAdded(self.bot, self.log, reaction, member)) + async def on_guild_channel_updated(self, before: GuildChannel, after: GuildChannel): + await self.dispatch_event(events.GuildChannelUpdated(before, after)) - async def on_reaction_remove(self, reaction: TextReaction, member: Member): - await self._do_event( - events.ReactionRemoved(self.bot, self.log, reaction, member) - ) + async def on_guild_channel_pins_updated( + self, channel: MessageableGuildChannel | Thread, last_pin: Optional[datetime] + ): + await self.dispatch_event(events.GuildChannelPinsUpdated(channel, last_pin)) + + # @@ MEMBERS - async def on_channel_create(self, channel: TextChannel | Thread): - await self._do_event(events.GuildChannelCreated(self.bot, self.log, channel)) + async def on_member_joined(self, member: Member): + await self.dispatch_event(events.MemberJoined(member)) - async def on_channel_delete(self, channel: TextChannel | Thread): - await self._do_event(events.GuildChannelDeleted(self.bot, self.log, channel)) + async def on_member_left(self, member: Member): + await self.dispatch_event(events.MemberLeft(member)) - async def on_channel_update( - self, before: TextChannel | Thread, after: TextChannel | Thread + async def on_member_typing( + self, channel: MessageableGuildChannel | Thread, member: Member, when: datetime ): - await self._do_event( - events.GuildChannelUpdated(self.bot, self.log, before, after) - ) + await self.dispatch_event(events.MemberTyping(channel, member, when)) + + async def on_member_updated(self, before: Member, after: Member): + await self.dispatch_event(events.MemberUpdated(before, after)) + + # @@ MESSAGES + + async def on_message_sent(self, message: Message): + await self.dispatch_event(events.MessageSent(message)) + + async def on_message_edited(self, before: Message, after: Message): + await self.dispatch_event(events.MessageEdited(before, after)) + + async def on_message_deleted(self, message: Message): + await self.dispatch_event(events.MessageDeleted(message)) + + # @@ REACTIONS + + async def on_reaction_added(self, reaction: Reaction, member: Member): + await self.dispatch_event(events.ReactionAdded(reaction, member)) + + async def on_reaction_removed(self, reaction: Reaction, member: Member): + await self.dispatch_event(events.ReactionRemoved(reaction, member)) # @@ THREADS - async def on_thread_create(self, thread: Thread): - await self._do_event(events.ThreadCreated(self.bot, self.log, thread)) + async def on_thread_created(self, thread: Thread): + await self.dispatch_event(events.ThreadCreated(thread)) - async def on_thread_join(self, thread: Thread): - await self._do_event(events.ThreadJoined(self.bot, self.log, thread)) + async def on_thread_joined(self, thread: Thread): + await self.dispatch_event(events.ThreadJoined(thread)) - async def on_thread_update(self, before: Thread, after: Thread): - await self._do_event(events.ThreadUpdated(self.bot, self.log, before, after)) + async def on_thread_updated(self, before: Thread, after: Thread): + await self.dispatch_event(events.ThreadUpdated(before, after)) - async def on_thread_remove(self, thread: Thread): - await self._do_event(events.ThreadRemoved(self.bot, self.log, thread)) + async def on_thread_removed(self, thread: Thread): + await self.dispatch_event(events.ThreadRemoved(thread)) - async def on_thread_delete(self, thread: Thread): - await self._do_event(events.ThreadDeleted(self.bot, self.log, thread)) + async def on_thread_deleted(self, thread: Thread): + await self.dispatch_event(events.ThreadDeleted(thread)) - async def on_thread_member_join(self, member: ThreadMember): - await self._do_event(events.ThreadMemberJoined(self.bot, self.log, member)) + async def on_thread_member_joined(self, member: ThreadMember): + await self.dispatch_event(events.ThreadMemberJoined(member)) - async def on_thread_member_remove(self, member: ThreadMember): - await self._do_event(events.ThreadMemberLeft(self.bot, self.log, member)) + async def on_thread_member_left(self, member: ThreadMember): + await self.dispatch_event(events.ThreadMemberLeft(member)) - # @@ MEMBERS + # @@ USERS - async def on_member_join(self, member: Member): - await self._do_event(events.MemberJoined(self.bot, self.log, member)) + async def on_user_updated(self, before: User, after: User): + await self.dispatch_event(events.UserUpdated(before, after)) - async def on_member_remove(self, member: Member): - await self._do_event(events.MemberLeft(self.bot, self.log, member)) + async def on_user_banned(self, user: User): + await self.dispatch_event(events.UserBanned(user)) - async def on_member_update(self, before: Member, after: Member): - await self._do_event(events.MemberUpdated(self.bot, self.log, before, after)) + async def on_user_unbanned(self, user: User): + await self.dispatch_event(events.UserUnbanned(user)) - async def on_user_update(self, before: User, after: User, member: Member): - await self._do_event( - events.UserUpdated(self.bot, self.log, before, after, member) + +class AddRuleModal(CogStateModal[AutomodGuildState, AutomodStore]): + def __init__(self, interaction: Interaction, state: AutomodGuildState): + super().__init__( + interaction, + state, + title="Add a new automod rule", + custom_id="commanderbot_ext:automod.rule.add", ) - async def on_user_ban(self, user: User): - await self._do_event(events.UserBanned(self.bot, self.log, user)) + self.rule_input = ui.TextInput( + label="The automod rule in Json format", + style=TextStyle.paragraph, + placeholder="{}", + required=True, + ) + self.help_display = ui.TextDisplay( + f"-# Run {state.get_schema_command()} if you need the schema" + ) - async def on_user_unban(self, user: User): - await self._do_event(events.UserUnbanned(self.bot, self.log, user)) + self.add_item(self.rule_input) + self.add_item(self.help_display) - # @@ RAW EVENT HANDLERS + async def on_submit(self, interaction: Interaction): + try: + rule = AutomodRule.model_validate_json(self.rule_input.value) + await self.store.add_rule(self.state.guild, rule, interaction.user.id) + await interaction.response.send_message(f"Added automod rule `{rule.name}`") + except ValueError as ex: + raise CouldNotValidateNewAutomodRule(ex) - async def on_raw_message_delete(self, payload: RawMessageDeleteEvent): - await self._do_event(events.RawMessageDeleted(self.bot, self.log, payload)) - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent): - await self._do_event(events.RawMessageEdited(self.bot, self.log, payload)) +class ModifyRuleModal(CogStateModal[AutomodGuildState, AutomodStore]): + def __init__( + self, interaction: Interaction, state: AutomodGuildState, rule: AutomodRule + ): + super().__init__( + interaction, + state, + title=f"Modifying automod rule '{rule.name}'", + custom_id="commanderbot_ext:automod.rule.modify", + ) - async def on_raw_reaction_add(self, payload: RawReactionActionEvent): - await self._do_event(events.RawReactionAdded(self.bot, self.log, payload)) + self.rule_input = ui.TextInput( + label="The automod rule in Json format", + style=TextStyle.paragraph, + placeholder="{}", + default=rule.model_dump_json(indent=4, exclude_defaults=True), + required=True, + ) + self.help_display = ui.TextDisplay( + f"-# Run {state.get_schema_command()} if you need the schema" + ) + + self.add_item(self.rule_input) + self.add_item(self.help_display) - async def on_raw_reaction_remove(self, payload: RawReactionActionEvent): - await self._do_event(events.RawReactionRemoved(self.bot, self.log, payload)) + async def on_submit(self, interaction: Interaction): + try: + rule = AutomodRule.model_validate_json(self.rule_input.value) + await self.store.modify_rule(self.state.guild, rule, interaction.user.id) + await interaction.response.send_message( + f"Modified automod rule `{rule.name}`" + ) + except ValueError as ex: + raise CouldNotValidateModifiedAutomodRule(ex) diff --git a/commanderbot/ext/automod/automod_store.py b/commanderbot/ext/automod/automod_store.py index 3c21e8c..df0d0b5 100644 --- a/commanderbot/ext/automod/automod_store.py +++ b/commanderbot/ext/automod/automod_store.py @@ -1,56 +1,183 @@ -from typing import Any, AsyncIterable, Optional, Protocol +from collections.abc import AsyncIterable +from dataclasses import dataclass +from typing import Optional from discord import Guild -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_rule import AutomodRule -from commanderbot.lib import JsonObject, LogOptions, RoleSet, utils +from commanderbot.ext.automod.automod_data import AutomodData +from commanderbot.ext.automod.bucket import AutomodBucket +from commanderbot.ext.automod.event import AutomodEvent +from commanderbot.ext.automod.rule import AutomodRule, AutomodRuleMetadata +from commanderbot.lib.cogs import CogStore +from commanderbot.lib.databases.json_db import JsonDB +from commanderbot.lib.log_channel import LogChannel +from commanderbot.lib.types import UserID -class AutomodStore(Protocol): - """ - Abstracts the data storage and persistence of the automod cog. - """ +@dataclass +class AutomodStore(CogStore): + db: JsonDB[AutomodData] - async def get_default_log_options(self, guild: Guild) -> Optional[LogOptions]: ... - - async def set_default_log_options( - self, guild: Guild, log_options: Optional[LogOptions] - ) -> Optional[LogOptions]: ... - - async def get_permitted_roles(self, guild: Guild) -> Optional[RoleSet]: ... - - async def set_permitted_roles( - self, guild: Guild, permitted_roles: Optional[RoleSet] - ) -> Optional[RoleSet]: ... - - def all_rules(self, guild: Guild) -> AsyncIterable[AutomodRule]: ... - - def rules_for_event( - self, guild: Guild, event: AutomodEvent - ) -> AsyncIterable[AutomodRule]: ... - - def query_rules(self, guild: Guild, query: str) -> AsyncIterable[AutomodRule]: ... - - async def get_rule(self, guild: Guild, name: str) -> Optional[AutomodRule]: ... - - async def require_rule(self, guild: Guild, name: str) -> AutomodRule: ... - - async def add_rule(self, guild: Guild, data: JsonObject) -> AutomodRule: ... - - async def remove_rule(self, guild: Guild, name: str) -> AutomodRule: ... - - async def modify_rule( + async def require_default_log_channel( self, guild: Guild, - name: str, - path: utils.JsonPath, - op: utils.JsonPathOp, - data: Any, - ) -> AutomodRule: ... + ) -> LogChannel: + cache = await self.db.get_cache() + return cache.require_default_log_channel(guild) - async def enable_rule(self, guild: Guild, name: str) -> AutomodRule: ... - - async def disable_rule(self, guild: Guild, name: str) -> AutomodRule: ... + async def get_default_log_channel( + self, + guild: Guild, + ) -> Optional[LogChannel]: + cache = await self.db.get_cache() + return cache.get_default_log_channel(guild) + + async def set_default_log_channel( + self, guild: Guild, log_channel: LogChannel + ) -> LogChannel: + cache = await self.db.get_cache() + result = cache.set_default_log_channel(guild, log_channel) + await self.db.commit() + return result + + async def modify_default_log_channel( + self, guild: Guild, log_channel: LogChannel + ) -> tuple[LogChannel, LogChannel]: + cache = await self.db.get_cache() + result = cache.modify_default_log_channel(guild, log_channel) + await self.db.commit() + return result + + async def remove_default_log_channel( + self, + guild: Guild, + ) -> LogChannel: + cache = await self.db.get_cache() + result = cache.remove_default_log_channel(guild) + await self.db.commit() + return result + + async def require_rule(self, guild: Guild, name: str) -> AutomodRule: + cache = await self.db.get_cache() + return cache.require_rule(guild, name) + + async def require_rule_metadata( + self, guild: Guild, name: str + ) -> AutomodRuleMetadata: + cache = await self.db.get_cache() + return cache.require_rule_metadata(guild, name) + + async def has_rule(self, guild: Guild, name: str) -> bool: + cache = await self.db.get_cache() + return cache.has_rule(guild, name) + + async def rules_for_event( + self, guild: Guild, event: AutomodEvent + ) -> AsyncIterable[AutomodRule]: + cache = await self.db.get_cache() + for rule in cache.rules_for_event(guild, event): + yield rule + + async def add_rule( + self, guild: Guild, rule: AutomodRule, user_id: UserID + ) -> tuple[AutomodRule, AutomodRuleMetadata]: + cache = await self.db.get_cache() + result = cache.add_rule(guild, rule, user_id) + await self.db.commit() + return result - async def increment_rule_hits(self, guild: Guild, name: str) -> AutomodRule: ... + async def modify_rule( + self, guild: Guild, rule: AutomodRule, user_id: UserID + ) -> tuple[AutomodRule, AutomodRule, AutomodRuleMetadata]: + cache = await self.db.get_cache() + result = cache.modify_rule(guild, rule, user_id) + await self.db.commit() + return result + + async def remove_rule( + self, guild: Guild, name: str + ) -> tuple[AutomodRule, AutomodRuleMetadata]: + cache = await self.db.get_cache() + result = cache.remove_rule(guild, name) + await self.db.commit() + return result + + async def increment_rule_hits(self, guild: Guild, name: str): + cache = await self.db.get_cache() + cache.increment_rule_hits(guild, name) + await self.db.commit() + + async def yield_rules( + self, guild: Guild, *, sort: bool = True + ) -> AsyncIterable[AutomodRule]: + cache = await self.db.get_cache() + for rule in cache.yield_rules(guild, sort): + yield rule + + async def rule_count(self, guild: Guild) -> int: + cache = await self.db.get_cache() + return cache.rule_count(guild) + + async def enable_rule(self, guild: Guild, name: str) -> AutomodRule: + cache = await self.db.get_cache() + rule = cache.enable_rule(guild, name) + await self.db.commit() + return rule + + async def disable_rule(self, guild: Guild, name: str) -> AutomodRule: + cache = await self.db.get_cache() + rule = cache.disable_rule(guild, name) + await self.db.commit() + return rule + + async def enable_all_rules(self, guild: Guild): + cache = await self.db.get_cache() + cache.enable_all_rules(guild) + await self.db.commit() + + async def disable_all_rules(self, guild: Guild): + cache = await self.db.get_cache() + cache.disable_all_rules(guild) + await self.db.commit() + + async def require_bucket(self, guild: Guild, name: str) -> AutomodBucket: + cache = await self.db.get_cache() + return cache.require_bucket(guild, name) + + async def require_bucket_with_type[BucketType = AutomodBucket]( + self, guild: Guild, name: str, bucket_type: type[BucketType] + ) -> BucketType: + cache = await self.db.get_cache() + return cache.require_bucket_with_type(guild, name, bucket_type) + + async def clear_bucket(self, guild: Guild, name: str): + cache = await self.db.get_cache() + bucket = cache.require_bucket(guild, name) + await bucket.clear() + await self.db.commit() + + async def bucket_count(self, guild: Guild) -> int: + cache = await self.db.get_cache() + return cache.bucket_count(guild) + + async def enable_bucket(self, guild: Guild, name: str) -> AutomodBucket: + cache = await self.db.get_cache() + bucket = cache.enable_bucket(guild, name) + await self.db.commit() + return bucket + + async def disable_bucket(self, guild: Guild, name: str) -> AutomodBucket: + cache = await self.db.get_cache() + bucket = cache.disable_bucket(guild, name) + await self.db.commit() + return bucket + + async def enable_all_buckets(self, guild: Guild): + cache = await self.db.get_cache() + cache.enable_all_buckets(guild) + await self.db.commit() + + async def disable_all_buckets(self, guild: Guild): + cache = await self.db.get_cache() + cache.disable_all_buckets(guild) + await self.db.commit() From 48c3e7694a2e465683f906b60726a2e3cbbba0de Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 12 Aug 2026 23:09:26 -0700 Subject: [PATCH 112/158] Changed field formatting --- commanderbot/ext/automod/actions/log_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/actions/log_message.py b/commanderbot/ext/automod/actions/log_message.py index d240f04..9571b7b 100644 --- a/commanderbot/ext/automod/actions/log_message.py +++ b/commanderbot/ext/automod/actions/log_message.py @@ -119,7 +119,7 @@ def _build_log_view( context_fields = context.get_fields() for field_name, field_title in self.fields.items(): if field_value := context_fields.get(field_name): - field_lines.append(f"- **{field_title}** **-** `{field_value}`") + field_lines.append(f"- **{field_title}**: `{field_value}`") if field_lines: log_container.add_item(ui.Separator()) From f42f8a79f24226514fb3c63a686b98a766e26efe Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 12 Aug 2026 23:11:05 -0700 Subject: [PATCH 113/158] Added a way to get the message and channel count across all user records --- .../ext/automod/buckets/message_history.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/commanderbot/ext/automod/buckets/message_history.py b/commanderbot/ext/automod/buckets/message_history.py index fee3ba8..8ec77ec 100644 --- a/commanderbot/ext/automod/buckets/message_history.py +++ b/commanderbot/ext/automod/buckets/message_history.py @@ -118,6 +118,22 @@ def lifetime_seconds(self) -> int: def interval_seconds(self) -> int: return int(self.lifetime.total_seconds()) + @property + def message_count(self) -> int: + count: int = 0 + for partition in self._history.values(): + for user_record in partition.values(): + count += user_record.message_count + return count + + @property + def channel_count(self) -> int: + count: int = 0 + for partition in self._history.values(): + for user_record in partition.values(): + count += user_record.channel_count + return count + def _clean_up(self): cutoff = utcnow() - self.lifetime for interval in list(self._history.keys()): From 63f1783e7b766ea615f7a985133783c0eb617222 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Wed, 12 Aug 2026 23:12:02 -0700 Subject: [PATCH 114/158] The default log can now be managed and started working on bucket commands --- commanderbot/ext/automod/automod_cog.py | 126 ++++-- commanderbot/ext/automod/automod_data.py | 185 +++------ .../ext/automod/automod_exceptions.py | 44 ++- .../ext/automod/automod_guild_state.py | 362 +++++++++++++----- commanderbot/ext/automod/automod_store.py | 134 +++++-- commanderbot/ext/automod/ui/log/__init__.py | 2 + .../ui/log/modify_default_log_modal.py | 128 +++++++ .../automod/ui/log/set_default_log_modal.py | 102 +++++ commanderbot/ext/automod/ui/rule/__init__.py | 2 + .../ext/automod/ui/rule/add_rule_modal.py | 44 +++ .../ext/automod/ui/rule/modify_rule_modal.py | 56 +++ 11 files changed, 879 insertions(+), 306 deletions(-) create mode 100644 commanderbot/ext/automod/ui/log/__init__.py create mode 100644 commanderbot/ext/automod/ui/log/modify_default_log_modal.py create mode 100644 commanderbot/ext/automod/ui/log/set_default_log_modal.py create mode 100644 commanderbot/ext/automod/ui/rule/__init__.py create mode 100644 commanderbot/ext/automod/ui/rule/add_rule_modal.py create mode 100644 commanderbot/ext/automod/ui/rule/modify_rule_modal.py diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index 1580e38..b932bc4 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -31,7 +31,7 @@ from commanderbot.ext.automod.automod_options import AutomodOptions from commanderbot.ext.automod.automod_state import AutomodState from commanderbot.ext.automod.automod_store import AutomodStore -from commanderbot.ext.automod.rule.rule import AutomodRule +from commanderbot.ext.automod.rule import AutomodRule from commanderbot.lib import is_guild from commanderbot.lib.cogs import CogGuildStateManager from commanderbot.lib.databases.json_db import JsonDB @@ -77,45 +77,67 @@ def __init__(self, bot: Bot, **options): # @@ COMMANDS # @@ automod schema - @command(name="schema", description="Export the Json schema for automod rules") + @command(name="schema", description="Export the Json schema for rules") async def cmd_automod_schema(self, interaction: Interaction): schema = json.dumps(AutomodRule.model_json_schema()) file = str_to_file(schema, "schema.json") await interaction.response.send_message( - "Exported Json schema for automod rules", file=file, ephemeral=True + "Exported Json schema for rules", file=file, ephemeral=True ) # @@ automod log cmd_automod_log = Group(name="log", description="Configure the default log channel") # @@ automod log set + @cmd_automod_log.command(name="set", description="Set the default log channel") + async def cmd_automod_log_set(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].set_default_log(interaction) # @@ automod log modify + @cmd_automod_log.command( + name="modify", description="Modify the default log channel" + ) + async def cmd_automod_log_modify(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].modify_default_log(interaction) # @@ automod log remove + @cmd_automod_log.command( + name="remove", description="Remove the default log channel" + ) + async def cmd_automod_log_remove(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].remove_default_log(interaction) # @@ automod log details + @cmd_automod_log.command( + name="details", description="Show details about the default log channel" + ) + async def cmd_automod_log_details(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].show_default_log_details(interaction) # @@ automod rules - cmd_automod_rules = Group(name="rules", description="Manage automod rules") + cmd_automod_rules = Group(name="rules", description="Manage rules") # @@ automod rules add - @cmd_automod_rules.command(name="add", description="Add a new automod rule") + @cmd_automod_rules.command(name="add", description="Add a new rule") async def cmd_automod_rules_all(self, interaction: Interaction): assert is_guild(interaction.guild) await self.state[interaction.guild].add_rule(interaction) # @@ automod rules modify - @cmd_automod_rules.command(name="modify", description="Modify an automod rule") - @describe(rule="The automod rule to modify") + @cmd_automod_rules.command(name="modify", description="Modify a rule") + @describe(rule="The rule to modify") async def cmd_automod_rules_modify(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) await self.state[interaction.guild].modify_rule(interaction, rule) # @@ automod rules upload - @cmd_automod_rules.command(name="upload", description="Upload an automod rule") + @cmd_automod_rules.command(name="upload", description="Upload a rule") @describe( - file="A Json file containing an automod rule (If the rule already exists, it will be modified)" + file="A Json file containing a rule (If the rule already exists, it will be modified)" ) async def cmd_automod_rules_upload( self, interaction: Interaction, file: Attachment @@ -124,44 +146,44 @@ async def cmd_automod_rules_upload( await self.state[interaction.guild].upload_rule(interaction, file) # @@ automod rules remove - @cmd_automod_rules.command(name="remove", description="Remove an automod rule") - @describe(rule="The automod rule to remove") + @cmd_automod_rules.command(name="remove", description="Remove a rule") + @describe(rule="The rule to remove") async def cmd_automod_rules_remove(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) await self.state[interaction.guild].remove_rule(interaction, rule) # @@ automod rules details @cmd_automod_rules.command( - name="details", description="Show the details about an automod rule" + name="details", description="Show details about a rule" ) - @describe(rule="The automod rule to show details about") + @describe(rule="The rule to show details about") async def cmd_automod_rules_details(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) await self.state[interaction.guild].show_rule_details(interaction, rule) - # @@ automod rules list - @cmd_automod_rules.command(name="list", description="List all automod rules") - async def cmd_automod_rules_list(self, interaction: Interaction): - assert is_guild(interaction.guild) - await self.state[interaction.guild].list_rules(interaction) - # @@ automod rules enable - @cmd_automod_rules.command(name="enable", description="Enable an automod rule") - @describe(rule="The automod rule to enable") + @cmd_automod_rules.command(name="enable", description="Enable a rule") + @describe(rule="The rule to enable") async def cmd_automod_rules_enable(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) await self.state[interaction.guild].enable_rule(interaction, rule) # @@ automod rules disable - @cmd_automod_rules.command(name="disable", description="disable an automod rule") - @describe(rule="The automod rule to disable") + @cmd_automod_rules.command(name="disable", description="Disable a rule") + @describe(rule="The rule to disable") async def cmd_automod_rules_disable(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) await self.state[interaction.guild].disable_rule(interaction, rule) + # @@ automod rules list + @cmd_automod_rules.command(name="list", description="List all rules") + async def cmd_automod_rules_list(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].list_rules(interaction) + # @@ automod rules enable-all @cmd_automod_rules.command( - name="enable-all", description="Enable all automod rules" + name="enable-all", description="Enable all rules" ) async def cmd_automod_rules_enable_all(self, interaction: Interaction): assert is_guild(interaction.guild) @@ -169,36 +191,84 @@ async def cmd_automod_rules_enable_all(self, interaction: Interaction): # @@ automod rules disable-all @cmd_automod_rules.command( - name="disable-all", description="Disable all automod rules" + name="disable-all", description="Disable all rules" ) async def cmd_automod_rules_disable_all(self, interaction: Interaction): assert is_guild(interaction.guild) await self.state[interaction.guild].disable_all_rule(interaction) # @@ automod buckets - cmd_automod_buckets = Group(name="buckets", description="Manage automod buckets") + cmd_automod_buckets = Group(name="buckets", description="Manage buckets") # @@ automod buckets add + async def cmd_automod_buckets_add(self, interaction: Interaction, type): + pass # @@ automod buckets modify + async def cmd_automod_buckets_modify(self, interaction: Interaction, bucket: str): + pass # @@ automod buckets remove + async def cmd_automod_buckets_remove(self, interaction: Interaction, bucket: str): + pass # @@ automod buckets details - - # @@ automod buckets list + @cmd_automod_buckets.command( + name="details", description="Show details about a bucket" + ) + @describe(bucket="The bucket to show details about") + async def cmd_automod_buckets_details(self, interaction: Interaction, bucket: str): + assert is_guild(interaction.guild) + await self.state[interaction.guild].show_bucket_details(interaction, bucket) # @@ automod buckets clear + @cmd_automod_buckets.command( + name="clear", description="Clear the saved data in a bucket" + ) + @describe(bucket="The bucket to clear the saved data of") + async def cmd_automod_buckets_clear(self, interaction: Interaction, bucket: str): + assert is_guild(interaction.guild) + await self.state[interaction.guild].clear_bucket(interaction, bucket) # @@ automod buckets enable + @cmd_automod_buckets.command(name="enable", description="Enable a bucket") + @describe(bucket="The bucket to enable") + async def cmd_automod_buckets_enable(self, interaction: Interaction, bucket: str): + assert is_guild(interaction.guild) + await self.state[interaction.guild].enable_bucket(interaction, bucket) # @@ automod buckets disable + @cmd_automod_buckets.command(name="disable", description="Disable a bucket") + @describe(bucket="The bucket to disable") + async def cmd_automod_buckets_disable(self, interaction: Interaction, bucket: str): + assert is_guild(interaction.guild) + await self.state[interaction.guild].disable_bucket(interaction, bucket) + + # @@ automod buckets list + @cmd_automod_buckets.command(name="list", description="List all buckets") + async def cmd_automod_buckets_list(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].list_buckets(interaction) # @@ automod buckets clear-all + @cmd_automod_buckets.command( + name="clear-all", description="Clear the saved data in all buckets" + ) + async def cmd_automod_buckets_clear_all(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].clear_all_buckets(interaction) # @@ automod buckets enable-all + @cmd_automod_buckets.command(name="enable-all", description="Enable all buckets") + async def cmd_automod_buckets_enable_all(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].enable_all_buckets(interaction) # @@ automod buckets disable-all + @cmd_automod_buckets.command(name="disable-all", description="Disable all buckets") + async def cmd_automod_buckets_disable_all(self, interaction: Interaction): + assert is_guild(interaction.guild) + await self.state[interaction.guild].disable_all_buckets(interaction) # @@ LISTENERS diff --git a/commanderbot/ext/automod/automod_data.py b/commanderbot/ext/automod/automod_data.py index bb01c27..b756697 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -2,13 +2,13 @@ from collections.abc import Iterable from typing import Annotated, Any, Optional -from discord import Guild from discord.utils import utcnow from pydantic import BaseModel, Field, PrivateAttr from commanderbot.ext.automod.automod_exceptions import ( AutomodBucketAlreadyDisabled, AutomodBucketAlreadyEnabled, + AutomodBucketAlreadyExists, AutomodBucketDoesNotExist, AutomodRuleAlreadyDisabled, AutomodRuleAlreadyEnabled, @@ -18,8 +18,7 @@ AutomodRuleMetadataDoesNotExist, DefaultLogChannelNotConfigured, ) -from commanderbot.ext.automod.bucket import AutomodBucket -from commanderbot.ext.automod.bucket.types import AutomodBucketType +from commanderbot.ext.automod.bucket import AutomodBucket, AutomodBucketType from commanderbot.ext.automod.event import AutomodEvent from commanderbot.ext.automod.rule import AutomodRule, AutomodRuleMetadata from commanderbot.lib.log_channel import LogChannel @@ -52,35 +51,33 @@ def _rebuild_mappings(self): for event_type in trigger.event_types: self._rules_by_event_type[event_type].add(rule) - def require_default_log_channel( + def require_default_log( self, ) -> LogChannel: - if log_channel := self.default_log: - return log_channel + if log := self.default_log: + return log raise DefaultLogChannelNotConfigured - def get_default_log_channel( + def get_default_log( self, ) -> Optional[LogChannel]: return self.default_log - def set_default_log_channel(self, log_channel: LogChannel) -> LogChannel: - self.default_log = log_channel + def set_default_log(self, log: LogChannel) -> LogChannel: + self.default_log = log return self.default_log - def modify_default_log_channel( - self, log_channel: LogChannel - ) -> tuple[LogChannel, LogChannel]: - old_log_channel = self.require_default_log_channel() - self.default_log = log_channel - return (old_log_channel, self.default_log) + def modify_default_log(self, log: LogChannel) -> tuple[LogChannel, LogChannel]: + old_log = self.require_default_log() + self.default_log = log + return (old_log, self.default_log) - def remove_default_log_channel( + def remove_default_log( self, ) -> LogChannel: - old_log_channel = self.require_default_log_channel() + old_log = self.require_default_log() self.default_log = None - return old_log_channel + return old_log def require_rule(self, name: str) -> AutomodRule: if rule := self.rules.get(name): @@ -92,6 +89,9 @@ def require_rule_metadata(self, name: str) -> AutomodRuleMetadata: return metadata raise AutomodRuleMetadataDoesNotExist(name) + def has_rule(self, name: str) -> bool: + return name in self.rules + def rules_for_event(self, event: AutomodEvent) -> Iterable[AutomodRule]: event_type = type(event) if rules := self._rules_by_event_type.get(event_type): @@ -154,6 +154,14 @@ def remove_rule(self, name: str) -> tuple[AutomodRule, AutomodRuleMetadata]: self._rebuild_mappings() return (rule, metadata) + def increment_rule_hits(self, name: str): + metadata = self.require_rule_metadata(name) + metadata.hits += 1 + + def yield_rules(self, *, sort: bool) -> Iterable[AutomodRule]: + rules = self.rules.values() + yield from (sorted(rules, key=lambda rule: rule.name) if sort else rules) + def rule_count(self) -> int: return len(self.rules) @@ -192,17 +200,51 @@ def require_bucket_with_type[BucketType = AutomodBucket]( return bucket raise AutomodBucketDoesNotExist(name) + def add_bucket(self, bucket: AutomodBucket) -> AutomodBucket: + # The bucket name needs to be available + if bucket.name in self.buckets: + raise AutomodBucketAlreadyExists(bucket.name) + + # Add the bucket + self.buckets[bucket.name] = bucket # type: ignore[ty:invalid-assignment] - This is fine since all buckets inherit from `AutomodBucket` + + return bucket + + def modify_bucket(self, bucket: AutomodBucket) -> AutomodBucket: + # The bucket need to exist + self.require_bucket_with_type(bucket.name, type(bucket)) + + # Modify the bucket + self.buckets[bucket.name] = bucket # type: ignore[ty:invalid-assignment] - This is fine since all buckets inherit from `AutomodBucket` + + return bucket + + def remove_bucket(self, name: str) -> AutomodBucket: + # The bucket need to exist + bucket = self.require_bucket(name) + + # Remove the bucket + del self.buckets[bucket.name] + + return bucket + + def yield_buckets(self, *, sort: bool) -> Iterable[AutomodBucket]: + buckets = self.buckets.values() + yield from ( + sorted(buckets, key=lambda bucket: bucket.name) if sort else buckets + ) + def bucket_count(self) -> int: return len(self.buckets) - def enable_bucket(self, name: str): + def enable_bucket(self, name: str) -> AutomodBucket: bucket = self.require_bucket(name) if bucket.disabled: bucket.disabled = False return bucket raise AutomodBucketAlreadyEnabled(name) - def disable_bucket(self, name: str): + def disable_bucket(self, name: str) -> AutomodBucket: bucket = self.require_bucket(name) if not bucket.disabled: bucket.disabled = True @@ -226,106 +268,3 @@ class AutomodData(BaseModel): default_factory=lambda: defaultdict(AutomodGuildData), exclude_if=lambda v: not v, ) - - def require_default_log_channel( - self, - guild: Guild, - ) -> LogChannel: - return self.guilds[guild.id].remove_default_log_channel() - - def get_default_log_channel( - self, - guild: Guild, - ) -> Optional[LogChannel]: - return self.guilds[guild.id].get_default_log_channel() - - def set_default_log_channel( - self, guild: Guild, log_channel: LogChannel - ) -> LogChannel: - return self.guilds[guild.id].set_default_log_channel(log_channel) - - def modify_default_log_channel( - self, guild: Guild, log_channel: LogChannel - ) -> tuple[LogChannel, LogChannel]: - return self.guilds[guild.id].modify_default_log_channel(log_channel) - - def remove_default_log_channel( - self, - guild: Guild, - ) -> LogChannel: - return self.guilds[guild.id].remove_default_log_channel() - - def require_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].require_rule(name) - - def require_rule_metadata(self, guild: Guild, name: str) -> AutomodRuleMetadata: - return self.guilds[guild.id].require_rule_metadata(name) - - def has_rule(self, guild: Guild, name: str) -> bool: - return name in self.guilds[guild.id].rules - - def rules_for_event( - self, guild: Guild, event: AutomodEvent - ) -> Iterable[AutomodRule]: - yield from self.guilds[guild.id].rules_for_event(event) - - def add_rule( - self, guild: Guild, rule: AutomodRule, user_id: UserID - ) -> tuple[AutomodRule, AutomodRuleMetadata]: - return self.guilds[guild.id].add_rule(rule, user_id) - - def modify_rule( - self, guild: Guild, rule: AutomodRule, user_id: UserID - ) -> tuple[AutomodRule, AutomodRule, AutomodRuleMetadata]: - return self.guilds[guild.id].modify_rule(rule, user_id) - - def remove_rule( - self, guild: Guild, name: str - ) -> tuple[AutomodRule, AutomodRuleMetadata]: - return self.guilds[guild.id].remove_rule(name) - - def increment_rule_hits(self, guild: Guild, name: str): - metadata = self.guilds[guild.id].require_rule_metadata(name) - metadata.hits += 1 - - def yield_rules(self, guild: Guild, sort: bool) -> Iterable[AutomodRule]: - rules = self.guilds[guild.id].rules.values() - yield from sorted(rules, key=lambda rule: rule.name) if sort else rules - - def rule_count(self, guild: Guild) -> int: - return self.guilds[guild.id].rule_count() - - def enable_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].enable_rule(name) - - def disable_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].disable_rule(name) - - def enable_all_rules(self, guild: Guild): - self.guilds[guild.id].enable_all_rules() - - def disable_all_rules(self, guild: Guild): - self.guilds[guild.id].disable_all_rules() - - def require_bucket(self, guild: Guild, name: str) -> AutomodBucket: - return self.guilds[guild.id].require_bucket(name) - - def require_bucket_with_type[BucketType = AutomodBucket]( - self, guild: Guild, name: str, bucket_type: type[BucketType] - ) -> BucketType: - return self.guilds[guild.id].require_bucket_with_type(name, bucket_type) - - def bucket_count(self, guild: Guild) -> int: - return self.guilds[guild.id].bucket_count() - - def enable_bucket(self, guild: Guild, name: str) -> AutomodBucket: - return self.guilds[guild.id].enable_bucket(name) - - def disable_bucket(self, guild: Guild, name: str) -> AutomodBucket: - return self.guilds[guild.id].disable_bucket(name) - - def enable_all_buckets(self, guild: Guild): - self.guilds[guild.id].enable_all_buckets() - - def disable_all_buckets(self, guild: Guild): - self.guilds[guild.id].disable_all_buckets() diff --git a/commanderbot/ext/automod/automod_exceptions.py b/commanderbot/ext/automod/automod_exceptions.py index e2e6303..391a2f0 100644 --- a/commanderbot/ext/automod/automod_exceptions.py +++ b/commanderbot/ext/automod/automod_exceptions.py @@ -5,11 +5,27 @@ class AutomodException(ResponsiveException): pass +class CouldNotValidateLogChannel(AutomodException): + def __init__(self, exception: ValueError): + self.exception = exception + super().__init__( + f"😵 An error occurred while validating the log channel:\n```\n{exception}\n```" + ) + + +class CouldNotValidateModifiedLogChannel(AutomodException): + def __init__(self, exception: ValueError): + self.exception = exception + super().__init__( + f"😵 An error occurred while validating the modified log channel:\n```\n{exception}\n```" + ) + + class CouldNotValidateNewAutomodRule(AutomodException): def __init__(self, exception: ValueError): self.exception = exception super().__init__( - f"😵 An error occurred while validating the new automod rule:\n```\n{exception}\n```" + f"😵 An error occurred while validating the new rule:\n```\n{exception}\n```" ) @@ -17,7 +33,7 @@ class CouldNotValidateModifiedAutomodRule(AutomodException): def __init__(self, exception: ValueError): self.exception = exception super().__init__( - f"😵 An error occurred while validating the modified automod rule:\n```\n{exception}\n```" + f"😵 An error occurred while validating the modified rule:\n```\n{exception}\n```" ) @@ -25,13 +41,13 @@ class CouldNotValidateUploadedAutomodRule(AutomodException): def __init__(self, exception: ValueError): self.exception = exception super().__init__( - f"😵 An error occurred while validating the uploaded automod rule:\n```\n{exception}\n```" + f"😵 An error occurred while validating the uploaded rule:\n```\n{exception}\n```" ) class UploadIsNotJsonFile(AutomodException): def __init__(self): - super().__init__("🤔 The uploaded automod rule is not a Json file") + super().__init__("🤔 The uploaded rule is not a Json file") class DefaultLogChannelNotConfigured(AutomodException): @@ -42,32 +58,32 @@ def __init__(self): class AutomodRuleAlreadyExists(AutomodException): def __init__(self, name: str): self.name: str = name - super().__init__(f"🤷 Automod rule `{self.name}` already exists") + super().__init__(f"🤷 Rule `{self.name}` already exists") class AutomodRuleDoesNotExist(AutomodException): def __init__(self, name: str): self.name: str = name - super().__init__(f"😬 Automod rule `{self.name}` does not exist") + super().__init__(f"😬 Rule `{self.name}` does not exist") class AutomodRuleAlreadyEnabled(AutomodException): def __init__(self, name: str): self.name: str = name - super().__init__(f"😬 Automod rule `{name}` is already enabled") + super().__init__(f"😬 Rule `{self.name}` is already enabled") class AutomodRuleAlreadyDisabled(AutomodException): def __init__(self, name: str): self.name: str = name - super().__init__(f"😬 Automod rule `{name}` is already disabled") + super().__init__(f"😬 Rule `{self.name}` is already disabled") class AutomodRuleMetadataAlreadyExists(AutomodException): def __init__(self, name: str): self.name: str = name super().__init__( - f"😬 Automod rule metadata `{self.name}` already exists (This should never happen!!!)" + f"😬 Rule metadata `{self.name}` already exists (This should never happen!!!)" ) @@ -75,32 +91,32 @@ class AutomodRuleMetadataDoesNotExist(AutomodException): def __init__(self, name: str): self.name: str = name super().__init__( - f"🤷 Automod rule metadata `{self.name}` does not exist (This should never happen!!!)" + f"🤷 Rule metadata `{self.name}` does not exist (This should never happen!!!)" ) class AutomodBucketAlreadyExists(AutomodException): def __init__(self, name: str): self.name: str = name - super().__init__(f"🤷 Automod bucket `{self.name}` already exists") + super().__init__(f"🤷 Bucket `{self.name}` already exists") class AutomodBucketDoesNotExist(AutomodException): def __init__(self, name: str): self.name: str = name - super().__init__(f"😬 Automod bucket `{self.name}` does not exist") + super().__init__(f"😬 Bucket `{self.name}` does not exist") class AutomodBucketAlreadyEnabled(AutomodException): def __init__(self, name: str): self.name: str = name - super().__init__(f"😬 Automod bucket `{name}` is already enabled") + super().__init__(f"😬 Bucket `{self.name}` is already enabled") class AutomodBucketAlreadyDisabled(AutomodException): def __init__(self, name: str): self.name: str = name - super().__init__(f"😬 Automod bucket `{name}` is already disabled") + super().__init__(f"😬 Bucket `{self.name}` is already disabled") class OCRNotSupported(AutomodException): diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index d3bf52a..56b2298 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -10,7 +10,6 @@ Member, Message, Reaction, - TextStyle, Thread, ThreadMember, User, @@ -20,20 +19,19 @@ from mime_enum import MimeType from commanderbot.core.utils import get_app_command -from commanderbot.ext.automod import events +from commanderbot.ext.automod import buckets, events from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.automod_exceptions import ( - CouldNotValidateModifiedAutomodRule, - CouldNotValidateNewAutomodRule, CouldNotValidateUploadedAutomodRule, UploadIsNotJsonFile, ) from commanderbot.ext.automod.automod_store import AutomodStore from commanderbot.ext.automod.event import AutomodEvent from commanderbot.ext.automod.rule import AutomodRule +from commanderbot.ext.automod.ui import log as log_ui +from commanderbot.ext.automod.ui import rule as rule_ui from commanderbot.lib.allowed_mentions import AllowedMentions from commanderbot.lib.cogs import CogGuildState -from commanderbot.lib.cogs.views import CogStateModal from commanderbot.lib.dialogs import ConfirmationResult, respond_with_confirmation from commanderbot.lib.types import GuildChannel, MessageableGuildChannel from commanderbot.lib.utils import str_to_file @@ -49,12 +47,88 @@ class AutomodGuildState(CogGuildState): # @@ COMMANDS + async def set_default_log(self, interaction: Interaction): + await interaction.response.send_modal( + log_ui.SetDefaultLogModal(interaction, self) + ) + + async def modify_default_log(self, interaction: Interaction): + log = await self.store.require_default_log(self.guild) + await interaction.response.send_modal( + log_ui.ModifyDefaultLogModal(interaction, self, log) + ) + + async def remove_default_log(self, interaction: Interaction): + # Get the default log + log = await self.store.require_default_log(self.guild) + + # Respond to this interaction with a confirmation dialog + result: ConfirmationResult = await respond_with_confirmation( + interaction, + f"Are you sure you want to remove the default log channel <#{log.channel}>?", + timeout=10.0, + ) + + # Handle response to dialog + match result: + case ConfirmationResult.YES: + try: + await self.store.remove_default_log(self.guild) + await interaction.followup.send( + content=f"Removed the default log channel <#{log.channel}>" + ) + except Exception: + await interaction.delete_original_response() + raise + case _: + await interaction.followup.send( + f"Did not remove the default log channel <#{log.channel}>" + ) + + async def show_default_log_details(self, interaction: Interaction): + # Get the default log + log = await self.store.require_default_log(self.guild) + + # Create fields + fields: dict[str, str] = { + "Channel": f"<#{log.channel}>", + "Emoji": log.emoji or "**None!**", + "Color": f"`{log.color}`" if log.color is not None else "**None!**", + "Stacktrace": "✅" if log.stacktrace else "❌", + "Allowed Mentions": "\n".join( + ( + "", + f"- Everyone: {'✅' if log.allowed_mentions.everyone else '❌'}", + f"- Users: {'✅' if log.allowed_mentions.users else '❌'}", + f"- Roles: {'✅' if log.allowed_mentions.roles else '❌'}", + f"- Replied User: {'✅' if log.allowed_mentions.replied_user else '❌'}", + ) + ), + } + + # Create the default log details view + view = ui.LayoutView() + container = ui.Container(accent_color=0x00ACED) + view.add_item(container) + + container.add_item( + ui.TextDisplay(f"### 🧾 Details for default log channel <#{log.channel}>") + ) + container.add_item(ui.Separator()) + for field_name, field_value in fields.items(): + container.add_item(ui.TextDisplay(f"**{field_name}**: {field_value}")) + + # Respond with the view + await interaction.response.send_message(view=view) + async def add_rule(self, interaction: Interaction): - await interaction.response.send_modal(AddRuleModal(interaction, self)) + await interaction.response.send_modal(rule_ui.AddRuleModal(interaction, self)) async def modify_rule(self, interaction: Interaction, name: str): rule = await self.store.require_rule(self.guild, name) - await interaction.response.send_modal(ModifyRuleModal(interaction, self, rule)) + await interaction.response.send_modal( + rule_ui.ModifyRuleModal(interaction, self, rule) + ) async def upload_rule(self, interaction: Interaction, file: Attachment): # The uploaded file needs to be a Json file @@ -71,12 +145,10 @@ async def upload_rule(self, interaction: Interaction, file: Attachment): # Modify the rule if it already exists if await self.store.has_rule(self.guild, rule.name): await self.store.modify_rule(self.guild, rule, interaction.user.id) - await interaction.response.send_message( - f"Modified automod rule `{rule.name}`" - ) + await interaction.response.send_message(f"Modified rule `{rule.name}`") else: await self.store.add_rule(self.guild, rule, interaction.user.id) - await interaction.response.send_message(f"Added automod rule `{rule.name}`") + await interaction.response.send_message(f"Added rule `{rule.name}`") async def remove_rule(self, interaction: Interaction, name: str): # Get the rule @@ -85,7 +157,7 @@ async def remove_rule(self, interaction: Interaction, name: str): # Respond to this interaction with a confirmation dialog result: ConfirmationResult = await respond_with_confirmation( interaction, - f"Are you sure you want to remove the automod rule `{rule.name}`?", + f"Are you sure you want to remove the rule `{rule.name}`?", timeout=10.0, ) @@ -95,14 +167,14 @@ async def remove_rule(self, interaction: Interaction, name: str): try: await self.store.remove_rule(self.guild, rule.name) await interaction.followup.send( - content=f"Removed the automod rule `{rule.name}`" + content=f"Removed the rule `{rule.name}`" ) except Exception: await interaction.delete_original_response() raise case _: await interaction.followup.send( - f"Did not remove the automod rule `{rule.name}`" + f"Did not remove the rule `{rule.name}`" ) async def show_rule_details(self, interaction: Interaction, name: str): @@ -115,8 +187,10 @@ async def show_rule_details(self, interaction: Interaction, name: str): rule_file = str_to_file(rule_json, f"{rule.name}.json") # Create fields + description = f"`{rule.description}`" if rule.description else "**None!**" fields: dict[str, str] = { "Name": f"`{rule.name}`", + "Description": description, "Enabled": "❌" if rule.disabled else "✅", "Hits": f"`{metadata.hits}`", "Added by": f"<@{metadata.added_by_id}>({format_dt(metadata.added_on, style='R')})", @@ -124,26 +198,32 @@ async def show_rule_details(self, interaction: Interaction, name: str): } # Create the rule details view - rule_details_view = ui.LayoutView() + view = ui.LayoutView() container = ui.Container(accent_color=0x00ACED) - rule_details_view.add_item(container) + view.add_item(container) - container.add_item( - ui.TextDisplay(f"### 📜 Details for automod rule `{rule.name}`") - ) + container.add_item(ui.TextDisplay(f"### 📜 Details for rule `{rule.name}`")) container.add_item(ui.Separator()) container.add_item(ui.File(rule_file)) container.add_item(ui.Separator()) for field_name, field_value in fields.items(): - container.add_item(ui.TextDisplay(f"**{field_name}** **-** {field_value}")) + container.add_item(ui.TextDisplay(f"**{field_name}**: {field_value}")) # Respond with the view await interaction.response.send_message( - view=rule_details_view, + view=view, file=rule_file, allowed_mentions=AllowedMentions.none(), ) + async def enable_rule(self, interaction: Interaction, name: str): + rule = await self.store.enable_rule(self.guild, name) + await interaction.response.send_message(f"Enabled rule `{rule.name}`") + + async def disable_rule(self, interaction: Interaction, name: str): + rule = await self.store.disable_rule(self.guild, name) + await interaction.response.send_message(f"Disabled rule `{rule.name}`") + async def list_rules(self, interaction: Interaction): # Get info about rules formatted_rules: list[str] = [] @@ -158,11 +238,11 @@ async def list_rules(self, interaction: Interaction): disabled_rules += 1 # Create rule list view - rule_list_view = ui.LayoutView() + view = ui.LayoutView() container = ui.Container(accent_color=0x00ACED) - rule_list_view.add_item(container) + view.add_item(container) - container.add_item(ui.TextDisplay("### 📜 All automod rules")) + container.add_item(ui.TextDisplay("### 📜 All rules")) container.add_item(ui.Separator()) if formatted_rules: @@ -177,28 +257,167 @@ async def list_rules(self, interaction: Interaction): ) # Respond with the view - await interaction.response.send_message(view=rule_list_view) - - async def enable_rule(self, interaction: Interaction, name: str): - rule = await self.store.enable_rule(self.guild, name) - await interaction.response.send_message(f"Enabled automod rule `{rule.name}`") - - async def disable_rule(self, interaction: Interaction, name: str): - rule = await self.store.disable_rule(self.guild, name) - await interaction.response.send_message(f"Disabled automod rule `{rule.name}`") + await interaction.response.send_message(view=view) async def enable_all_rule(self, interaction: Interaction): rule_count = await self.store.rule_count(self.guild) await self.store.enable_all_rules(self.guild) - await interaction.response.send_message( - f"Enabled all `{rule_count}` automod rules" - ) + await interaction.response.send_message(f"Enabled all `{rule_count}` rules") async def disable_all_rule(self, interaction: Interaction): rule_count = await self.store.rule_count(self.guild) await self.store.disable_all_rules(self.guild) + await interaction.response.send_message(f"Disabled all `{rule_count}` rules") + + async def add_flagged_image_attachments_bucket(self, interaction: Interaction): + pass + + async def add_message_history_bucket(self, interaction: Interaction): + pass + + async def modify_bucket(self, interaction: Interaction, name: str): + # Get the bucket + bucket = await self.store.require_bucket(self.guild, name) + + # Send the right modal based on the bucket type + match bucket: + case buckets.FlaggedImageAttachments(): + pass + case buckets.MessageHistory(): + pass + case _: + pass + + async def remove_bucket(self, interaction: Interaction, name: str): + # Get the bucket + bucket = await self.store.require_bucket(self.guild, name) + + # Respond to this interaction with a confirmation dialog + result: ConfirmationResult = await respond_with_confirmation( + interaction, + f"Are you sure you want to remove the bucket `{bucket.name}`?", + timeout=10.0, + ) + + # Handle response to dialog + match result: + case ConfirmationResult.YES: + try: + await self.store.remove_bucket(self.guild, bucket.name) + await interaction.followup.send( + content=f"Removed the bucket `{bucket.name}`" + ) + except Exception: + await interaction.delete_original_response() + raise + case _: + await interaction.followup.send( + f"Did not remove the bucket `{bucket.name}`" + ) + + async def show_bucket_details(self, interaction: Interaction, name: str): + # Get the bucket + bucket = await self.store.require_bucket(self.guild, name) + + # Create fields + description = f"`{bucket.description}`" if bucket.description else "**None!**" + fields: dict[str, str] = { + "Type": f"`{bucket.type}`", + "Name": f"`{bucket.name}`", + "Description": description, + "Enabled": "❌" if bucket.disabled else "✅", + } + + # Add any fields that are specific to the bucket type + match bucket: + case buckets.FlaggedImageAttachments(): + fields["Lifetime"] = f"`{bucket.lifetime}`" + fields["Flagged"] = f"`{len(bucket.attachments)}`" + case buckets.MessageHistory(): + fields["Lifetime"] = f"`{bucket.lifetime}`" + fields["Interval"] = f"`{bucket.interval}`" + fields["Messages"] = f"`{bucket.message_count}`" + fields["Channels"] = f"`{bucket.channel_count}`" + case _: + pass + + # Create the bucket details view + view = ui.LayoutView() + container = ui.Container(accent_color=0x00ACED) + view.add_item(container) + + container.add_item(ui.TextDisplay(f"### 🪣 Details for bucket `{bucket.name}`")) + container.add_item(ui.Separator()) + for field_name, field_value in fields.items(): + container.add_item(ui.TextDisplay(f"**{field_name}**: {field_value}")) + + # Respond with the view + await interaction.response.send_message(view=view) + + async def clear_bucket(self, interaction: Interaction, name: str): + bucket = await self.store.clear_bucket(self.guild, name) + await interaction.response.send_message(f"Cleared bucket `{bucket.name}`") + + async def enable_bucket(self, interaction: Interaction, name: str): + bucket = await self.store.enable_bucket(self.guild, name) + await interaction.response.send_message(f"Enabled bucket `{bucket.name}`") + + async def disable_bucket(self, interaction: Interaction, name: str): + bucket = await self.store.disable_bucket(self.guild, name) + await interaction.response.send_message(f"Disabled bucket `{bucket.name}`") + + async def list_buckets(self, interaction: Interaction): + # Get info about buckets + formatted_buckets: list[str] = [] + enabled_buckets: int = 0 + disabled_buckets: int = 0 + async for bucket in self.store.yield_buckets(self.guild): + if not bucket.disabled: + formatted_buckets.append(bucket.name) + enabled_buckets += 1 + else: + formatted_buckets.append(f"{bucket.name} (Disabled)") + disabled_buckets += 1 + + # Create bucket list view + view = ui.LayoutView() + container = ui.Container(accent_color=0x00ACED) + view.add_item(container) + + container.add_item(ui.TextDisplay("### 🪣 All buckets")) + container.add_item(ui.Separator()) + + if formatted_buckets: + lines = "\n".join(("```", "\n".join(formatted_buckets), "```")) + container.add_item(ui.TextDisplay(lines)) + else: + container.add_item(ui.TextDisplay("**None!**")) + + container.add_item(ui.Separator()) + container.add_item( + ui.TextDisplay( + f"-# Enabled: {enabled_buckets} | Disabled: {disabled_buckets}" + ) + ) + + # Respond with the view + await interaction.response.send_message(view=view) + + async def clear_all_buckets(self, interaction: Interaction): + bucket_count = await self.store.bucket_count(self.guild) + await self.store.clear_all_buckets(self.guild) + await interaction.response.send_message(f"Cleared all `{bucket_count}` buckets") + + async def enable_all_buckets(self, interaction: Interaction): + bucket_count = await self.store.bucket_count(self.guild) + await self.store.enable_all_buckets(self.guild) + await interaction.response.send_message(f"Enabled all `{bucket_count}` buckets") + + async def disable_all_buckets(self, interaction: Interaction): + bucket_count = await self.store.bucket_count(self.guild) + await self.store.disable_all_buckets(self.guild) await interaction.response.send_message( - f"Disabled all `{rule_count}` automod rules" + f"Disabled all `{bucket_count}` buckets" ) # @@ UTILITIES @@ -218,7 +437,7 @@ async def _handle_rule_error(self, rule: AutomodRule, error: Exception): self.log.exception(f"Automod rule '{rule.name}' caused an error") # Try to get the log channel - log_channel = rule.log or await self.store.get_default_log_channel(self.guild) + log_channel = rule.log or await self.store.get_default_log(self.guild) if not log_channel: return @@ -343,70 +562,3 @@ async def on_user_banned(self, user: User): async def on_user_unbanned(self, user: User): await self.dispatch_event(events.UserUnbanned(user)) - - -class AddRuleModal(CogStateModal[AutomodGuildState, AutomodStore]): - def __init__(self, interaction: Interaction, state: AutomodGuildState): - super().__init__( - interaction, - state, - title="Add a new automod rule", - custom_id="commanderbot_ext:automod.rule.add", - ) - - self.rule_input = ui.TextInput( - label="The automod rule in Json format", - style=TextStyle.paragraph, - placeholder="{}", - required=True, - ) - self.help_display = ui.TextDisplay( - f"-# Run {state.get_schema_command()} if you need the schema" - ) - - self.add_item(self.rule_input) - self.add_item(self.help_display) - - async def on_submit(self, interaction: Interaction): - try: - rule = AutomodRule.model_validate_json(self.rule_input.value) - await self.store.add_rule(self.state.guild, rule, interaction.user.id) - await interaction.response.send_message(f"Added automod rule `{rule.name}`") - except ValueError as ex: - raise CouldNotValidateNewAutomodRule(ex) - - -class ModifyRuleModal(CogStateModal[AutomodGuildState, AutomodStore]): - def __init__( - self, interaction: Interaction, state: AutomodGuildState, rule: AutomodRule - ): - super().__init__( - interaction, - state, - title=f"Modifying automod rule '{rule.name}'", - custom_id="commanderbot_ext:automod.rule.modify", - ) - - self.rule_input = ui.TextInput( - label="The automod rule in Json format", - style=TextStyle.paragraph, - placeholder="{}", - default=rule.model_dump_json(indent=4, exclude_defaults=True), - required=True, - ) - self.help_display = ui.TextDisplay( - f"-# Run {state.get_schema_command()} if you need the schema" - ) - - self.add_item(self.rule_input) - self.add_item(self.help_display) - - async def on_submit(self, interaction: Interaction): - try: - rule = AutomodRule.model_validate_json(self.rule_input.value) - await self.store.modify_rule(self.state.guild, rule, interaction.user.id) - await interaction.response.send_message( - f"Modified automod rule `{rule.name}`" - ) - except ValueError as ex: - raise CouldNotValidateModifiedAutomodRule(ex) diff --git a/commanderbot/ext/automod/automod_store.py b/commanderbot/ext/automod/automod_store.py index df0d0b5..1097157 100644 --- a/commanderbot/ext/automod/automod_store.py +++ b/commanderbot/ext/automod/automod_store.py @@ -18,71 +18,79 @@ class AutomodStore(CogStore): db: JsonDB[AutomodData] - async def require_default_log_channel( + async def require_default_log( self, guild: Guild, ) -> LogChannel: cache = await self.db.get_cache() - return cache.require_default_log_channel(guild) + guild_data = cache.guilds[guild.id] + return guild_data.require_default_log() - async def get_default_log_channel( + async def get_default_log( self, guild: Guild, ) -> Optional[LogChannel]: cache = await self.db.get_cache() - return cache.get_default_log_channel(guild) + guild_data = cache.guilds[guild.id] + return guild_data.get_default_log() - async def set_default_log_channel( - self, guild: Guild, log_channel: LogChannel - ) -> LogChannel: + async def set_default_log(self, guild: Guild, log: LogChannel) -> LogChannel: cache = await self.db.get_cache() - result = cache.set_default_log_channel(guild, log_channel) + guild_data = cache.guilds[guild.id] + result = guild_data.set_default_log(log) await self.db.commit() return result - async def modify_default_log_channel( - self, guild: Guild, log_channel: LogChannel + async def modify_default_log( + self, guild: Guild, log: LogChannel ) -> tuple[LogChannel, LogChannel]: cache = await self.db.get_cache() - result = cache.modify_default_log_channel(guild, log_channel) + guild_data = cache.guilds[guild.id] + result = guild_data.modify_default_log(log) await self.db.commit() return result - async def remove_default_log_channel( + async def remove_default_log( self, guild: Guild, ) -> LogChannel: cache = await self.db.get_cache() - result = cache.remove_default_log_channel(guild) + guild_data = cache.guilds[guild.id] + result = guild_data.remove_default_log() await self.db.commit() return result async def require_rule(self, guild: Guild, name: str) -> AutomodRule: cache = await self.db.get_cache() - return cache.require_rule(guild, name) + guild_data = cache.guilds[guild.id] + return guild_data.require_rule(name) async def require_rule_metadata( self, guild: Guild, name: str ) -> AutomodRuleMetadata: cache = await self.db.get_cache() - return cache.require_rule_metadata(guild, name) + guild_data = cache.guilds[guild.id] + return guild_data.require_rule_metadata(name) async def has_rule(self, guild: Guild, name: str) -> bool: cache = await self.db.get_cache() - return cache.has_rule(guild, name) + guild_data = cache.guilds[guild.id] + return guild_data.has_rule(name) async def rules_for_event( self, guild: Guild, event: AutomodEvent ) -> AsyncIterable[AutomodRule]: cache = await self.db.get_cache() - for rule in cache.rules_for_event(guild, event): + guild_data = cache.guilds[guild.id] + for rule in guild_data.rules_for_event(event): yield rule async def add_rule( self, guild: Guild, rule: AutomodRule, user_id: UserID ) -> tuple[AutomodRule, AutomodRuleMetadata]: cache = await self.db.get_cache() - result = cache.add_rule(guild, rule, user_id) + guild_data = cache.guilds[guild.id] + result = guild_data.add_rule(rule, user_id) await self.db.commit() return result @@ -90,7 +98,8 @@ async def modify_rule( self, guild: Guild, rule: AutomodRule, user_id: UserID ) -> tuple[AutomodRule, AutomodRule, AutomodRuleMetadata]: cache = await self.db.get_cache() - result = cache.modify_rule(guild, rule, user_id) + guild_data = cache.guilds[guild.id] + result = guild_data.modify_rule(rule, user_id) await self.db.commit() return result @@ -98,86 +107,139 @@ async def remove_rule( self, guild: Guild, name: str ) -> tuple[AutomodRule, AutomodRuleMetadata]: cache = await self.db.get_cache() - result = cache.remove_rule(guild, name) + guild_data = cache.guilds[guild.id] + result = guild_data.remove_rule(name) await self.db.commit() return result async def increment_rule_hits(self, guild: Guild, name: str): cache = await self.db.get_cache() - cache.increment_rule_hits(guild, name) + guild_data = cache.guilds[guild.id] + guild_data.increment_rule_hits(name) await self.db.commit() async def yield_rules( self, guild: Guild, *, sort: bool = True ) -> AsyncIterable[AutomodRule]: cache = await self.db.get_cache() - for rule in cache.yield_rules(guild, sort): + guild_data = cache.guilds[guild.id] + for rule in guild_data.yield_rules(sort=sort): yield rule async def rule_count(self, guild: Guild) -> int: cache = await self.db.get_cache() - return cache.rule_count(guild) + guild_data = cache.guilds[guild.id] + return guild_data.rule_count() async def enable_rule(self, guild: Guild, name: str) -> AutomodRule: cache = await self.db.get_cache() - rule = cache.enable_rule(guild, name) + guild_data = cache.guilds[guild.id] + rule = guild_data.enable_rule(name) await self.db.commit() return rule async def disable_rule(self, guild: Guild, name: str) -> AutomodRule: cache = await self.db.get_cache() - rule = cache.disable_rule(guild, name) + guild_data = cache.guilds[guild.id] + rule = guild_data.disable_rule(name) await self.db.commit() return rule async def enable_all_rules(self, guild: Guild): cache = await self.db.get_cache() - cache.enable_all_rules(guild) + guild_data = cache.guilds[guild.id] + guild_data.enable_all_rules() await self.db.commit() async def disable_all_rules(self, guild: Guild): cache = await self.db.get_cache() - cache.disable_all_rules(guild) + guild_data = cache.guilds[guild.id] + guild_data.disable_all_rules() await self.db.commit() async def require_bucket(self, guild: Guild, name: str) -> AutomodBucket: cache = await self.db.get_cache() - return cache.require_bucket(guild, name) + guild_data = cache.guilds[guild.id] + return guild_data.require_bucket(name) async def require_bucket_with_type[BucketType = AutomodBucket]( self, guild: Guild, name: str, bucket_type: type[BucketType] ) -> BucketType: cache = await self.db.get_cache() - return cache.require_bucket_with_type(guild, name, bucket_type) + guild_data = cache.guilds[guild.id] + return guild_data.require_bucket_with_type(name, bucket_type) + + async def add_bucket(self, guild: Guild, bucket: AutomodBucket) -> AutomodBucket: + cache = await self.db.get_cache() + guild_data = cache.guilds[guild.id] + result = guild_data.add_bucket(bucket) + await self.db.commit() + return result + + async def modify_bucket(self, guild: Guild, bucket: AutomodBucket) -> AutomodBucket: + cache = await self.db.get_cache() + guild_data = cache.guilds[guild.id] + result = guild_data.modify_bucket(bucket) + await self.db.commit() + return result + + async def remove_bucket(self, guild: Guild, name: str) -> AutomodBucket: + cache = await self.db.get_cache() + guild_data = cache.guilds[guild.id] + bucket = guild_data.remove_bucket(name) + await self.db.commit() + return bucket - async def clear_bucket(self, guild: Guild, name: str): + async def clear_bucket(self, guild: Guild, name: str) -> AutomodBucket: cache = await self.db.get_cache() - bucket = cache.require_bucket(guild, name) + guild_data = cache.guilds[guild.id] + bucket = guild_data.require_bucket(name) await bucket.clear() await self.db.commit() + return bucket + + async def yield_buckets( + self, guild: Guild, *, sort: bool = True + ) -> AsyncIterable[AutomodBucket]: + cache = await self.db.get_cache() + guild_data = cache.guilds[guild.id] + for bucket in guild_data.yield_buckets(sort=sort): + yield bucket async def bucket_count(self, guild: Guild) -> int: cache = await self.db.get_cache() - return cache.bucket_count(guild) + guild_data = cache.guilds[guild.id] + return guild_data.bucket_count() async def enable_bucket(self, guild: Guild, name: str) -> AutomodBucket: cache = await self.db.get_cache() - bucket = cache.enable_bucket(guild, name) + guild_data = cache.guilds[guild.id] + bucket = guild_data.enable_bucket(name) await self.db.commit() return bucket async def disable_bucket(self, guild: Guild, name: str) -> AutomodBucket: cache = await self.db.get_cache() - bucket = cache.disable_bucket(guild, name) + guild_data = cache.guilds[guild.id] + bucket = guild_data.disable_bucket(name) await self.db.commit() return bucket + async def clear_all_buckets(self, guild: Guild): + cache = await self.db.get_cache() + guild_data = cache.guilds[guild.id] + for bucket in guild_data.yield_buckets(sort=False): + await bucket.clear() + await self.db.commit() + async def enable_all_buckets(self, guild: Guild): cache = await self.db.get_cache() - cache.enable_all_buckets(guild) + guild_data = cache.guilds[guild.id] + guild_data.enable_all_buckets() await self.db.commit() async def disable_all_buckets(self, guild: Guild): cache = await self.db.get_cache() - cache.disable_all_buckets(guild) + guild_data = cache.guilds[guild.id] + guild_data.disable_all_buckets() await self.db.commit() diff --git a/commanderbot/ext/automod/ui/log/__init__.py b/commanderbot/ext/automod/ui/log/__init__.py new file mode 100644 index 0000000..8fa14e5 --- /dev/null +++ b/commanderbot/ext/automod/ui/log/__init__.py @@ -0,0 +1,2 @@ +from .modify_default_log_modal import * +from .set_default_log_modal import * diff --git a/commanderbot/ext/automod/ui/log/modify_default_log_modal.py b/commanderbot/ext/automod/ui/log/modify_default_log_modal.py new file mode 100644 index 0000000..49c90b8 --- /dev/null +++ b/commanderbot/ext/automod/ui/log/modify_default_log_modal.py @@ -0,0 +1,128 @@ +from typing import TYPE_CHECKING, override + +from discord import ( + ChannelType, + Interaction, + SelectDefaultValue, + SelectDefaultValueType, + TextStyle, + ui, +) + +from commanderbot.ext.automod.automod_exceptions import ( + CouldNotValidateModifiedLogChannel, +) +from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.lib.cogs.views import CogStateModal +from commanderbot.lib.log_channel import LogChannel + +__all__ = ("ModifyDefaultLogModal",) + +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + + +class ModifyDefaultLogModal(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__( + self, interaction: Interaction, state: AutomodGuildState, log: LogChannel + ): + super().__init__( + interaction, + state, + title="Modifying default log channel", + custom_id="commanderbot_ext:automod.log.modify", + ) + + self.channel_input = ui.ChannelSelect( + channel_types=[ChannelType.text, ChannelType.public_thread], + placeholder="Pick a channel", + default_values=[ + SelectDefaultValue(id=log.channel, type=SelectDefaultValueType.channel) + ], + min_values=1, + max_values=1, + required=True, + ) + self.channel_label = ui.Label( + text="The channel to send log messages in", component=self.channel_input + ) + + self.emoji_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 🔥", + default=log.emoji or "", + required=False, + ) + self.emoji_label = ui.Label( + text="The emoji used for log messages", + description="Can be a regular emoji or Discord emoji", + component=self.emoji_input, + ) + + self.color_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'mcc_blue' or '0x00ACED'", + default=str(log.color) if log.color is not None else "", + required=False, + ) + self.color_label = ui.Label( + text="The color used for log messages", + description="Can be the name of a color, a hex code, or an integer", + component=self.color_input, + ) + + self.stacktrace_input = ui.CheckboxGroup(required=False) + self.stacktrace_input.add_option( + label="\u200b", default=log.stacktrace or False + ) + self.stacktrace_label = ui.Label( + text="Print exception stacktraces", component=self.stacktrace_input + ) + + self.allowed_mentions_input = ui.CheckboxGroup(required=False) + self.allowed_mentions_input.add_option( + label="Everyone", default=bool(log.allowed_mentions.everyone) + ) + self.allowed_mentions_input.add_option( + label="Users", default=bool(log.allowed_mentions.users) + ) + self.allowed_mentions_input.add_option( + label="Roles", default=bool(log.allowed_mentions.roles) + ) + self.allowed_mentions_input.add_option( + label="Replied User", default=bool(log.allowed_mentions.replied_user) + ) + self.allowed_mentions_label = ui.Label( + text="The types of mentions allowed in log messages", + component=self.allowed_mentions_input, + ) + + self.add_item(self.channel_label) + self.add_item(self.emoji_label) + self.add_item(self.color_label) + self.add_item(self.stacktrace_label) + self.add_item(self.allowed_mentions_label) + + @override + async def on_submit(self, interaction: Interaction): + raw_log = { + "channel": self.channel_input.values[-1].id, + "emoji": self.emoji_input.value or None, + "color": self.color_input.value or None, + "stacktrace": bool(self.stacktrace_input.values) or None, + "allowed_mentions": { + "everyone": "Everyone" in self.allowed_mentions_input.values, + "users": "Users" in self.allowed_mentions_input.values, + "roles": "Roles" in self.allowed_mentions_input.values, + "replied_user": "Replied User" in self.allowed_mentions_input.values, + }, + } + + try: + log = LogChannel.model_validate(raw_log) + await self.store.modify_default_log(self.state.guild, log) + await interaction.response.send_message( + f"Modified default log channel <#{log.channel}>" + ) + except ValueError as ex: + raise CouldNotValidateModifiedLogChannel(ex) diff --git a/commanderbot/ext/automod/ui/log/set_default_log_modal.py b/commanderbot/ext/automod/ui/log/set_default_log_modal.py new file mode 100644 index 0000000..aba0514 --- /dev/null +++ b/commanderbot/ext/automod/ui/log/set_default_log_modal.py @@ -0,0 +1,102 @@ +from typing import TYPE_CHECKING, override + +from discord import ChannelType, Interaction, TextStyle, ui + +from commanderbot.ext.automod.automod_exceptions import CouldNotValidateLogChannel +from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.lib.cogs.views import CogStateModal +from commanderbot.lib.log_channel import LogChannel + +__all__ = ("SetDefaultLogModal",) + +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + + +class SetDefaultLogModal(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__(self, interaction: Interaction, state: AutomodGuildState): + super().__init__( + interaction, + state, + title="Set the default log channel", + custom_id="commanderbot_ext:automod.log.set", + ) + + self.channel_input = ui.ChannelSelect( + channel_types=[ChannelType.text, ChannelType.public_thread], + placeholder="Pick a channel", + min_values=1, + max_values=1, + required=True, + ) + self.channel_label = ui.Label( + text="The channel to send log messages in", component=self.channel_input + ) + + self.emoji_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 🔥", + required=False, + ) + self.emoji_label = ui.Label( + text="The emoji used for log messages", + description="Can be a regular emoji or Discord emoji", + component=self.emoji_input, + ) + + self.color_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'mcc_blue' or '0x00ACED'", + required=False, + ) + self.color_label = ui.Label( + text="The color used for log messages", + description="Can be the name of a color, a hex code, or an integer", + component=self.color_input, + ) + + self.stacktrace_input = ui.CheckboxGroup(required=False) + self.stacktrace_input.add_option(label="\u200b", default=False) + self.stacktrace_label = ui.Label( + text="Print exception stacktraces", component=self.stacktrace_input + ) + + self.allowed_mentions_input = ui.CheckboxGroup(required=False) + self.allowed_mentions_input.add_option(label="Everyone", default=False) + self.allowed_mentions_input.add_option(label="Users", default=False) + self.allowed_mentions_input.add_option(label="Roles", default=False) + self.allowed_mentions_input.add_option(label="Replied User", default=False) + self.allowed_mentions_label = ui.Label( + text="The types of mentions allowed in log messages", + component=self.allowed_mentions_input, + ) + + self.add_item(self.channel_label) + self.add_item(self.emoji_label) + self.add_item(self.color_label) + self.add_item(self.stacktrace_label) + self.add_item(self.allowed_mentions_label) + + @override + async def on_submit(self, interaction: Interaction): + raw_log = { + "channel": self.channel_input.values[-1].id, + "emoji": self.emoji_input.value or None, + "color": self.color_input.value or None, + "stacktrace": bool(self.stacktrace_input.values) or None, + "allowed_mentions": { + "everyone": "Everyone" in self.allowed_mentions_input.values, + "users": "Users" in self.allowed_mentions_input.values, + "roles": "Roles" in self.allowed_mentions_input.values, + "replied_user": "Replied User" in self.allowed_mentions_input.values, + }, + } + + try: + log = LogChannel.model_validate(raw_log) + await self.store.set_default_log(self.state.guild, log) + await interaction.response.send_message( + f"Set default log channel to <#{log.channel}>" + ) + except ValueError as ex: + raise CouldNotValidateLogChannel(ex) diff --git a/commanderbot/ext/automod/ui/rule/__init__.py b/commanderbot/ext/automod/ui/rule/__init__.py new file mode 100644 index 0000000..19f327e --- /dev/null +++ b/commanderbot/ext/automod/ui/rule/__init__.py @@ -0,0 +1,2 @@ +from .add_rule_modal import * +from .modify_rule_modal import * diff --git a/commanderbot/ext/automod/ui/rule/add_rule_modal.py b/commanderbot/ext/automod/ui/rule/add_rule_modal.py new file mode 100644 index 0000000..2602bbb --- /dev/null +++ b/commanderbot/ext/automod/ui/rule/add_rule_modal.py @@ -0,0 +1,44 @@ +from typing import TYPE_CHECKING, override + +from discord import Interaction, TextStyle, ui + +from commanderbot.ext.automod.automod_exceptions import CouldNotValidateNewAutomodRule +from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.ext.automod.rule import AutomodRule +from commanderbot.lib.cogs.views import CogStateModal + +__all__ = ("AddRuleModal",) + +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + + +class AddRuleModal(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__(self, interaction: Interaction, state: AutomodGuildState): + super().__init__( + interaction, + state, + title="Add a new rule", + custom_id="commanderbot_ext:automod.rule.add", + ) + + self.rule_input = ui.TextInput( + style=TextStyle.paragraph, + placeholder="{}", + required=True, + ) + self.rule_input_label = ui.Label( + text="The rule in Json format", + description=f"Run {state.get_schema_command()} if you need the schema", + component=self.rule_input, + ) + self.add_item(self.rule_input_label) + + @override + async def on_submit(self, interaction: Interaction): + try: + rule = AutomodRule.model_validate_json(self.rule_input.value) + await self.store.add_rule(self.state.guild, rule, interaction.user.id) + await interaction.response.send_message(f"Added rule `{rule.name}`") + except ValueError as ex: + raise CouldNotValidateNewAutomodRule(ex) diff --git a/commanderbot/ext/automod/ui/rule/modify_rule_modal.py b/commanderbot/ext/automod/ui/rule/modify_rule_modal.py new file mode 100644 index 0000000..c6c8482 --- /dev/null +++ b/commanderbot/ext/automod/ui/rule/modify_rule_modal.py @@ -0,0 +1,56 @@ +from typing import TYPE_CHECKING, override + +from discord import Interaction, TextStyle, ui + +from commanderbot.ext.automod.automod_exceptions import ( + CouldNotValidateModifiedAutomodRule, +) +from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.ext.automod.rule import AutomodRule +from commanderbot.lib.cogs.views import CogStateModal +from commanderbot.lib.constants import MAX_MODAL_TITLE_LENGTH + +__all__ = ("ModifyRuleModal",) + +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + + +class ModifyRuleModal(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__( + self, interaction: Interaction, state: AutomodGuildState, rule: AutomodRule + ): + title: str = f"Modifying rule '{rule.name}'" + if len(title) > MAX_MODAL_TITLE_LENGTH: + title = f"{title[:42]}..." + + super().__init__( + interaction, + state, + title=title, + custom_id="commanderbot_ext:automod.rule.modify", + ) + + self.rule_input = ui.TextInput( + style=TextStyle.paragraph, + placeholder="{}", + default=rule.model_dump_json(indent=4, exclude_defaults=True), + required=True, + ) + self.rule_input_label = ui.Label( + text="The rule in Json format", + description=f"Run {state.get_schema_command()} if you need the schema", + component=self.rule_input, + ) + self.add_item(self.rule_input_label) + + @override + async def on_submit(self, interaction: Interaction): + try: + rule = AutomodRule.model_validate_json(self.rule_input.value) + await self.store.modify_rule(self.state.guild, rule, interaction.user.id) + await interaction.response.send_message( + f"Modified rule `{rule.name}`" + ) + except ValueError as ex: + raise CouldNotValidateModifiedAutomodRule(ex) From 515233863b223aeaf5b41c5ca4683eb59b975a25 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 13 Aug 2026 22:17:16 -0700 Subject: [PATCH 115/158] Updated dependencies --- pyproject.toml | 12 +- uv.lock | 348 ++++++++++++++++++++++++------------------------- 2 files changed, 178 insertions(+), 182 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ef661db..f3e68ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,21 +21,21 @@ dependencies = [ "discord-py>=2.7.0", "python-dotenv>=1.0.0", "pydantic>=2.13.0", - "aiohttp>=3.13.0", + "aiohttp>=3.14.0", # These are used for logging. "colorama>=0.4.0", - "colorlog>=6.10.0", + "colorlog>=6.11.0", # These are used for handling text-based data (json, yaml, etc). - "jsonpath-ng>=1.7.0", + "jsonpath-ng>=1.8.0", "pyyaml>=6.0.0", # The rest are for various extensions. - "emoji>=2.0.0", + "emoji>=2.14.0", "allay>=1.3.0", "mccq>=1.0.0", - "psutil>=7.0.0", + "psutil>=7.2.0", "types-psutil>=7.2.0", "numpy>=2.5.0", "opencv-python-headless>=5.0.0.0", @@ -58,7 +58,7 @@ commanderbot = "commanderbot:__main__" [dependency-groups] dev = [ "ruff>=0.16.0", - "ty>=0.0.69", + "ty>=0.0.71", ] [tool.ruff.lint] diff --git a/uv.lock b/uv.lock index 9e34308..d018572 100644 --- a/uv.lock +++ b/uv.lock @@ -2,23 +2,22 @@ version = 1 revision = 3 requires-python = "==3.14.*" resolution-markers = [ - "sys_platform == 'linux'", "sys_platform == 'win32'", - "sys_platform != 'linux' and sys_platform != 'win32'", + "sys_platform != 'win32'", ] [[package]] name = "aiohappyeyeballs" -version = "2.6.2" +version = "2.7.1" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, ] [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -29,49 +28,49 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -100,11 +99,11 @@ wheels = [ [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] @@ -167,14 +166,14 @@ wheels = [ [[package]] name = "colorlog" -version = "6.10.1" +version = "6.12.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/55/ba79756cb90c8d69d599d57785398ac87bba7b19c80e87f4e8a562197c93/colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f", size = 18151, upload-time = "2026-07-23T13:40:40.71Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, + { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" }, ] [[package]] @@ -199,7 +198,6 @@ dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "pyyaml" }, - { name = "ty" }, { name = "types-psutil" }, ] @@ -217,26 +215,25 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.13.0" }, + { name = "aiohttp", specifier = ">=3.14.0" }, { name = "allay", specifier = ">=1.3.0" }, { name = "colorama", specifier = ">=0.4.0" }, - { name = "colorlog", specifier = ">=6.10.0" }, + { name = "colorlog", specifier = ">=6.11.0" }, { name = "discord-py", specifier = ">=2.7.0" }, - { name = "emoji", specifier = ">=2.0.0" }, + { name = "emoji", specifier = ">=2.14.0" }, { name = "imagehash", specifier = ">=4.3.0" }, - { name = "jsonpath-ng", specifier = ">=1.7.0" }, + { name = "jsonpath-ng", specifier = ">=1.8.0" }, { name = "mccq", specifier = ">=1.0.0" }, { name = "mime-enum", specifier = ">=0.0.3" }, { name = "numpy", specifier = ">=2.5.0" }, { name = "opencv-python-headless", specifier = ">=5.0.0.0" }, { name = "pillow", specifier = ">=12.3.0" }, - { name = "psutil", specifier = ">=7.0.0" }, + { name = "psutil", specifier = ">=7.2.0" }, { name = "pydantic", specifier = ">=2.13.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "tesserocr", marker = "sys_platform == 'win32' and extra == 'ocr'", url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl" }, { name = "tesserocr", marker = "sys_platform != 'win32' and extra == 'ocr'", specifier = ">=2.10.0" }, - { name = "ty", specifier = ">=0.0.69" }, { name = "types-psutil", specifier = ">=7.2.0" }, ] provides-extras = ["ocr"] @@ -244,7 +241,7 @@ provides-extras = ["ocr"] [package.metadata.requires-dev] dev = [ { name = "ruff", specifier = ">=0.16.0" }, - { name = "ty", specifier = ">=0.0.69" }, + { name = "ty", specifier = ">=0.0.71" }, ] [[package]] @@ -422,31 +419,31 @@ wheels = [ [[package]] name = "numpy" -version = "2.5.1" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, - { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, - { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, - { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, - { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, - { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, - { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, - { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, - { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, - { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, - { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, - { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, - { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, - { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +version = "2.5.2" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, ] [[package]] @@ -682,27 +679,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.1" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, - { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, - { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, - { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, - { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, - { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, - { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, - { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, - { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, - { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +version = "0.16.3" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, ] [[package]] @@ -741,8 +738,7 @@ name = "tesserocr" version = "2.10.0" source = { registry = "https://pypi.org/simple/" } resolution-markers = [ - "sys_platform == 'linux'", - "sys_platform != 'linux' and sys_platform != 'win32'", + "sys_platform != 'win32'", ] dependencies = [ { name = "cysignals" }, @@ -783,27 +779,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.69" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/5b/7a618632dfe9373b7df572ecd7a08c8f799d772fbc317da82dd3aa363207/ty-0.0.69.tar.gz", hash = "sha256:b65106e9ff24fa76e25e1142fb09c85244e815c40450e3021d2bf652c231bb43", size = 6565094, upload-time = "2026-08-06T10:04:25.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/60/6534092f4d2c15e2491807edd609c2e50d527c1fed957acf40b9f110b64a/ty-0.0.69-py3-none-linux_armv6l.whl", hash = "sha256:98bfd383b273540829af673e7f98b9c1c4bcc8547d12a1a3806cd0bec7f0e087", size = 12364185, upload-time = "2026-08-06T10:03:47.137Z" }, - { url = "https://files.pythonhosted.org/packages/34/2b/5c29689bd4f74c2e3394d983d85e4011b629f2ce3730c9442553b8554bf8/ty-0.0.69-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:964621ddd05771660017c51b4e74078d861d9fc863c21ef2a500db1ab62c9ccf", size = 12042510, upload-time = "2026-08-06T10:03:49.481Z" }, - { url = "https://files.pythonhosted.org/packages/09/46/fa085bde4d23516d7ef14b24736fc5dd7dc498f60f52b3d077e59ffdea20/ty-0.0.69-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3ffea4048dd0da4c9c97393b4be0901098a9065b06fa81be2477cbde65d8a151", size = 11549397, upload-time = "2026-08-06T10:03:51.747Z" }, - { url = "https://files.pythonhosted.org/packages/25/cc/97b9efb2061dcab6fef1e94a4ad99df0bb45bd2cc15d4f5794c787ee0552/ty-0.0.69-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8684d4a70aadd1eab0f41bdba835e3288ef49db8402a8e6ca81bab52ed5d610", size = 12115567, upload-time = "2026-08-06T10:03:53.79Z" }, - { url = "https://files.pythonhosted.org/packages/e0/c1/a5e0404965093835f3e62544e661784ec0aa8ef0b006ed50af50b19c107e/ty-0.0.69-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:afaaba240ab4122e2069a796836d10be81b4ddb053ae268b3dff962a0b4ca5c7", size = 12149770, upload-time = "2026-08-06T10:03:55.993Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8cad6b205a4abe8a044ca0c84aea71e8ccda29b07a75a5f090e310605580/ty-0.0.69-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ea63ef07d4e33aeb1a775cf5f2c736b3ed22fa6f8b1b608591612c36795044", size = 12941278, upload-time = "2026-08-06T10:03:58.324Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/8766d96b732c2a060d70dc8ccafcc4d6a54109a2a95f1deb0705de88892b/ty-0.0.69-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb3730b1268e92a2907d7aea3afe8dd1b360ae65862f0557080cf479d481b424", size = 13426509, upload-time = "2026-08-06T10:04:00.621Z" }, - { url = "https://files.pythonhosted.org/packages/02/1f/e991b2cde953ea5b94d6a9a4c45c87937bd916bc09235f764407bf471c0a/ty-0.0.69-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a544ff57a752ef186ed40b5a2f44c17402af4cdefeb74a311ca02ebd57c4fca0", size = 13106582, upload-time = "2026-08-06T10:04:02.818Z" }, - { url = "https://files.pythonhosted.org/packages/ea/bb/73538f1b99e3558fd9db87b98698426f0f60fc8666da0b1efd0e70e275eb/ty-0.0.69-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87ed2cbca20caddfdf8e3e14d213ce91b67e75feed78900f4aaf3ef884954028", size = 12708931, upload-time = "2026-08-06T10:04:05.233Z" }, - { url = "https://files.pythonhosted.org/packages/87/cd/484a5208d74c4ad1155933906295ccdce9aa81a257d8df2ab9e41bd60133/ty-0.0.69-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2684efcbce5b6fe45045faf610b377b50781b6d2aa7e61ea23ecf5b3d2bce421", size = 12985322, upload-time = "2026-08-06T10:04:07.587Z" }, - { url = "https://files.pythonhosted.org/packages/6e/81/b75003f0d4da9ab3bc8fd4f4802f836cb9921ff7e70f460604f7b769a0b5/ty-0.0.69-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da9aeb26fdac1d2214937542b59e0d4d1ba94ec7a3f45444f33c846de1eb1d63", size = 12063910, upload-time = "2026-08-06T10:04:09.835Z" }, - { url = "https://files.pythonhosted.org/packages/8a/76/088469f547ef63dceefc4a75826aedee5014f9371dc5171cde931896a82c/ty-0.0.69-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:00e7677cd14ede381f705f71104ea7b8ea0ce217a8634e19a89781953de0e9ad", size = 12166823, upload-time = "2026-08-06T10:04:12.114Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c9/ce88a0bec0d46d8ae180b99c6ec014866fecc4cba1727b5feec8877b2765/ty-0.0.69-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d91965eb799649833d0d6042db09cd03d15289125245337cc46a2606effb7bda", size = 12483136, upload-time = "2026-08-06T10:04:14.33Z" }, - { url = "https://files.pythonhosted.org/packages/63/9e/6fae0ff225a0012642cf72c077e20f8f448c0a80771bc3360e8178fe2f32/ty-0.0.69-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f03359cd8e5c412aa0c181118fa9b9061a4dddaedbb61bac0a424fb0814d402", size = 12799025, upload-time = "2026-08-06T10:04:16.445Z" }, - { url = "https://files.pythonhosted.org/packages/e4/43/78a658d18b2a4ccf35b053392f2213bf12e3c63b2abea512d3b6751d1f4c/ty-0.0.69-py3-none-win32.whl", hash = "sha256:ec460e01586b1eb91894c4a8403bee3e045a47e7a4ada943cc27ce8e348e88cf", size = 11787774, upload-time = "2026-08-06T10:04:18.622Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/88db1f674403f2b81316a853a44a81ed220621fa96f8f7ae586fb6ca7513/ty-0.0.69-py3-none-win_amd64.whl", hash = "sha256:18976ca26a4e28fc3249477f79a695d5502e670803f2e080d89ac905baef3c6e", size = 12864038, upload-time = "2026-08-06T10:04:20.748Z" }, - { url = "https://files.pythonhosted.org/packages/4d/7b/6fc6efd00c69103d70f2bdbe824343089cd70b17b3079170057d3e5a3ac0/ty-0.0.69-py3-none-win_arm64.whl", hash = "sha256:7d4ca3bb74d91cb9947ba3f3b4cb131ad6a2b3ecc76d34040c4ec6092d2e411d", size = 12196693, upload-time = "2026-08-06T10:04:22.902Z" }, +version = "0.0.71" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/e2/f6e716371b5913a31190db1ad250ac2b5c68b3ca2db71afeba3f98f5fe50/ty-0.0.71.tar.gz", hash = "sha256:c2a24f2745294946c27cef8cc012b84fb2db5405ecefddbe845be4162833da01", size = 6624721, upload-time = "2026-08-13T00:39:31.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/e4/8d6d17827c5335d0efe54241fb54fed3cb6ca2d2eca62f3f2382be196b91/ty-0.0.71-py3-none-linux_armv6l.whl", hash = "sha256:a309c9a35e69f45d7053e9205c7fe2295fac09e07e3a2fdb791524f30440a9cc", size = 12576435, upload-time = "2026-08-13T00:38:50.617Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cb/d4c48832ee3d162abc6c834ba62242ec27630cb93014a0e1be82e86c3ee3/ty-0.0.71-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2d42e9ae4b754ce1f34dd1b545f1cdcfc8e704d7460b584c898be156f048828f", size = 12177806, upload-time = "2026-08-13T00:38:53.068Z" }, + { url = "https://files.pythonhosted.org/packages/f0/97/e04782c9eaa1a0b634830f61b0e18cd1b3415332143d45a28882802c8ea1/ty-0.0.71-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4d0b1f2002adc03f3a53aeb70b5cafcea634bb48726d82a307b0f15580d2f74b", size = 12015265, upload-time = "2026-08-13T00:38:55.209Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/d716b9049c11a7b85b3b74ec3547d2dbab9a261347a6558420dcae37083d/ty-0.0.71-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:165e6086363f5c149ae4acacfbc36eea0093552a60f4153ef48321e4a3a15c4c", size = 12119608, upload-time = "2026-08-13T00:38:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/b8/78/560ce2d874467d50605b7783e19e45b571fea7a89f664bd0ccdf252adf8a/ty-0.0.71-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:067e16f80855afbda024e7ff6fa5ed3ada5290c403badc63800f0534bd617c04", size = 12353762, upload-time = "2026-08-13T00:38:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7b/f6a22c0bf2c0dbfe20b7ce90b51bc09efa9a9e245f1c35e160e39ed5dea9/ty-0.0.71-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:416149a550bcb678e619b4e2cd1cca9066d28edc52df76ad9d3640b36e6b5bf1", size = 13088120, upload-time = "2026-08-13T00:39:02.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/95/6f5382781f2e4fa933296d303376c61b8b3475d58ed60125c54de665fd21/ty-0.0.71-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39e04c41e6d0e74f73cf8b3247d4dde3642ec38aeda7f4674110155b3da416a5", size = 13545140, upload-time = "2026-08-13T00:39:04.655Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/8f0ad7b6c4804f8682e08fb161eaf710dd1765006b1b6df7c657b9707c95/ty-0.0.71-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fb696bdea4a3554a0d0a7bbb36e4c5f508acb2bbad435e91c7fc812c1de6bfb", size = 13266730, upload-time = "2026-08-13T00:39:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/ac/06/b83158fdf1473c2486fba0de337a963e9cc21317ccaa3e54ab003d419737/ty-0.0.71-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3ec4ca9d4ba3e11ecc282c3d50d0730a2e181da7142734219d9f213fcbaaa00", size = 12673786, upload-time = "2026-08-13T00:39:09.281Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/10f37c0550277722ac1d2c8096e492e0f3fc1aa2e587082439dd066fdb8d/ty-0.0.71-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48a231253b32639ff4b19f74e476bacdba0150182603011d3792d1f1a335b932", size = 13140294, upload-time = "2026-08-13T00:39:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e6/5710de1da7eb8aa755d289aa25316691e545b72b4ee2ab14172612eec1c9/ty-0.0.71-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1a082f57d1fcbe209afdcacff37870defd4cea15ce69e2c6c652a9208462722f", size = 12171216, upload-time = "2026-08-13T00:39:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/80/c5/8d9113e3cf0d4c6c0a9c9e088cee93e41facb278026bea6f6e12533b09dd/ty-0.0.71-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:cbe1c962f6c9e8964180cd171cc6ad35d687f74d51c07f60b670d3f1c5d58ffe", size = 12360626, upload-time = "2026-08-13T00:39:16.747Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a0/0e167507c4c863814d3ea7602bde958baefd03346bd38bae3a430dff1ea9/ty-0.0.71-py3-none-musllinux_1_2_i686.whl", hash = "sha256:25f641b988916b3975e50b2a59cbc5179f2a466d181f207f3032e1e6bc617a88", size = 12637309, upload-time = "2026-08-13T00:39:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c6/ff6719b91e4916985e9a92f9bddb10d9fe3cb3bdf5abf0f9019a67aebe21/ty-0.0.71-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c5fe916b6e5b152ef4f583324e1efd770ac3316894776dc1b57c354cb767b8ee", size = 12937996, upload-time = "2026-08-13T00:39:21.533Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3e/21a0873da8f1ece28e166fbbaf696cd48c55fd7cd203dba0266a1fe05ceb/ty-0.0.71-py3-none-win32.whl", hash = "sha256:a273fe0dcc453e94cf2e1955076efec8b739e6e1a890862de29d3c5a1c9ddaff", size = 11953321, upload-time = "2026-08-13T00:39:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/ce6614c748f7abfc546d12983d4bad1085116b6458b8c84d9372eb713f08/ty-0.0.71-py3-none-win_amd64.whl", hash = "sha256:65f5f980551ed79f68a0f6c0f2fb71b39a8d54ed0625d2529b01f6c919db72c5", size = 12570891, upload-time = "2026-08-13T00:39:26.346Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/0fb022535c66fd96e7dd2a1f9c14e3a0e39544a4c3f35a451e8835562730/ty-0.0.71-py3-none-win_arm64.whl", hash = "sha256:6d5552078b9934d359bd5f381dbcb160f1fc5addfca59a960021adca38a73741", size = 12338716, upload-time = "2026-08-13T00:39:28.987Z" }, ] [[package]] @@ -817,69 +813,69 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] name = "yarl" -version = "1.24.2" +version = "1.24.5" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, - { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, - { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, - { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, - { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, - { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, - { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, - { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, - { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, - { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, - { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, - { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, - { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, - { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, - { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, - { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, - { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, - { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, - { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, ] From ccf4f09cf5d874bfcb9e7ca9bd5971690fbe9cfd Mon Sep 17 00:00:00 2001 From: Ersatz Date: Thu, 13 Aug 2026 22:17:31 -0700 Subject: [PATCH 116/158] Added badges for Ruff and Ty --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 67cae7a..bab6f7c 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ A collection of utilities and extensions for discord.py bots. -[![uv-badge]](https://github.com/astral-sh/uv/) -[![package-badge]](https://pypi.python.org/pypi/commanderbot/) -[![version-badge]](https://pypi.python.org/pypi/commanderbot/) +[![uv-badge]](https://github.com/astral-sh/uv) +[![ruff-badge]](https://github.com/astral-sh/ruff) +[![ty-badge]](https://github.com/astral-sh/ty) +[![package-badge]](https://pypi.python.org/pypi/commanderbot) +[![version-badge]](https://pypi.python.org/pypi/commanderbot) ## Requirements @@ -75,5 +77,7 @@ The current set of configuration options is limited. The following is an example ``` [uv-badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json&style=flat-square +[ruff-badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&style=flat-square +[ty-badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json&style=flat-square [package-badge]: https://img.shields.io/pypi/v/commanderbot.svg?style=flat-square [version-badge]: https://img.shields.io/pypi/pyversions/commanderbot.svg?style=flat-square From ed4db41630dbfb5f315631e98d26659ad1ebf188 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 14 Aug 2026 18:34:30 -0700 Subject: [PATCH 117/158] Updated Ty --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f3e68ea..451a336 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ commanderbot = "commanderbot:__main__" [dependency-groups] dev = [ "ruff>=0.16.0", - "ty>=0.0.71", + "ty>=0.0.72", ] [tool.ruff.lint] From 2388471a8210967e9db1fb49a97d6164ea2ddb86 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 14 Aug 2026 20:01:01 -0700 Subject: [PATCH 118/158] Moved `Timedelta` to a separate file --- .../actions/abc/delete_target_messages.py | 2 +- commanderbot/ext/automod/actions/dm_member.py | 2 +- .../ext/automod/actions/reply_to_message.py | 2 +- .../ext/automod/actions/send_message.py | 3 +- .../ext/automod/actions/timeout_actor.py | 2 +- .../ext/automod/actions/timeout_author.py | 2 +- commanderbot/ext/automod/actions/wait.py | 2 +- .../buckets/flagged_image_attachments.py | 3 +- .../ext/automod/buckets/message_history.py | 3 +- .../conditions/abc/target_account_age.py | 5 +-- .../conditions/abc/target_member_for.py | 2 +- commanderbot/ext/automod/conditions/wait.py | 2 +- .../ext/automod/triggers/message_frequency.py | 2 +- commanderbot/lib/__init__.py | 1 + commanderbot/lib/timedelta.py | 44 +++++++++++++++++++ commanderbot/lib/types.py | 35 +-------------- 16 files changed, 63 insertions(+), 49 deletions(-) create mode 100644 commanderbot/lib/timedelta.py diff --git a/commanderbot/ext/automod/actions/abc/delete_target_messages.py b/commanderbot/ext/automod/actions/abc/delete_target_messages.py index 5b07b14..2d4dfb1 100644 --- a/commanderbot/ext/automod/actions/abc/delete_target_messages.py +++ b/commanderbot/ext/automod/actions/abc/delete_target_messages.py @@ -14,7 +14,7 @@ BULK_DELETE_MAX_MESSAGES, ) from commanderbot.lib.predicates import is_messagable_guild_channel, is_thread -from commanderbot.lib.types import Timedelta +from commanderbot.lib.timedelta import Timedelta __all__ = ("DeleteTargetMessages",) diff --git a/commanderbot/ext/automod/actions/dm_member.py b/commanderbot/ext/automod/actions/dm_member.py index b372300..14e93e1 100644 --- a/commanderbot/ext/automod/actions/dm_member.py +++ b/commanderbot/ext/automod/actions/dm_member.py @@ -5,7 +5,7 @@ from commanderbot.ext.automod.action import AutomodAction from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.lib.allowed_mentions import AllowedMentions -from commanderbot.lib.types import Timedelta +from commanderbot.lib.timedelta import Timedelta from commanderbot.lib.utils import dict_without_nones __all__ = ("DMMember",) diff --git a/commanderbot/ext/automod/actions/reply_to_message.py b/commanderbot/ext/automod/actions/reply_to_message.py index a12093d..86be326 100644 --- a/commanderbot/ext/automod/actions/reply_to_message.py +++ b/commanderbot/ext/automod/actions/reply_to_message.py @@ -5,7 +5,7 @@ from commanderbot.ext.automod.action import AutomodAction from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.lib.allowed_mentions import AllowedMentions -from commanderbot.lib.types import Timedelta +from commanderbot.lib.timedelta import Timedelta from commanderbot.lib.utils import dict_without_nones __all__ = ("ReplyToMessage",) diff --git a/commanderbot/ext/automod/actions/send_message.py b/commanderbot/ext/automod/actions/send_message.py index 0455635..c537a8b 100644 --- a/commanderbot/ext/automod/actions/send_message.py +++ b/commanderbot/ext/automod/actions/send_message.py @@ -7,7 +7,8 @@ from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.lib.allowed_mentions import AllowedMentions from commanderbot.lib.predicates import is_messagable_guild_channel, is_thread -from commanderbot.lib.types import ChannelID, MessageableGuildChannel, Timedelta +from commanderbot.lib.timedelta import Timedelta +from commanderbot.lib.types import ChannelID, MessageableGuildChannel from commanderbot.lib.utils import dict_without_nones __all__ = ("SendMessage",) diff --git a/commanderbot/ext/automod/actions/timeout_actor.py b/commanderbot/ext/automod/actions/timeout_actor.py index 3c40dd5..8ceeaae 100644 --- a/commanderbot/ext/automod/actions/timeout_actor.py +++ b/commanderbot/ext/automod/actions/timeout_actor.py @@ -3,7 +3,7 @@ from commanderbot.ext.automod.action import AutomodAction from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.lib.predicates import is_member -from commanderbot.lib.types import Timedelta +from commanderbot.lib.timedelta import Timedelta __all__ = ("TimeoutActor",) diff --git a/commanderbot/ext/automod/actions/timeout_author.py b/commanderbot/ext/automod/actions/timeout_author.py index a9987c6..ee9d5e8 100644 --- a/commanderbot/ext/automod/actions/timeout_author.py +++ b/commanderbot/ext/automod/actions/timeout_author.py @@ -3,7 +3,7 @@ from commanderbot.ext.automod.action import AutomodAction from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.lib.predicates import is_member -from commanderbot.lib.types import Timedelta +from commanderbot.lib.timedelta import Timedelta __all__ = ("TimeoutAuthor",) diff --git a/commanderbot/ext/automod/actions/wait.py b/commanderbot/ext/automod/actions/wait.py index 0e21f1a..2fc5c17 100644 --- a/commanderbot/ext/automod/actions/wait.py +++ b/commanderbot/ext/automod/actions/wait.py @@ -3,7 +3,7 @@ from commanderbot.ext.automod.action import AutomodAction from commanderbot.ext.automod.automod_context import AutomodContext -from commanderbot.lib.types import Timedelta +from commanderbot.lib.timedelta import Timedelta __all__ = ("Wait",) diff --git a/commanderbot/ext/automod/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/buckets/flagged_image_attachments.py index 4219430..45dd16d 100644 --- a/commanderbot/ext/automod/buckets/flagged_image_attachments.py +++ b/commanderbot/ext/automod/buckets/flagged_image_attachments.py @@ -9,7 +9,8 @@ from commanderbot.ext.automod.bucket import AutomodBucket from commanderbot.ext.automod.constants import IMAGE_MIME_TYPES from commanderbot.lib.image_hash import ImageHash -from commanderbot.lib.types import AttachmentID, Timedelta +from commanderbot.lib.timedelta import Timedelta +from commanderbot.lib.types import AttachmentID __all__ = ("FlaggedImageAttachments",) diff --git a/commanderbot/ext/automod/buckets/message_history.py b/commanderbot/ext/automod/buckets/message_history.py index 8ec77ec..20ceaee 100644 --- a/commanderbot/ext/automod/buckets/message_history.py +++ b/commanderbot/ext/automod/buckets/message_history.py @@ -11,7 +11,8 @@ from commanderbot.ext.automod import events from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.bucket import AutomodBucket -from commanderbot.lib.types import ChannelID, MessageID, Timedelta, UserID +from commanderbot.lib.timedelta import Timedelta +from commanderbot.lib.types import ChannelID, MessageID, UserID __all__ = ("MessageHistory",) diff --git a/commanderbot/ext/automod/conditions/abc/target_account_age.py b/commanderbot/ext/automod/conditions/abc/target_account_age.py index 65f9403..752b71f 100644 --- a/commanderbot/ext/automod/conditions/abc/target_account_age.py +++ b/commanderbot/ext/automod/conditions/abc/target_account_age.py @@ -6,7 +6,7 @@ from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.condition import AutomodCondition -from commanderbot.lib.types import Timedelta +from commanderbot.lib.timedelta import Timedelta __all__ = ("TargetAccountAge",) @@ -19,8 +19,7 @@ class TargetAccountAge(AutomodCondition): """The upper bound to check against, if any (inclusive).""" @abstractmethod - def get_target(self, context: AutomodContext) -> Optional[User]: - ... + def get_target(self, context: AutomodContext) -> Optional[User]: ... @override async def check(self, context: AutomodContext) -> bool: diff --git a/commanderbot/ext/automod/conditions/abc/target_member_for.py b/commanderbot/ext/automod/conditions/abc/target_member_for.py index cf769b2..4473da0 100644 --- a/commanderbot/ext/automod/conditions/abc/target_member_for.py +++ b/commanderbot/ext/automod/conditions/abc/target_member_for.py @@ -6,7 +6,7 @@ from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.condition import AutomodCondition -from commanderbot.lib.types import Timedelta +from commanderbot.lib.timedelta import Timedelta __all__ = ("TargetMemberFor",) diff --git a/commanderbot/ext/automod/conditions/wait.py b/commanderbot/ext/automod/conditions/wait.py index d41a4cb..7a8cb01 100644 --- a/commanderbot/ext/automod/conditions/wait.py +++ b/commanderbot/ext/automod/conditions/wait.py @@ -3,7 +3,7 @@ from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.condition import AutomodCondition -from commanderbot.lib.types import Timedelta +from commanderbot.lib.timedelta import Timedelta __all__ = ("Wait",) diff --git a/commanderbot/ext/automod/triggers/message_frequency.py b/commanderbot/ext/automod/triggers/message_frequency.py index c1ee6bc..1cd5386 100644 --- a/commanderbot/ext/automod/triggers/message_frequency.py +++ b/commanderbot/ext/automod/triggers/message_frequency.py @@ -4,7 +4,7 @@ from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.guards import IntegerRangeGuard from commanderbot.ext.automod.trigger import AutomodTrigger -from commanderbot.lib.types import Timedelta +from commanderbot.lib.timedelta import Timedelta __all__ = ("MessageFrequency",) diff --git a/commanderbot/lib/__init__.py b/commanderbot/lib/__init__.py index 59afafa..cefa41c 100644 --- a/commanderbot/lib/__init__.py +++ b/commanderbot/lib/__init__.py @@ -14,4 +14,5 @@ from .log_options import * from .predicates import * from .role_set import * +from .timedelta import * from .types import * diff --git a/commanderbot/lib/timedelta.py b/commanderbot/lib/timedelta.py new file mode 100644 index 0000000..51a10f3 --- /dev/null +++ b/commanderbot/lib/timedelta.py @@ -0,0 +1,44 @@ +from datetime import timedelta +from typing import Annotated + +from pydantic import TypeAdapter, WithJsonSchema + +__all__ = ( + "Timedelta", + "TimedeltaAdapter", +) + +type Timedelta = Annotated[ + timedelta, + WithJsonSchema( + { + "anyOf": [ + { + "type": "number", + "examples": [5, 1.0, 3.5], + }, + { + "type": "string", + "format": "iso-8061-duration", + "pattern": r"^[+-]?P(?:(?:\d+W)|(?=.*(?:\d+[YMWDHMS]))(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+H(?:\d+M(?:\d+S)?)?|\d+M(?:\d+S)?|\d+S))?)", + "examples": ["PT5H30M", "PT2DT5H15M10S", "P2Y5M2W1DT4H20M"], + }, + { + "type": "string", + "format": "duration", + "pattern": r"^[+-]?\s*(?:(?:\d+)\s*(?i:(?:days?|d))\s*,?\s*)?\d{2}:\d{2}:\d{2}(?:\.\d+)?$", + "examples": ["05:30:00", "2 days, 05:15:00", "5d, 04:20:00"], + }, + ] + }, + mode="validation", + ), +] +""" +An alias for `datetime.timedelta`, but with a Json schema for Pydantic. +""" + +TimedeltaAdapter = TypeAdapter(Timedelta) +""" +A Pydantic type adapter that lets you de/serialize `Timedelta` outside of a Pydantic `BaseModel`. +""" diff --git a/commanderbot/lib/types.py b/commanderbot/lib/types.py index a12c081..3579920 100644 --- a/commanderbot/lib/types.py +++ b/commanderbot/lib/types.py @@ -1,5 +1,4 @@ -from datetime import timedelta -from typing import Annotated, Any, Literal +from typing import Any, Literal from discord import ( CategoryChannel, @@ -13,7 +12,6 @@ User, VoiceChannel, ) -from pydantic import WithJsonSchema __all__ = ( "AppCommandID", @@ -40,7 +38,6 @@ "TesseractScripts", "ThreadID", "ThreadableChannel", - "Timedelta", "UnicodeNormalizationForms", "UserID", ) @@ -151,36 +148,6 @@ """ -type Timedelta = Annotated[ - timedelta, - WithJsonSchema( - { - "anyOf": [ - { - "type": "number", - "examples": [5, 1.0, 3.5], - }, - { - "type": "string", - "format": "iso-8061-duration", - "pattern": r"^[+-]?P(?:(?:\d+W)|(?=.*(?:\d+[YMWDHMS]))(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+H(?:\d+M(?:\d+S)?)?|\d+M(?:\d+S)?|\d+S))?)", - "examples": ["PT5H30M", "PT2DT5H15M10S", "P2Y5M2W1DT4H20M"], - }, - { - "type": "string", - "format": "duration", - "pattern": r"^[+-]?\s*(?:(?:\d+)\s*(?i:(?:days?|d))\s*,?\s*)?\d{2}:\d{2}:\d{2}(?:\.\d+)?$", - "examples": ["05:30:00", "2 days, 05:15:00", "5d, 04:20:00"], - }, - ] - }, - mode="validation", - ), -] -""" -An alias for `datetime.timedelta`, but with a Json schema for Pydantic. -""" - type UnicodeNormalizationForms = Literal["NFC", "NFD", "NFKC", "NFKD"] From 68fcc8c653366832f294f735a76a0e169e20e439 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 14 Aug 2026 20:01:13 -0700 Subject: [PATCH 119/158] Changed type hint --- commanderbot/ext/automod/rule/rule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index 503ea52..86a68c0 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -24,7 +24,7 @@ class AutomodRule(BaseModel): description: Optional[str] = None """Describe what the rule does.""" - disabled: Optional[bool] = None + disabled: bool = False """Is the rule disabled?""" log: Optional[LogChannel] = None From 4ccb3319daf39c2f5a01d495bb5526688a089d1e Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 14 Aug 2026 20:01:51 -0700 Subject: [PATCH 120/158] Moved some files around --- commanderbot/ext/automod/ui/__init__.py | 0 .../modify_default_log_modal.py => log.py} | 97 ++++++++++++++++- commanderbot/ext/automod/ui/log/__init__.py | 2 - .../automod/ui/log/set_default_log_modal.py | 102 ------------------ .../ui/{rule/modify_rule_modal.py => rule.py} | 41 ++++++- commanderbot/ext/automod/ui/rule/__init__.py | 2 - .../ext/automod/ui/rule/add_rule_modal.py | 44 -------- 7 files changed, 132 insertions(+), 156 deletions(-) create mode 100644 commanderbot/ext/automod/ui/__init__.py rename commanderbot/ext/automod/ui/{log/modify_default_log_modal.py => log.py} (55%) delete mode 100644 commanderbot/ext/automod/ui/log/__init__.py delete mode 100644 commanderbot/ext/automod/ui/log/set_default_log_modal.py rename commanderbot/ext/automod/ui/{rule/modify_rule_modal.py => rule.py} (58%) delete mode 100644 commanderbot/ext/automod/ui/rule/__init__.py delete mode 100644 commanderbot/ext/automod/ui/rule/add_rule_modal.py diff --git a/commanderbot/ext/automod/ui/__init__.py b/commanderbot/ext/automod/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/commanderbot/ext/automod/ui/log/modify_default_log_modal.py b/commanderbot/ext/automod/ui/log.py similarity index 55% rename from commanderbot/ext/automod/ui/log/modify_default_log_modal.py rename to commanderbot/ext/automod/ui/log.py index 49c90b8..cac4f6d 100644 --- a/commanderbot/ext/automod/ui/log/modify_default_log_modal.py +++ b/commanderbot/ext/automod/ui/log.py @@ -10,18 +10,111 @@ ) from commanderbot.ext.automod.automod_exceptions import ( + CouldNotValidateLogChannel, CouldNotValidateModifiedLogChannel, ) from commanderbot.ext.automod.automod_store import AutomodStore from commanderbot.lib.cogs.views import CogStateModal from commanderbot.lib.log_channel import LogChannel -__all__ = ("ModifyDefaultLogModal",) +__all__ = ( + "ModifyDefaultLogModal", + "SetDefaultLogModal", +) if TYPE_CHECKING: from commanderbot.ext.automod.automod_guild_state import AutomodGuildState +class SetDefaultLogModal(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__(self, interaction: Interaction, state: AutomodGuildState): + super().__init__( + interaction, + state, + title="Set the default log channel", + custom_id="commanderbot_ext:automod.log.set", + ) + + self.channel_input = ui.ChannelSelect( + channel_types=[ChannelType.text, ChannelType.public_thread], + placeholder="Pick a channel", + min_values=1, + max_values=1, + required=True, + ) + self.channel_label = ui.Label( + text="The channel to send log messages in", component=self.channel_input + ) + + self.emoji_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 🔥", + required=False, + ) + self.emoji_label = ui.Label( + text="The emoji used for log messages", + description="Can be a regular emoji or Discord emoji", + component=self.emoji_input, + ) + + self.color_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'mcc_blue', '0x00ACED', or '44269'", + required=False, + ) + self.color_label = ui.Label( + text="The color used for log messages", + description="Can be the name of a color, a hex code, or an integer", + component=self.color_input, + ) + + self.stacktrace_input = ui.CheckboxGroup(required=False) + self.stacktrace_input.add_option(label="\u200b", default=False) + self.stacktrace_label = ui.Label( + text="Print exception stacktraces", component=self.stacktrace_input + ) + + self.allowed_mentions_input = ui.CheckboxGroup(required=False) + self.allowed_mentions_input.add_option(label="Everyone", default=False) + self.allowed_mentions_input.add_option(label="Users", default=False) + self.allowed_mentions_input.add_option(label="Roles", default=False) + self.allowed_mentions_input.add_option(label="Replied User", default=False) + self.allowed_mentions_label = ui.Label( + text="The types of mentions allowed in log messages", + component=self.allowed_mentions_input, + ) + + self.add_item(self.channel_label) + self.add_item(self.emoji_label) + self.add_item(self.color_label) + self.add_item(self.stacktrace_label) + self.add_item(self.allowed_mentions_label) + + @override + async def on_submit(self, interaction: Interaction): + raw_log = { + "channel": self.channel_input.values[-1].id, + "emoji": self.emoji_input.value or None, + "color": self.color_input.value or None, + "stacktrace": bool(self.stacktrace_input.values) or None, + "allowed_mentions": { + "everyone": "Everyone" in self.allowed_mentions_input.values, + "users": "Users" in self.allowed_mentions_input.values, + "roles": "Roles" in self.allowed_mentions_input.values, + "replied_user": "Replied User" in self.allowed_mentions_input.values, + }, + } + + try: + log = LogChannel.model_validate(raw_log) + await self.store.set_default_log(self.state.guild, log) + await interaction.response.send_message( + f"Set default log channel to <#{log.channel}>" + ) + except ValueError as ex: + raise CouldNotValidateLogChannel(ex) + + class ModifyDefaultLogModal(CogStateModal["AutomodGuildState", AutomodStore]): def __init__( self, interaction: Interaction, state: AutomodGuildState, log: LogChannel @@ -61,7 +154,7 @@ def __init__( self.color_input = ui.TextInput( style=TextStyle.short, - placeholder="Ex: 'mcc_blue' or '0x00ACED'", + placeholder="Ex: 'mcc_blue', '0x00ACED', or '44269'", default=str(log.color) if log.color is not None else "", required=False, ) diff --git a/commanderbot/ext/automod/ui/log/__init__.py b/commanderbot/ext/automod/ui/log/__init__.py deleted file mode 100644 index 8fa14e5..0000000 --- a/commanderbot/ext/automod/ui/log/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .modify_default_log_modal import * -from .set_default_log_modal import * diff --git a/commanderbot/ext/automod/ui/log/set_default_log_modal.py b/commanderbot/ext/automod/ui/log/set_default_log_modal.py deleted file mode 100644 index aba0514..0000000 --- a/commanderbot/ext/automod/ui/log/set_default_log_modal.py +++ /dev/null @@ -1,102 +0,0 @@ -from typing import TYPE_CHECKING, override - -from discord import ChannelType, Interaction, TextStyle, ui - -from commanderbot.ext.automod.automod_exceptions import CouldNotValidateLogChannel -from commanderbot.ext.automod.automod_store import AutomodStore -from commanderbot.lib.cogs.views import CogStateModal -from commanderbot.lib.log_channel import LogChannel - -__all__ = ("SetDefaultLogModal",) - -if TYPE_CHECKING: - from commanderbot.ext.automod.automod_guild_state import AutomodGuildState - - -class SetDefaultLogModal(CogStateModal["AutomodGuildState", AutomodStore]): - def __init__(self, interaction: Interaction, state: AutomodGuildState): - super().__init__( - interaction, - state, - title="Set the default log channel", - custom_id="commanderbot_ext:automod.log.set", - ) - - self.channel_input = ui.ChannelSelect( - channel_types=[ChannelType.text, ChannelType.public_thread], - placeholder="Pick a channel", - min_values=1, - max_values=1, - required=True, - ) - self.channel_label = ui.Label( - text="The channel to send log messages in", component=self.channel_input - ) - - self.emoji_input = ui.TextInput( - style=TextStyle.short, - placeholder="Ex: 🔥", - required=False, - ) - self.emoji_label = ui.Label( - text="The emoji used for log messages", - description="Can be a regular emoji or Discord emoji", - component=self.emoji_input, - ) - - self.color_input = ui.TextInput( - style=TextStyle.short, - placeholder="Ex: 'mcc_blue' or '0x00ACED'", - required=False, - ) - self.color_label = ui.Label( - text="The color used for log messages", - description="Can be the name of a color, a hex code, or an integer", - component=self.color_input, - ) - - self.stacktrace_input = ui.CheckboxGroup(required=False) - self.stacktrace_input.add_option(label="\u200b", default=False) - self.stacktrace_label = ui.Label( - text="Print exception stacktraces", component=self.stacktrace_input - ) - - self.allowed_mentions_input = ui.CheckboxGroup(required=False) - self.allowed_mentions_input.add_option(label="Everyone", default=False) - self.allowed_mentions_input.add_option(label="Users", default=False) - self.allowed_mentions_input.add_option(label="Roles", default=False) - self.allowed_mentions_input.add_option(label="Replied User", default=False) - self.allowed_mentions_label = ui.Label( - text="The types of mentions allowed in log messages", - component=self.allowed_mentions_input, - ) - - self.add_item(self.channel_label) - self.add_item(self.emoji_label) - self.add_item(self.color_label) - self.add_item(self.stacktrace_label) - self.add_item(self.allowed_mentions_label) - - @override - async def on_submit(self, interaction: Interaction): - raw_log = { - "channel": self.channel_input.values[-1].id, - "emoji": self.emoji_input.value or None, - "color": self.color_input.value or None, - "stacktrace": bool(self.stacktrace_input.values) or None, - "allowed_mentions": { - "everyone": "Everyone" in self.allowed_mentions_input.values, - "users": "Users" in self.allowed_mentions_input.values, - "roles": "Roles" in self.allowed_mentions_input.values, - "replied_user": "Replied User" in self.allowed_mentions_input.values, - }, - } - - try: - log = LogChannel.model_validate(raw_log) - await self.store.set_default_log(self.state.guild, log) - await interaction.response.send_message( - f"Set default log channel to <#{log.channel}>" - ) - except ValueError as ex: - raise CouldNotValidateLogChannel(ex) diff --git a/commanderbot/ext/automod/ui/rule/modify_rule_modal.py b/commanderbot/ext/automod/ui/rule.py similarity index 58% rename from commanderbot/ext/automod/ui/rule/modify_rule_modal.py rename to commanderbot/ext/automod/ui/rule.py index c6c8482..92970b8 100644 --- a/commanderbot/ext/automod/ui/rule/modify_rule_modal.py +++ b/commanderbot/ext/automod/ui/rule.py @@ -4,18 +4,53 @@ from commanderbot.ext.automod.automod_exceptions import ( CouldNotValidateModifiedAutomodRule, + CouldNotValidateNewAutomodRule, ) from commanderbot.ext.automod.automod_store import AutomodStore from commanderbot.ext.automod.rule import AutomodRule from commanderbot.lib.cogs.views import CogStateModal from commanderbot.lib.constants import MAX_MODAL_TITLE_LENGTH -__all__ = ("ModifyRuleModal",) +__all__ = ( + "AddRuleModal", + "ModifyRuleModal", +) if TYPE_CHECKING: from commanderbot.ext.automod.automod_guild_state import AutomodGuildState +class AddRuleModal(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__(self, interaction: Interaction, state: AutomodGuildState): + super().__init__( + interaction, + state, + title="Add a new rule", + custom_id="commanderbot_ext:automod.rule.add", + ) + + self.rule_input = ui.TextInput( + style=TextStyle.paragraph, + placeholder="{}", + required=True, + ) + self.rule_input_label = ui.Label( + text="The rule in Json format", + description=f"Run {state.get_schema_command()} if you need the schema", + component=self.rule_input, + ) + self.add_item(self.rule_input_label) + + @override + async def on_submit(self, interaction: Interaction): + try: + rule = AutomodRule.model_validate_json(self.rule_input.value) + await self.store.add_rule(self.state.guild, rule, interaction.user.id) + await interaction.response.send_message(f"Added rule `{rule.name}`") + except ValueError as ex: + raise CouldNotValidateNewAutomodRule(ex) + + class ModifyRuleModal(CogStateModal["AutomodGuildState", AutomodStore]): def __init__( self, interaction: Interaction, state: AutomodGuildState, rule: AutomodRule @@ -49,8 +84,6 @@ async def on_submit(self, interaction: Interaction): try: rule = AutomodRule.model_validate_json(self.rule_input.value) await self.store.modify_rule(self.state.guild, rule, interaction.user.id) - await interaction.response.send_message( - f"Modified rule `{rule.name}`" - ) + await interaction.response.send_message(f"Modified rule `{rule.name}`") except ValueError as ex: raise CouldNotValidateModifiedAutomodRule(ex) diff --git a/commanderbot/ext/automod/ui/rule/__init__.py b/commanderbot/ext/automod/ui/rule/__init__.py deleted file mode 100644 index 19f327e..0000000 --- a/commanderbot/ext/automod/ui/rule/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .add_rule_modal import * -from .modify_rule_modal import * diff --git a/commanderbot/ext/automod/ui/rule/add_rule_modal.py b/commanderbot/ext/automod/ui/rule/add_rule_modal.py deleted file mode 100644 index 2602bbb..0000000 --- a/commanderbot/ext/automod/ui/rule/add_rule_modal.py +++ /dev/null @@ -1,44 +0,0 @@ -from typing import TYPE_CHECKING, override - -from discord import Interaction, TextStyle, ui - -from commanderbot.ext.automod.automod_exceptions import CouldNotValidateNewAutomodRule -from commanderbot.ext.automod.automod_store import AutomodStore -from commanderbot.ext.automod.rule import AutomodRule -from commanderbot.lib.cogs.views import CogStateModal - -__all__ = ("AddRuleModal",) - -if TYPE_CHECKING: - from commanderbot.ext.automod.automod_guild_state import AutomodGuildState - - -class AddRuleModal(CogStateModal["AutomodGuildState", AutomodStore]): - def __init__(self, interaction: Interaction, state: AutomodGuildState): - super().__init__( - interaction, - state, - title="Add a new rule", - custom_id="commanderbot_ext:automod.rule.add", - ) - - self.rule_input = ui.TextInput( - style=TextStyle.paragraph, - placeholder="{}", - required=True, - ) - self.rule_input_label = ui.Label( - text="The rule in Json format", - description=f"Run {state.get_schema_command()} if you need the schema", - component=self.rule_input, - ) - self.add_item(self.rule_input_label) - - @override - async def on_submit(self, interaction: Interaction): - try: - rule = AutomodRule.model_validate_json(self.rule_input.value) - await self.store.add_rule(self.state.guild, rule, interaction.user.id) - await interaction.response.send_message(f"Added rule `{rule.name}`") - except ValueError as ex: - raise CouldNotValidateNewAutomodRule(ex) From 401a3b97e1011d6cb48533fa3172ee4257803f4c Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 14 Aug 2026 20:02:30 -0700 Subject: [PATCH 121/158] Forgot to commit lock file --- uv.lock | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/uv.lock b/uv.lock index d018572..f7c02d1 100644 --- a/uv.lock +++ b/uv.lock @@ -241,7 +241,7 @@ provides-extras = ["ocr"] [package.metadata.requires-dev] dev = [ { name = "ruff", specifier = ">=0.16.0" }, - { name = "ty", specifier = ">=0.0.71" }, + { name = "ty", specifier = ">=0.0.72" }, ] [[package]] @@ -779,27 +779,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.71" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/e2/f6e716371b5913a31190db1ad250ac2b5c68b3ca2db71afeba3f98f5fe50/ty-0.0.71.tar.gz", hash = "sha256:c2a24f2745294946c27cef8cc012b84fb2db5405ecefddbe845be4162833da01", size = 6624721, upload-time = "2026-08-13T00:39:31.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/e4/8d6d17827c5335d0efe54241fb54fed3cb6ca2d2eca62f3f2382be196b91/ty-0.0.71-py3-none-linux_armv6l.whl", hash = "sha256:a309c9a35e69f45d7053e9205c7fe2295fac09e07e3a2fdb791524f30440a9cc", size = 12576435, upload-time = "2026-08-13T00:38:50.617Z" }, - { url = "https://files.pythonhosted.org/packages/d5/cb/d4c48832ee3d162abc6c834ba62242ec27630cb93014a0e1be82e86c3ee3/ty-0.0.71-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2d42e9ae4b754ce1f34dd1b545f1cdcfc8e704d7460b584c898be156f048828f", size = 12177806, upload-time = "2026-08-13T00:38:53.068Z" }, - { url = "https://files.pythonhosted.org/packages/f0/97/e04782c9eaa1a0b634830f61b0e18cd1b3415332143d45a28882802c8ea1/ty-0.0.71-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4d0b1f2002adc03f3a53aeb70b5cafcea634bb48726d82a307b0f15580d2f74b", size = 12015265, upload-time = "2026-08-13T00:38:55.209Z" }, - { url = "https://files.pythonhosted.org/packages/4f/5c/d716b9049c11a7b85b3b74ec3547d2dbab9a261347a6558420dcae37083d/ty-0.0.71-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:165e6086363f5c149ae4acacfbc36eea0093552a60f4153ef48321e4a3a15c4c", size = 12119608, upload-time = "2026-08-13T00:38:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/b8/78/560ce2d874467d50605b7783e19e45b571fea7a89f664bd0ccdf252adf8a/ty-0.0.71-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:067e16f80855afbda024e7ff6fa5ed3ada5290c403badc63800f0534bd617c04", size = 12353762, upload-time = "2026-08-13T00:38:59.729Z" }, - { url = "https://files.pythonhosted.org/packages/a3/7b/f6a22c0bf2c0dbfe20b7ce90b51bc09efa9a9e245f1c35e160e39ed5dea9/ty-0.0.71-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:416149a550bcb678e619b4e2cd1cca9066d28edc52df76ad9d3640b36e6b5bf1", size = 13088120, upload-time = "2026-08-13T00:39:02.34Z" }, - { url = "https://files.pythonhosted.org/packages/a3/95/6f5382781f2e4fa933296d303376c61b8b3475d58ed60125c54de665fd21/ty-0.0.71-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39e04c41e6d0e74f73cf8b3247d4dde3642ec38aeda7f4674110155b3da416a5", size = 13545140, upload-time = "2026-08-13T00:39:04.655Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e7/8f0ad7b6c4804f8682e08fb161eaf710dd1765006b1b6df7c657b9707c95/ty-0.0.71-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fb696bdea4a3554a0d0a7bbb36e4c5f508acb2bbad435e91c7fc812c1de6bfb", size = 13266730, upload-time = "2026-08-13T00:39:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/ac/06/b83158fdf1473c2486fba0de337a963e9cc21317ccaa3e54ab003d419737/ty-0.0.71-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3ec4ca9d4ba3e11ecc282c3d50d0730a2e181da7142734219d9f213fcbaaa00", size = 12673786, upload-time = "2026-08-13T00:39:09.281Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/10f37c0550277722ac1d2c8096e492e0f3fc1aa2e587082439dd066fdb8d/ty-0.0.71-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48a231253b32639ff4b19f74e476bacdba0150182603011d3792d1f1a335b932", size = 13140294, upload-time = "2026-08-13T00:39:11.659Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e6/5710de1da7eb8aa755d289aa25316691e545b72b4ee2ab14172612eec1c9/ty-0.0.71-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1a082f57d1fcbe209afdcacff37870defd4cea15ce69e2c6c652a9208462722f", size = 12171216, upload-time = "2026-08-13T00:39:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/80/c5/8d9113e3cf0d4c6c0a9c9e088cee93e41facb278026bea6f6e12533b09dd/ty-0.0.71-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:cbe1c962f6c9e8964180cd171cc6ad35d687f74d51c07f60b670d3f1c5d58ffe", size = 12360626, upload-time = "2026-08-13T00:39:16.747Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a0/0e167507c4c863814d3ea7602bde958baefd03346bd38bae3a430dff1ea9/ty-0.0.71-py3-none-musllinux_1_2_i686.whl", hash = "sha256:25f641b988916b3975e50b2a59cbc5179f2a466d181f207f3032e1e6bc617a88", size = 12637309, upload-time = "2026-08-13T00:39:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/1d/c6/ff6719b91e4916985e9a92f9bddb10d9fe3cb3bdf5abf0f9019a67aebe21/ty-0.0.71-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c5fe916b6e5b152ef4f583324e1efd770ac3316894776dc1b57c354cb767b8ee", size = 12937996, upload-time = "2026-08-13T00:39:21.533Z" }, - { url = "https://files.pythonhosted.org/packages/ee/3e/21a0873da8f1ece28e166fbbaf696cd48c55fd7cd203dba0266a1fe05ceb/ty-0.0.71-py3-none-win32.whl", hash = "sha256:a273fe0dcc453e94cf2e1955076efec8b739e6e1a890862de29d3c5a1c9ddaff", size = 11953321, upload-time = "2026-08-13T00:39:23.804Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/ce6614c748f7abfc546d12983d4bad1085116b6458b8c84d9372eb713f08/ty-0.0.71-py3-none-win_amd64.whl", hash = "sha256:65f5f980551ed79f68a0f6c0f2fb71b39a8d54ed0625d2529b01f6c919db72c5", size = 12570891, upload-time = "2026-08-13T00:39:26.346Z" }, - { url = "https://files.pythonhosted.org/packages/77/7f/0fb022535c66fd96e7dd2a1f9c14e3a0e39544a4c3f35a451e8835562730/ty-0.0.71-py3-none-win_arm64.whl", hash = "sha256:6d5552078b9934d359bd5f381dbcb160f1fc5addfca59a960021adca38a73741", size = 12338716, upload-time = "2026-08-13T00:39:28.987Z" }, +version = "0.0.72" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" }, + { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" }, + { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" }, + { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" }, + { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" }, + { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" }, ] [[package]] From 642c23795400abd58acca803752afd0d1cc56cb3 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 14 Aug 2026 22:07:09 -0700 Subject: [PATCH 122/158] Added `guild` attribute --- commanderbot/lib/cogs/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/commanderbot/lib/cogs/views.py b/commanderbot/lib/cogs/views.py index b49335a..3c4bb13 100644 --- a/commanderbot/lib/cogs/views.py +++ b/commanderbot/lib/cogs/views.py @@ -1,6 +1,6 @@ from typing import Generic, Optional, TypeVar, override -from discord import Interaction, ui +from discord import Guild, Interaction, ui from discord.ext.commands import Bot, Cog __all__ = ("CogStateModal", "CogStateView") @@ -47,6 +47,7 @@ def __init__( self.state: CogStateType = state self.bot: Bot = getattr(state, "bot") self.cog: Cog = getattr(state, "cog") + self.guild: Guild = getattr(state, "guild") self.store: CogStoreType = getattr(state, "store") super().__init__(title=title, custom_id=custom_id, timeout=timeout) @@ -98,6 +99,7 @@ def __init__( self.state: CogStateType = state self.bot: Bot = getattr(state, "bot") self.cog: Cog = getattr(state, "cog") + self.guild: Guild = getattr(state, "guild") self.store: CogStateType = getattr(state, "store") super().__init__(timeout=timeout) @@ -110,4 +112,4 @@ async def interaction_check(self, interaction: Interaction) -> bool: @override async def on_error(self, interaction: Interaction, error: Exception, item: ui.Item): # Pipe this error through the command tree's error handler - await self.bot.tree.on_error(interaction, error) # type: ignore[ty:missing-argument, ty:invalid-argument-type] - Maybe fix this in the future? #enhance \ No newline at end of file + await self.bot.tree.on_error(interaction, error) # type: ignore[ty:missing-argument, ty:invalid-argument-type] - Maybe fix this in the future? #enhance From ef94fb8b652420c779a6256e695f09f8f1929483 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 14 Aug 2026 22:07:46 -0700 Subject: [PATCH 123/158] Added commands for managing buckets --- commanderbot/ext/automod/automod_cog.py | 40 ++-- commanderbot/ext/automod/automod_data.py | 14 +- .../ext/automod/automod_exceptions.py | 58 +++++- .../ext/automod/automod_guild_state.py | 39 +++- commanderbot/ext/automod/automod_store.py | 4 +- commanderbot/ext/automod/enums.py | 14 ++ commanderbot/ext/automod/ui/__init__.py | 3 + .../ext/automod/ui/buckets/__init__.py | 2 + .../ui/buckets/flagged_image_attachments.py | 152 +++++++++++++++ .../ext/automod/ui/buckets/message_history.py | 178 ++++++++++++++++++ commanderbot/ext/automod/ui/log.py | 8 +- commanderbot/ext/automod/ui/rule.py | 4 +- 12 files changed, 477 insertions(+), 39 deletions(-) create mode 100644 commanderbot/ext/automod/enums.py create mode 100644 commanderbot/ext/automod/ui/buckets/__init__.py create mode 100644 commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py create mode 100644 commanderbot/ext/automod/ui/buckets/message_history.py diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index b932bc4..8d202b3 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -17,9 +17,11 @@ ) from discord.abc import Messageable from discord.app_commands import ( + Choice, Group, allowed_contexts, allowed_installs, + choices, command, default_permissions, describe, @@ -31,12 +33,13 @@ from commanderbot.ext.automod.automod_options import AutomodOptions from commanderbot.ext.automod.automod_state import AutomodState from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.ext.automod.enums import BucketTypeChoices from commanderbot.ext.automod.rule import AutomodRule -from commanderbot.lib import is_guild from commanderbot.lib.cogs import CogGuildStateManager from commanderbot.lib.databases.json_db import JsonDB from commanderbot.lib.predicates import ( is_bot, + is_guild, is_guild_channel, is_member, is_messagable_guild_channel, @@ -82,7 +85,7 @@ async def cmd_automod_schema(self, interaction: Interaction): schema = json.dumps(AutomodRule.model_json_schema()) file = str_to_file(schema, "schema.json") await interaction.response.send_message( - "Exported Json schema for rules", file=file, ephemeral=True + "Exported Json schema for rules:", file=file, ephemeral=True ) # @@ automod log @@ -153,9 +156,7 @@ async def cmd_automod_rules_remove(self, interaction: Interaction, rule: str): await self.state[interaction.guild].remove_rule(interaction, rule) # @@ automod rules details - @cmd_automod_rules.command( - name="details", description="Show details about a rule" - ) + @cmd_automod_rules.command(name="details", description="Show details about a rule") @describe(rule="The rule to show details about") async def cmd_automod_rules_details(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) @@ -182,17 +183,13 @@ async def cmd_automod_rules_list(self, interaction: Interaction): await self.state[interaction.guild].list_rules(interaction) # @@ automod rules enable-all - @cmd_automod_rules.command( - name="enable-all", description="Enable all rules" - ) + @cmd_automod_rules.command(name="enable-all", description="Enable all rules") async def cmd_automod_rules_enable_all(self, interaction: Interaction): assert is_guild(interaction.guild) await self.state[interaction.guild].enable_all_rule(interaction) # @@ automod rules disable-all - @cmd_automod_rules.command( - name="disable-all", description="Disable all rules" - ) + @cmd_automod_rules.command(name="disable-all", description="Disable all rules") async def cmd_automod_rules_disable_all(self, interaction: Interaction): assert is_guild(interaction.guild) await self.state[interaction.guild].disable_all_rule(interaction) @@ -201,16 +198,29 @@ async def cmd_automod_rules_disable_all(self, interaction: Interaction): cmd_automod_buckets = Group(name="buckets", description="Manage buckets") # @@ automod buckets add - async def cmd_automod_buckets_add(self, interaction: Interaction, type): - pass + @cmd_automod_buckets.command(name="add", description="Add a bucket") + @describe(type="The type of bucket to add") + @choices(type=BucketTypeChoices.as_choices()) + async def cmd_automod_buckets_add( + self, interaction: Interaction, type: Choice[str] + ): + assert is_guild(interaction.guild) + bucket_type = BucketTypeChoices(type.value) + await self.state[interaction.guild].add_bucket(interaction, bucket_type) # @@ automod buckets modify + @cmd_automod_buckets.command(name="modify", description="Modify a bucket") + @describe(bucket="The bucket to modify") async def cmd_automod_buckets_modify(self, interaction: Interaction, bucket: str): - pass + assert is_guild(interaction.guild) + await self.state[interaction.guild].modify_bucket(interaction, bucket) # @@ automod buckets remove + @cmd_automod_buckets.command(name="remove", description="Remove a bucket") + @describe(bucket="The bucket to remove") async def cmd_automod_buckets_remove(self, interaction: Interaction, bucket: str): - pass + assert is_guild(interaction.guild) + await self.state[interaction.guild].remove_bucket(interaction, bucket) # @@ automod buckets details @cmd_automod_buckets.command( diff --git a/commanderbot/ext/automod/automod_data.py b/commanderbot/ext/automod/automod_data.py index b756697..c24ba8b 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -68,8 +68,12 @@ def set_default_log(self, log: LogChannel) -> LogChannel: return self.default_log def modify_default_log(self, log: LogChannel) -> tuple[LogChannel, LogChannel]: + # The log channel needs to exist old_log = self.require_default_log() + + # Replace the old log channel with the modified log channel self.default_log = log + return (old_log, self.default_log) def remove_default_log( @@ -210,14 +214,16 @@ def add_bucket(self, bucket: AutomodBucket) -> AutomodBucket: return bucket - def modify_bucket(self, bucket: AutomodBucket) -> AutomodBucket: + def modify_bucket( + self, bucket: AutomodBucket + ) -> tuple[AutomodBucket, AutomodBucket]: # The bucket need to exist - self.require_bucket_with_type(bucket.name, type(bucket)) + old_bucket = self.require_bucket_with_type(bucket.name, type(bucket)) - # Modify the bucket + # Replace the old bucket with the modified bucket self.buckets[bucket.name] = bucket # type: ignore[ty:invalid-assignment] - This is fine since all buckets inherit from `AutomodBucket` - return bucket + return (old_bucket, bucket) def remove_bucket(self, name: str) -> AutomodBucket: # The bucket need to exist diff --git a/commanderbot/ext/automod/automod_exceptions.py b/commanderbot/ext/automod/automod_exceptions.py index 391a2f0..fc5bd56 100644 --- a/commanderbot/ext/automod/automod_exceptions.py +++ b/commanderbot/ext/automod/automod_exceptions.py @@ -1,3 +1,4 @@ +from commanderbot.ext.automod.enums import BucketTypeChoices from commanderbot.lib.exceptions import ResponsiveException @@ -7,7 +8,7 @@ class AutomodException(ResponsiveException): class CouldNotValidateLogChannel(AutomodException): def __init__(self, exception: ValueError): - self.exception = exception + self.exception: ValueError = exception super().__init__( f"😵 An error occurred while validating the log channel:\n```\n{exception}\n```" ) @@ -15,7 +16,7 @@ def __init__(self, exception: ValueError): class CouldNotValidateModifiedLogChannel(AutomodException): def __init__(self, exception: ValueError): - self.exception = exception + self.exception: ValueError = exception super().__init__( f"😵 An error occurred while validating the modified log channel:\n```\n{exception}\n```" ) @@ -23,7 +24,7 @@ def __init__(self, exception: ValueError): class CouldNotValidateNewAutomodRule(AutomodException): def __init__(self, exception: ValueError): - self.exception = exception + self.exception: ValueError = exception super().__init__( f"😵 An error occurred while validating the new rule:\n```\n{exception}\n```" ) @@ -31,7 +32,7 @@ def __init__(self, exception: ValueError): class CouldNotValidateModifiedAutomodRule(AutomodException): def __init__(self, exception: ValueError): - self.exception = exception + self.exception: ValueError = exception super().__init__( f"😵 An error occurred while validating the modified rule:\n```\n{exception}\n```" ) @@ -39,12 +40,59 @@ def __init__(self, exception: ValueError): class CouldNotValidateUploadedAutomodRule(AutomodException): def __init__(self, exception: ValueError): - self.exception = exception + self.exception: ValueError = exception super().__init__( f"😵 An error occurred while validating the uploaded rule:\n```\n{exception}\n```" ) +class UnsupportedBucketTypeChoice(AutomodException): + def __init__(self, choice: BucketTypeChoices): + self.choice: BucketTypeChoices = choice + super().__init__( + f"😵 Unsupported bucket type choice '{choice}' (This should never happen!!!)" + ) + + +class UnsupportedBucketType(AutomodException): + def __init__(self, type: str): + self.type: str = type + super().__init__( + f"😵 Unsupported bucket type '{type}' (This should never happen!!!)" + ) + + +class CouldNotValidateNewMessageHistoryBucket(AutomodException): + def __init__(self, exception: ValueError): + self.exception: ValueError = exception + super().__init__( + f"😵 An error occurred while validating the new message history bucket:\n```\n{exception}\n```" + ) + + +class CouldNotValidateModifiedMessageHistoryBucket(AutomodException): + def __init__(self, exception: ValueError): + self.exception: ValueError = exception + super().__init__( + f"😵 An error occurred while validating the modified message history bucket:\n```\n{exception}\n```" + ) + +class CouldNotValidateNewFlaggedImageAttachmentsBucket(AutomodException): + def __init__(self, exception: ValueError): + self.exception: ValueError = exception + super().__init__( + f"😵 An error occurred while validating the new flagged image attachments bucket:\n```\n{exception}\n```" + ) + + +class CouldNotValidateModifiedFlaggedImageAttachmentsBucket(AutomodException): + def __init__(self, exception: ValueError): + self.exception: ValueError = exception + super().__init__( + f"😵 An error occurred while validating the modified flagged image attachments bucket:\n```\n{exception}\n```" + ) + + class UploadIsNotJsonFile(AutomodException): def __init__(self): super().__init__("🤔 The uploaded rule is not a Json file") diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index 56b2298..63988f8 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -23,11 +23,15 @@ from commanderbot.ext.automod.automod_context import AutomodContext from commanderbot.ext.automod.automod_exceptions import ( CouldNotValidateUploadedAutomodRule, + UnsupportedBucketType, + UnsupportedBucketTypeChoice, UploadIsNotJsonFile, ) from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.ext.automod.enums import BucketTypeChoices from commanderbot.ext.automod.event import AutomodEvent from commanderbot.ext.automod.rule import AutomodRule +from commanderbot.ext.automod.ui import buckets as buckets_ui from commanderbot.ext.automod.ui import log as log_ui from commanderbot.ext.automod.ui import rule as rule_ui from commanderbot.lib.allowed_mentions import AllowedMentions @@ -269,11 +273,21 @@ async def disable_all_rule(self, interaction: Interaction): await self.store.disable_all_rules(self.guild) await interaction.response.send_message(f"Disabled all `{rule_count}` rules") - async def add_flagged_image_attachments_bucket(self, interaction: Interaction): - pass - - async def add_message_history_bucket(self, interaction: Interaction): - pass + async def add_bucket( + self, interaction: Interaction, bucket_type: BucketTypeChoices + ): + match bucket_type: + case BucketTypeChoices.FLAGGED_IMAGE_ATTACHMENTS: + await interaction.response.send_modal( + buckets_ui.AddFlaggedImageAttachmentsBucketModal(interaction, self) + ) + case BucketTypeChoices.MESSAGE_HISTORY: + await interaction.response.send_modal( + buckets_ui.AddMessageHistoryBucketModal(interaction, self) + ) + case _: + # Just in case we forget to add modals in the future + raise UnsupportedBucketTypeChoice(bucket_type) async def modify_bucket(self, interaction: Interaction, name: str): # Get the bucket @@ -282,11 +296,20 @@ async def modify_bucket(self, interaction: Interaction, name: str): # Send the right modal based on the bucket type match bucket: case buckets.FlaggedImageAttachments(): - pass + await interaction.response.send_modal( + buckets_ui.ModifyFlaggedImageAttachmentsBucketModal( + interaction, self, bucket + ) + ) case buckets.MessageHistory(): - pass + await interaction.response.send_modal( + buckets_ui.ModifyMessageHistoryBucketModal( + interaction, self, bucket + ) + ) case _: - pass + # Just in case we forget to add modals in the future + raise UnsupportedBucketType(bucket.type) async def remove_bucket(self, interaction: Interaction, name: str): # Get the bucket diff --git a/commanderbot/ext/automod/automod_store.py b/commanderbot/ext/automod/automod_store.py index 1097157..6bba8ac 100644 --- a/commanderbot/ext/automod/automod_store.py +++ b/commanderbot/ext/automod/automod_store.py @@ -176,7 +176,9 @@ async def add_bucket(self, guild: Guild, bucket: AutomodBucket) -> AutomodBucket await self.db.commit() return result - async def modify_bucket(self, guild: Guild, bucket: AutomodBucket) -> AutomodBucket: + async def modify_bucket( + self, guild: Guild, bucket: AutomodBucket + ) -> tuple[AutomodBucket, AutomodBucket]: cache = await self.db.get_cache() guild_data = cache.guilds[guild.id] result = guild_data.modify_bucket(bucket) diff --git a/commanderbot/ext/automod/enums.py b/commanderbot/ext/automod/enums.py new file mode 100644 index 0000000..92de18a --- /dev/null +++ b/commanderbot/ext/automod/enums.py @@ -0,0 +1,14 @@ +from enum import StrEnum + +from discord.app_commands import Choice + +__all__ = ("BucketTypeChoices",) + + +class BucketTypeChoices(StrEnum): + FLAGGED_IMAGE_ATTACHMENTS = "flagged_image_attachments" + MESSAGE_HISTORY = "message_history" + + @classmethod + def as_choices(cls) -> list[Choice[str]]: + return [Choice(name=e.value, value=e.value) for e in cls] diff --git a/commanderbot/ext/automod/ui/__init__.py b/commanderbot/ext/automod/ui/__init__.py index e69de29..06b6180 100644 --- a/commanderbot/ext/automod/ui/__init__.py +++ b/commanderbot/ext/automod/ui/__init__.py @@ -0,0 +1,3 @@ +from .buckets import * +from .log import * +from .rule import * diff --git a/commanderbot/ext/automod/ui/buckets/__init__.py b/commanderbot/ext/automod/ui/buckets/__init__.py new file mode 100644 index 0000000..9cc456c --- /dev/null +++ b/commanderbot/ext/automod/ui/buckets/__init__.py @@ -0,0 +1,2 @@ +from .flagged_image_attachments import * +from .message_history import * diff --git a/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py new file mode 100644 index 0000000..6cb0a9f --- /dev/null +++ b/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py @@ -0,0 +1,152 @@ +from typing import TYPE_CHECKING, override + +from discord import Interaction, TextStyle, ui + +from commanderbot.ext.automod import buckets +from commanderbot.ext.automod.automod_exceptions import ( + CouldNotValidateNewFlaggedImageAttachmentsBucket, +) +from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.lib.cogs.views import CogStateModal +from commanderbot.lib.constants import MAX_MODAL_TITLE_LENGTH +from commanderbot.lib.timedelta import TimedeltaAdapter + +__all__ = ( + "AddFlaggedImageAttachmentsBucketModal", + "ModifyFlaggedImageAttachmentsBucketModal", +) +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + + +class AddFlaggedImageAttachmentsBucketModal( + CogStateModal["AutomodGuildState", AutomodStore] +): + def __init__(self, interaction: Interaction, state: AutomodGuildState): + super().__init__( + interaction, + state, + title="Add a new flagged image attachments bucket", + custom_id="commanderbot_ext:automod.buckets.flagged_image_attachments.add", + ) + + self.name_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'sample-text'", + required=True, + ) + self.name_label = ui.Label( + text="The name of the bucket", component=self.name_input + ) + + self.description_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'Spam'", + required=False, + ) + self.description_label = ui.Label( + text="Describe what the bucket is for", component=self.description_input + ) + + self.lifetime_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'PT5H30M', '2 days, 05:15:00', or '1.5'", + required=True, + ) + self.lifetime_label = ui.Label( + text="How long to store flagged image attachments", + description="Longer durations are able to store more image attachments, but take more memory on average", + component=self.lifetime_input, + ) + + self.add_item(self.name_label) + self.add_item(self.description_label) + self.add_item(self.lifetime_label) + + @override + async def on_submit(self, interaction: Interaction): + raw_bucket: dict = { + "type": "flagged_image_attachments", + "name": self.name_input.value, + "description": self.description_input.value or None, + "lifetime": self.lifetime_input.value, + } + + try: + bucket = buckets.FlaggedImageAttachments.model_validate(raw_bucket) + await self.store.add_bucket(self.guild, bucket) + await interaction.response.send_message( + f"Added flagged image attachments bucket `{bucket.name}`" + ) + except ValueError as ex: + raise CouldNotValidateNewFlaggedImageAttachmentsBucket(ex) + + +class ModifyFlaggedImageAttachmentsBucketModal( + CogStateModal["AutomodGuildState", AutomodStore] +): + def __init__( + self, + interaction: Interaction, + state: AutomodGuildState, + bucket: buckets.FlaggedImageAttachments, + ): + self.name: str = bucket.name + title: str = f"Modifying bucket '{bucket.name}'" + if len(title) > MAX_MODAL_TITLE_LENGTH: + title = f"{title[:42]}..." + + super().__init__( + interaction, + state, + title=title, + custom_id="commanderbot_ext:automod.buckets.flagged_image_attachments.modify", + ) + + self.description_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'Spam'", + default=bucket.description or "", + required=False, + ) + self.description_label = ui.Label( + text="Describe what the bucket is for", component=self.description_input + ) + + lifetime: str = TimedeltaAdapter.dump_json(bucket.lifetime).decode().strip('"') + self.lifetime_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'PT5H30M', '2 days, 05:15:00', or '1.5'", + default=lifetime, + required=True, + ) + self.lifetime_label = ui.Label( + text="How long to record message history", + description="Longer durations are able to record more message history, but take more memory on average", + component=self.lifetime_input, + ) + + self.add_item(self.description_label) + self.add_item(self.lifetime_label) + + @override + async def on_submit(self, interaction: Interaction): + # The bucket needs to exist + old_bucket = await self.store.require_bucket_with_type( + self.guild, self.name, buckets.FlaggedImageAttachments + ) + + # Serialize the bucket and modify its data + raw_bucket: dict = old_bucket.model_dump(exclude_defaults=True) + raw_bucket["description"] = self.description_input.value or None + raw_bucket["lifetime"] = self.lifetime_input.value + + try: + # Deserialize the modified bucket and update the store + bucket = buckets.FlaggedImageAttachments.model_validate(raw_bucket) + await self.store.modify_bucket(self.guild, bucket) + await interaction.response.send_message( + f"Modified flagged image attachments bucket `{bucket.name}`" + ) + except ValueError as ex: + raise CouldNotValidateNewFlaggedImageAttachmentsBucket(ex) diff --git a/commanderbot/ext/automod/ui/buckets/message_history.py b/commanderbot/ext/automod/ui/buckets/message_history.py new file mode 100644 index 0000000..3b8e536 --- /dev/null +++ b/commanderbot/ext/automod/ui/buckets/message_history.py @@ -0,0 +1,178 @@ +from typing import TYPE_CHECKING, override + +from discord import Interaction, TextStyle, ui + +from commanderbot.ext.automod import buckets +from commanderbot.ext.automod.automod_exceptions import ( + CouldNotValidateModifiedMessageHistoryBucket, + CouldNotValidateNewMessageHistoryBucket, +) +from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.lib.cogs.views import CogStateModal +from commanderbot.lib.constants import MAX_MODAL_TITLE_LENGTH +from commanderbot.lib.timedelta import TimedeltaAdapter + +__all__ = ( + "AddMessageHistoryBucketModal", + "ModifyMessageHistoryBucketModal", +) + +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + + +class AddMessageHistoryBucketModal(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__(self, interaction: Interaction, state: AutomodGuildState): + super().__init__( + interaction, + state, + title="Add a new message history bucket", + custom_id="commanderbot_ext:automod.buckets.message_history.add", + ) + + self.name_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'sample-text'", + required=True, + ) + self.name_label = ui.Label( + text="The name of the bucket", component=self.name_input + ) + + self.description_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'Spam'", + required=False, + ) + self.description_label = ui.Label( + text="Describe what the bucket is for", component=self.description_input + ) + + self.lifetime_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'PT5H30M', '2 days, 05:15:00', or '1.5'", + required=True, + ) + self.lifetime_label = ui.Label( + text="How long to record message history", + description="Longer durations are able to record more message history, but take more memory on average", + component=self.lifetime_input, + ) + + self.interval_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'PT5H30M', '2 days, 05:15:00', or '1.5'", + required=True, + ) + self.interval_label = ui.Label( + text="The interval to partition message history by", + description="This is used to decide when to release old, unused message history and free memory", + component=self.interval_input, + ) + + self.add_item(self.name_label) + self.add_item(self.description_label) + self.add_item(self.lifetime_label) + self.add_item(self.interval_label) + + @override + async def on_submit(self, interaction: Interaction): + raw_bucket: dict = { + "type": "message_history", + "name": self.name_input.value, + "description": self.description_input.value or None, + "lifetime": self.lifetime_input.value, + "interval": self.interval_input.value, + } + + try: + bucket = buckets.MessageHistory.model_validate(raw_bucket) + await self.store.add_bucket(self.guild, bucket) + await interaction.response.send_message( + f"Added message history bucket `{bucket.name}`" + ) + except ValueError as ex: + raise CouldNotValidateNewMessageHistoryBucket(ex) + + +class ModifyMessageHistoryBucketModal(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__( + self, + interaction: Interaction, + state: AutomodGuildState, + bucket: buckets.MessageHistory, + ): + self.name: str = bucket.name + title: str = f"Modifying bucket '{bucket.name}'" + if len(title) > MAX_MODAL_TITLE_LENGTH: + title = f"{title[:42]}..." + + super().__init__( + interaction, + state, + title=title, + custom_id="commanderbot_ext:automod.buckets.message_history.modify", + ) + + self.description_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'Spam'", + default=bucket.description or "", + required=False, + ) + self.description_label = ui.Label( + text="Describe what the bucket is for", component=self.description_input + ) + + lifetime: str = TimedeltaAdapter.dump_json(bucket.lifetime).decode().strip('"') + self.lifetime_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'PT5H30M', '2 days, 05:15:00', or '1.5'", + default=lifetime, + required=True, + ) + self.lifetime_label = ui.Label( + text="How long to record message history", + description="Longer durations are able to record more message history, but take more memory on average", + component=self.lifetime_input, + ) + + interval: str = TimedeltaAdapter.dump_json(bucket.interval).decode().strip('"') + self.interval_input = ui.TextInput( + style=TextStyle.short, + placeholder="Ex: 'PT5H30M', '2 days, 05:15:00', or '1.5'", + default=interval, + required=True, + ) + self.interval_label = ui.Label( + text="The interval to partition message history by", + description="This is used to decide when to release old, unused message history and free memory", + component=self.interval_input, + ) + + self.add_item(self.description_label) + self.add_item(self.lifetime_label) + self.add_item(self.interval_label) + + @override + async def on_submit(self, interaction: Interaction): + # The bucket needs to exist + old_bucket = await self.store.require_bucket_with_type( + self.guild, self.name, buckets.MessageHistory + ) + + # Serialize the bucket and modify its data + raw_bucket: dict = old_bucket.model_dump(exclude_defaults=True) + raw_bucket["description"] = self.description_input.value or None + raw_bucket["lifetime"] = self.lifetime_input.value + raw_bucket["interval"] = self.interval_input.value + + try: + # Deserialize the modified bucket and update the store + bucket = buckets.MessageHistory.model_validate(raw_bucket) + await self.store.modify_bucket(self.guild, bucket) + await interaction.response.send_message( + f"Modified message history bucket `{bucket.name}`" + ) + except ValueError as ex: + raise CouldNotValidateModifiedMessageHistoryBucket(ex) diff --git a/commanderbot/ext/automod/ui/log.py b/commanderbot/ext/automod/ui/log.py index cac4f6d..0457c44 100644 --- a/commanderbot/ext/automod/ui/log.py +++ b/commanderbot/ext/automod/ui/log.py @@ -92,7 +92,7 @@ def __init__(self, interaction: Interaction, state: AutomodGuildState): @override async def on_submit(self, interaction: Interaction): - raw_log = { + raw_log: dict = { "channel": self.channel_input.values[-1].id, "emoji": self.emoji_input.value or None, "color": self.color_input.value or None, @@ -107,7 +107,7 @@ async def on_submit(self, interaction: Interaction): try: log = LogChannel.model_validate(raw_log) - await self.store.set_default_log(self.state.guild, log) + await self.store.set_default_log(self.guild, log) await interaction.response.send_message( f"Set default log channel to <#{log.channel}>" ) @@ -198,7 +198,7 @@ def __init__( @override async def on_submit(self, interaction: Interaction): - raw_log = { + raw_log: dict = { "channel": self.channel_input.values[-1].id, "emoji": self.emoji_input.value or None, "color": self.color_input.value or None, @@ -213,7 +213,7 @@ async def on_submit(self, interaction: Interaction): try: log = LogChannel.model_validate(raw_log) - await self.store.modify_default_log(self.state.guild, log) + await self.store.modify_default_log(self.guild, log) await interaction.response.send_message( f"Modified default log channel <#{log.channel}>" ) diff --git a/commanderbot/ext/automod/ui/rule.py b/commanderbot/ext/automod/ui/rule.py index 92970b8..4efc860 100644 --- a/commanderbot/ext/automod/ui/rule.py +++ b/commanderbot/ext/automod/ui/rule.py @@ -45,7 +45,7 @@ def __init__(self, interaction: Interaction, state: AutomodGuildState): async def on_submit(self, interaction: Interaction): try: rule = AutomodRule.model_validate_json(self.rule_input.value) - await self.store.add_rule(self.state.guild, rule, interaction.user.id) + await self.store.add_rule(self.guild, rule, interaction.user.id) await interaction.response.send_message(f"Added rule `{rule.name}`") except ValueError as ex: raise CouldNotValidateNewAutomodRule(ex) @@ -83,7 +83,7 @@ def __init__( async def on_submit(self, interaction: Interaction): try: rule = AutomodRule.model_validate_json(self.rule_input.value) - await self.store.modify_rule(self.state.guild, rule, interaction.user.id) + await self.store.modify_rule(self.guild, rule, interaction.user.id) await interaction.response.send_message(f"Modified rule `{rule.name}`") except ValueError as ex: raise CouldNotValidateModifiedAutomodRule(ex) From 1599f02cdf66609b755ce16b8645896d575b18d5 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sat, 15 Aug 2026 02:56:11 -0700 Subject: [PATCH 124/158] Moved UIs to their own files --- .../ext/automod/automod_guild_state.py | 186 ++---------------- commanderbot/ext/automod/ui/__init__.py | 1 + commanderbot/ext/automod/ui/bucket.py | 40 ++++ .../ui/buckets/flagged_image_attachments.py | 36 +++- .../ext/automod/ui/buckets/message_history.py | 39 +++- commanderbot/ext/automod/ui/log.py | 44 ++++- commanderbot/ext/automod/ui/rule.py | 65 +++++- 7 files changed, 229 insertions(+), 182 deletions(-) create mode 100644 commanderbot/ext/automod/ui/bucket.py diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index 63988f8..f910d18 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -13,9 +13,7 @@ Thread, ThreadMember, User, - ui, ) -from discord.utils import format_dt from mime_enum import MimeType from commanderbot.core.utils import get_app_command @@ -31,6 +29,7 @@ from commanderbot.ext.automod.enums import BucketTypeChoices from commanderbot.ext.automod.event import AutomodEvent from commanderbot.ext.automod.rule import AutomodRule +from commanderbot.ext.automod.ui import bucket as bucket_ui from commanderbot.ext.automod.ui import buckets as buckets_ui from commanderbot.ext.automod.ui import log as log_ui from commanderbot.ext.automod.ui import rule as rule_ui @@ -52,14 +51,12 @@ class AutomodGuildState(CogGuildState): # @@ COMMANDS async def set_default_log(self, interaction: Interaction): - await interaction.response.send_modal( - log_ui.SetDefaultLogModal(interaction, self) - ) + await interaction.response.send_modal(log_ui.SetDefaultLog(interaction, self)) async def modify_default_log(self, interaction: Interaction): log = await self.store.require_default_log(self.guild) await interaction.response.send_modal( - log_ui.ModifyDefaultLogModal(interaction, self, log) + log_ui.ModifyDefaultLog(interaction, self, log) ) async def remove_default_log(self, interaction: Interaction): @@ -90,39 +87,8 @@ async def remove_default_log(self, interaction: Interaction): ) async def show_default_log_details(self, interaction: Interaction): - # Get the default log log = await self.store.require_default_log(self.guild) - - # Create fields - fields: dict[str, str] = { - "Channel": f"<#{log.channel}>", - "Emoji": log.emoji or "**None!**", - "Color": f"`{log.color}`" if log.color is not None else "**None!**", - "Stacktrace": "✅" if log.stacktrace else "❌", - "Allowed Mentions": "\n".join( - ( - "", - f"- Everyone: {'✅' if log.allowed_mentions.everyone else '❌'}", - f"- Users: {'✅' if log.allowed_mentions.users else '❌'}", - f"- Roles: {'✅' if log.allowed_mentions.roles else '❌'}", - f"- Replied User: {'✅' if log.allowed_mentions.replied_user else '❌'}", - ) - ), - } - - # Create the default log details view - view = ui.LayoutView() - container = ui.Container(accent_color=0x00ACED) - view.add_item(container) - - container.add_item( - ui.TextDisplay(f"### 🧾 Details for default log channel <#{log.channel}>") - ) - container.add_item(ui.Separator()) - for field_name, field_value in fields.items(): - container.add_item(ui.TextDisplay(f"**{field_name}**: {field_value}")) - - # Respond with the view + view = log_ui.DefaultLogDetails(log) await interaction.response.send_message(view=view) async def add_rule(self, interaction: Interaction): @@ -190,30 +156,8 @@ async def show_rule_details(self, interaction: Interaction, name: str): rule_json = rule.model_dump_json(indent=4, exclude_defaults=True) rule_file = str_to_file(rule_json, f"{rule.name}.json") - # Create fields - description = f"`{rule.description}`" if rule.description else "**None!**" - fields: dict[str, str] = { - "Name": f"`{rule.name}`", - "Description": description, - "Enabled": "❌" if rule.disabled else "✅", - "Hits": f"`{metadata.hits}`", - "Added by": f"<@{metadata.added_by_id}>({format_dt(metadata.added_on, style='R')})", - "Modified by": f"<@{metadata.modified_by_id}>({format_dt(metadata.modified_on, style='R')})", - } - - # Create the rule details view - view = ui.LayoutView() - container = ui.Container(accent_color=0x00ACED) - view.add_item(container) - - container.add_item(ui.TextDisplay(f"### 📜 Details for rule `{rule.name}`")) - container.add_item(ui.Separator()) - container.add_item(ui.File(rule_file)) - container.add_item(ui.Separator()) - for field_name, field_value in fields.items(): - container.add_item(ui.TextDisplay(f"**{field_name}**: {field_value}")) - - # Respond with the view + # Create the rule details view and respond with it + view = rule_ui.RuleDetails(rule, metadata) await interaction.response.send_message( view=view, file=rule_file, @@ -229,38 +173,8 @@ async def disable_rule(self, interaction: Interaction, name: str): await interaction.response.send_message(f"Disabled rule `{rule.name}`") async def list_rules(self, interaction: Interaction): - # Get info about rules - formatted_rules: list[str] = [] - enabled_rules: int = 0 - disabled_rules: int = 0 - async for rule in self.store.yield_rules(self.guild): - if not rule.disabled: - formatted_rules.append(rule.name) - enabled_rules += 1 - else: - formatted_rules.append(f"{rule.name} (Disabled)") - disabled_rules += 1 - - # Create rule list view - view = ui.LayoutView() - container = ui.Container(accent_color=0x00ACED) - view.add_item(container) - - container.add_item(ui.TextDisplay("### 📜 All rules")) - container.add_item(ui.Separator()) - - if formatted_rules: - lines = "\n".join(("```", "\n".join(formatted_rules), "```")) - container.add_item(ui.TextDisplay(lines)) - else: - container.add_item(ui.TextDisplay("**None!**")) - - container.add_item(ui.Separator()) - container.add_item( - ui.TextDisplay(f"-# Enabled: {enabled_rules} | Disabled: {disabled_rules}") - ) - - # Respond with the view + all_rules = [r async for r in self.store.yield_rules(self.guild)] + view = rule_ui.RuleList(all_rules) await interaction.response.send_message(view=view) async def enable_all_rule(self, interaction: Interaction): @@ -279,14 +193,13 @@ async def add_bucket( match bucket_type: case BucketTypeChoices.FLAGGED_IMAGE_ATTACHMENTS: await interaction.response.send_modal( - buckets_ui.AddFlaggedImageAttachmentsBucketModal(interaction, self) + buckets_ui.AddFlaggedImageAttachmentsBucket(interaction, self) ) case BucketTypeChoices.MESSAGE_HISTORY: await interaction.response.send_modal( - buckets_ui.AddMessageHistoryBucketModal(interaction, self) + buckets_ui.AddMessageHistoryBucket(interaction, self) ) case _: - # Just in case we forget to add modals in the future raise UnsupportedBucketTypeChoice(bucket_type) async def modify_bucket(self, interaction: Interaction, name: str): @@ -297,18 +210,15 @@ async def modify_bucket(self, interaction: Interaction, name: str): match bucket: case buckets.FlaggedImageAttachments(): await interaction.response.send_modal( - buckets_ui.ModifyFlaggedImageAttachmentsBucketModal( + buckets_ui.ModifyFlaggedImageAttachmentsBucket( interaction, self, bucket ) ) case buckets.MessageHistory(): await interaction.response.send_modal( - buckets_ui.ModifyMessageHistoryBucketModal( - interaction, self, bucket - ) + buckets_ui.ModifyMessageHistoryBucket(interaction, self, bucket) ) case _: - # Just in case we forget to add modals in the future raise UnsupportedBucketType(bucket.type) async def remove_bucket(self, interaction: Interaction, name: str): @@ -339,43 +249,17 @@ async def remove_bucket(self, interaction: Interaction, name: str): ) async def show_bucket_details(self, interaction: Interaction, name: str): - # Get the bucket bucket = await self.store.require_bucket(self.guild, name) - # Create fields - description = f"`{bucket.description}`" if bucket.description else "**None!**" - fields: dict[str, str] = { - "Type": f"`{bucket.type}`", - "Name": f"`{bucket.name}`", - "Description": description, - "Enabled": "❌" if bucket.disabled else "✅", - } - - # Add any fields that are specific to the bucket type match bucket: case buckets.FlaggedImageAttachments(): - fields["Lifetime"] = f"`{bucket.lifetime}`" - fields["Flagged"] = f"`{len(bucket.attachments)}`" + view = buckets_ui.FlaggedImageAttachmentsBucketDetails(bucket) + await interaction.response.send_message(view=view) case buckets.MessageHistory(): - fields["Lifetime"] = f"`{bucket.lifetime}`" - fields["Interval"] = f"`{bucket.interval}`" - fields["Messages"] = f"`{bucket.message_count}`" - fields["Channels"] = f"`{bucket.channel_count}`" + view = buckets_ui.MessageHistoryBucketDetails(bucket) + await interaction.response.send_message(view=view) case _: - pass - - # Create the bucket details view - view = ui.LayoutView() - container = ui.Container(accent_color=0x00ACED) - view.add_item(container) - - container.add_item(ui.TextDisplay(f"### 🪣 Details for bucket `{bucket.name}`")) - container.add_item(ui.Separator()) - for field_name, field_value in fields.items(): - container.add_item(ui.TextDisplay(f"**{field_name}**: {field_value}")) - - # Respond with the view - await interaction.response.send_message(view=view) + raise UnsupportedBucketType(bucket.type) async def clear_bucket(self, interaction: Interaction, name: str): bucket = await self.store.clear_bucket(self.guild, name) @@ -390,40 +274,8 @@ async def disable_bucket(self, interaction: Interaction, name: str): await interaction.response.send_message(f"Disabled bucket `{bucket.name}`") async def list_buckets(self, interaction: Interaction): - # Get info about buckets - formatted_buckets: list[str] = [] - enabled_buckets: int = 0 - disabled_buckets: int = 0 - async for bucket in self.store.yield_buckets(self.guild): - if not bucket.disabled: - formatted_buckets.append(bucket.name) - enabled_buckets += 1 - else: - formatted_buckets.append(f"{bucket.name} (Disabled)") - disabled_buckets += 1 - - # Create bucket list view - view = ui.LayoutView() - container = ui.Container(accent_color=0x00ACED) - view.add_item(container) - - container.add_item(ui.TextDisplay("### 🪣 All buckets")) - container.add_item(ui.Separator()) - - if formatted_buckets: - lines = "\n".join(("```", "\n".join(formatted_buckets), "```")) - container.add_item(ui.TextDisplay(lines)) - else: - container.add_item(ui.TextDisplay("**None!**")) - - container.add_item(ui.Separator()) - container.add_item( - ui.TextDisplay( - f"-# Enabled: {enabled_buckets} | Disabled: {disabled_buckets}" - ) - ) - - # Respond with the view + all_buckets = [b async for b in self.store.yield_buckets(self.guild)] + view = bucket_ui.BucketList(all_buckets) await interaction.response.send_message(view=view) async def clear_all_buckets(self, interaction: Interaction): diff --git a/commanderbot/ext/automod/ui/__init__.py b/commanderbot/ext/automod/ui/__init__.py index 06b6180..023f996 100644 --- a/commanderbot/ext/automod/ui/__init__.py +++ b/commanderbot/ext/automod/ui/__init__.py @@ -1,3 +1,4 @@ +from .bucket import * from .buckets import * from .log import * from .rule import * diff --git a/commanderbot/ext/automod/ui/bucket.py b/commanderbot/ext/automod/ui/bucket.py new file mode 100644 index 0000000..997a833 --- /dev/null +++ b/commanderbot/ext/automod/ui/bucket.py @@ -0,0 +1,40 @@ +from discord import ui + +from commanderbot.ext.automod.bucket import AutomodBucket + +__all__ = ("BucketList",) + + +class BucketList(ui.LayoutView): + def __init__(self, buckets: list[AutomodBucket]): + super().__init__() + + container = ui.Container(accent_color=0x00ACED) + self.add_item(container) + + container.add_item(ui.TextDisplay("### 🪣 All buckets")) + container.add_item(ui.Separator()) + + formatted_buckets: list[str] = [] + enabled_buckets: int = 0 + disabled_buckets: int = 0 + for bucket in buckets: + if not bucket.disabled: + formatted_buckets.append(bucket.name) + enabled_buckets += 1 + else: + formatted_buckets.append(f"{bucket.name} (Disabled)") + disabled_buckets += 1 + + if formatted_buckets: + lines = "\n".join(("```", "\n".join(formatted_buckets), "```")) + container.add_item(ui.TextDisplay(lines)) + else: + container.add_item(ui.TextDisplay("**None!**")) + + container.add_item(ui.Separator()) + container.add_item( + ui.TextDisplay( + f"-# Enabled: {enabled_buckets} | Disabled: {disabled_buckets}" + ) + ) diff --git a/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py index 6cb0a9f..4b4285d 100644 --- a/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py +++ b/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py @@ -12,14 +12,15 @@ from commanderbot.lib.timedelta import TimedeltaAdapter __all__ = ( - "AddFlaggedImageAttachmentsBucketModal", - "ModifyFlaggedImageAttachmentsBucketModal", + "AddFlaggedImageAttachmentsBucket", + "FlaggedImageAttachmentsBucketDetails", + "ModifyFlaggedImageAttachmentsBucket", ) if TYPE_CHECKING: from commanderbot.ext.automod.automod_guild_state import AutomodGuildState -class AddFlaggedImageAttachmentsBucketModal( +class AddFlaggedImageAttachmentsBucket( CogStateModal["AutomodGuildState", AutomodStore] ): def __init__(self, interaction: Interaction, state: AutomodGuildState): @@ -82,7 +83,7 @@ async def on_submit(self, interaction: Interaction): raise CouldNotValidateNewFlaggedImageAttachmentsBucket(ex) -class ModifyFlaggedImageAttachmentsBucketModal( +class ModifyFlaggedImageAttachmentsBucket( CogStateModal["AutomodGuildState", AutomodStore] ): def __init__( @@ -150,3 +151,30 @@ async def on_submit(self, interaction: Interaction): ) except ValueError as ex: raise CouldNotValidateNewFlaggedImageAttachmentsBucket(ex) + + +class FlaggedImageAttachmentsBucketDetails(ui.LayoutView): + def __init__(self, bucket: buckets.FlaggedImageAttachments): + super().__init__() + + container = ui.Container(accent_color=0x00ACED) + self.add_item(container) + + container.add_item(ui.TextDisplay(f"### 🪣 Details for bucket `{bucket.name}`")) + container.add_item(ui.Separator()) + + description: str = ( + f"`{bucket.description}`" if bucket.description else "**None!**" + ) + lifetime: str = TimedeltaAdapter.dump_json(bucket.lifetime).decode().strip('"') + fields: dict[str, str] = { + "Type": f"`{bucket.type}`", + "Name": f"`{bucket.name}`", + "Description": description, + "Enabled": "❌" if bucket.disabled else "✅", + "Lifetime": f"`{lifetime}`", + "Flagged": f"`{len(bucket.attachments)}`", + } + + for field_name, field_value in fields.items(): + container.add_item(ui.TextDisplay(f"**{field_name}**: {field_value}")) diff --git a/commanderbot/ext/automod/ui/buckets/message_history.py b/commanderbot/ext/automod/ui/buckets/message_history.py index 3b8e536..a44756f 100644 --- a/commanderbot/ext/automod/ui/buckets/message_history.py +++ b/commanderbot/ext/automod/ui/buckets/message_history.py @@ -13,15 +13,16 @@ from commanderbot.lib.timedelta import TimedeltaAdapter __all__ = ( - "AddMessageHistoryBucketModal", - "ModifyMessageHistoryBucketModal", + "AddMessageHistoryBucket", + "MessageHistoryBucketDetails", + "ModifyMessageHistoryBucket", ) if TYPE_CHECKING: from commanderbot.ext.automod.automod_guild_state import AutomodGuildState -class AddMessageHistoryBucketModal(CogStateModal["AutomodGuildState", AutomodStore]): +class AddMessageHistoryBucket(CogStateModal["AutomodGuildState", AutomodStore]): def __init__(self, interaction: Interaction, state: AutomodGuildState): super().__init__( interaction, @@ -95,7 +96,7 @@ async def on_submit(self, interaction: Interaction): raise CouldNotValidateNewMessageHistoryBucket(ex) -class ModifyMessageHistoryBucketModal(CogStateModal["AutomodGuildState", AutomodStore]): +class ModifyMessageHistoryBucket(CogStateModal["AutomodGuildState", AutomodStore]): def __init__( self, interaction: Interaction, @@ -176,3 +177,33 @@ async def on_submit(self, interaction: Interaction): ) except ValueError as ex: raise CouldNotValidateModifiedMessageHistoryBucket(ex) + + +class MessageHistoryBucketDetails(ui.LayoutView): + def __init__(self, bucket: buckets.MessageHistory): + super().__init__() + + container = ui.Container(accent_color=0x00ACED) + self.add_item(container) + + container.add_item(ui.TextDisplay(f"### 🪣 Details for bucket `{bucket.name}`")) + container.add_item(ui.Separator()) + + description: str = ( + f"`{bucket.description}`" if bucket.description else "**None!**" + ) + lifetime: str = TimedeltaAdapter.dump_json(bucket.lifetime).decode().strip('"') + interval: str = TimedeltaAdapter.dump_json(bucket.interval).decode().strip('"') + fields: dict[str, str] = { + "Type": f"`{bucket.type}`", + "Name": f"`{bucket.name}`", + "Description": description, + "Enabled": "❌" if bucket.disabled else "✅", + "Lifetime": f"`{lifetime}`", + "Interval": f"`{interval}`", + "Messages": f"`{bucket.message_count}`", + "Channels": f"`{bucket.channel_count}`", + } + + for field_name, field_value in fields.items(): + container.add_item(ui.TextDisplay(f"**{field_name}**: {field_value}")) diff --git a/commanderbot/ext/automod/ui/log.py b/commanderbot/ext/automod/ui/log.py index 0457c44..d094b92 100644 --- a/commanderbot/ext/automod/ui/log.py +++ b/commanderbot/ext/automod/ui/log.py @@ -18,15 +18,15 @@ from commanderbot.lib.log_channel import LogChannel __all__ = ( - "ModifyDefaultLogModal", - "SetDefaultLogModal", + "ModifyDefaultLog", + "SetDefaultLog", ) if TYPE_CHECKING: from commanderbot.ext.automod.automod_guild_state import AutomodGuildState -class SetDefaultLogModal(CogStateModal["AutomodGuildState", AutomodStore]): +class SetDefaultLog(CogStateModal["AutomodGuildState", AutomodStore]): def __init__(self, interaction: Interaction, state: AutomodGuildState): super().__init__( interaction, @@ -69,7 +69,7 @@ def __init__(self, interaction: Interaction, state: AutomodGuildState): ) self.stacktrace_input = ui.CheckboxGroup(required=False) - self.stacktrace_input.add_option(label="\u200b", default=False) + self.stacktrace_input.add_option(label="\u200B", default=False) self.stacktrace_label = ui.Label( text="Print exception stacktraces", component=self.stacktrace_input ) @@ -115,7 +115,7 @@ async def on_submit(self, interaction: Interaction): raise CouldNotValidateLogChannel(ex) -class ModifyDefaultLogModal(CogStateModal["AutomodGuildState", AutomodStore]): +class ModifyDefaultLog(CogStateModal["AutomodGuildState", AutomodStore]): def __init__( self, interaction: Interaction, state: AutomodGuildState, log: LogChannel ): @@ -166,7 +166,7 @@ def __init__( self.stacktrace_input = ui.CheckboxGroup(required=False) self.stacktrace_input.add_option( - label="\u200b", default=log.stacktrace or False + label="\u200B", default=log.stacktrace or False ) self.stacktrace_label = ui.Label( text="Print exception stacktraces", component=self.stacktrace_input @@ -219,3 +219,35 @@ async def on_submit(self, interaction: Interaction): ) except ValueError as ex: raise CouldNotValidateModifiedLogChannel(ex) + + +class DefaultLogDetails(ui.LayoutView): + def __init__(self, log: LogChannel): + super().__init__() + + container = ui.Container(accent_color=0x00ACED) + self.add_item(container) + + container.add_item( + ui.TextDisplay(f"### 🧾 Details for default log channel <#{log.channel}>") + ) + container.add_item(ui.Separator()) + + fields: dict[str, str] = { + "Channel": f"<#{log.channel}>", + "Emoji": log.emoji or "**None!**", + "Color": f"`{log.color}`" if log.color is not None else "**None!**", + "Stacktrace": "✅" if log.stacktrace else "❌", + "Allowed Mentions": "\n".join( + ( + "", + f"- Everyone: {'✅' if log.allowed_mentions.everyone else '❌'}", + f"- Users: {'✅' if log.allowed_mentions.users else '❌'}", + f"- Roles: {'✅' if log.allowed_mentions.roles else '❌'}", + f"- Replied User: {'✅' if log.allowed_mentions.replied_user else '❌'}", + ) + ), + } + + for field_name, field_value in fields.items(): + container.add_item(ui.TextDisplay(f"**{field_name}**: {field_value}")) diff --git a/commanderbot/ext/automod/ui/rule.py b/commanderbot/ext/automod/ui/rule.py index 4efc860..8a40aef 100644 --- a/commanderbot/ext/automod/ui/rule.py +++ b/commanderbot/ext/automod/ui/rule.py @@ -1,19 +1,22 @@ from typing import TYPE_CHECKING, override from discord import Interaction, TextStyle, ui +from discord.utils import format_dt from commanderbot.ext.automod.automod_exceptions import ( CouldNotValidateModifiedAutomodRule, CouldNotValidateNewAutomodRule, ) from commanderbot.ext.automod.automod_store import AutomodStore -from commanderbot.ext.automod.rule import AutomodRule +from commanderbot.ext.automod.rule import AutomodRule, AutomodRuleMetadata from commanderbot.lib.cogs.views import CogStateModal from commanderbot.lib.constants import MAX_MODAL_TITLE_LENGTH __all__ = ( "AddRuleModal", "ModifyRuleModal", + "RuleDetails", + "RuleList", ) if TYPE_CHECKING: @@ -87,3 +90,63 @@ async def on_submit(self, interaction: Interaction): await interaction.response.send_message(f"Modified rule `{rule.name}`") except ValueError as ex: raise CouldNotValidateModifiedAutomodRule(ex) + + +class RuleDetails(ui.LayoutView): + def __init__(self, rule: AutomodRule, metadata: AutomodRuleMetadata): + super().__init__() + + container = ui.Container(accent_color=0x00ACED) + self.add_item(container) + + container.add_item(ui.TextDisplay(f"### 📜 Details for rule `{rule.name}`")) + container.add_item(ui.Separator()) + + container.add_item(ui.File(f"attachment://{rule.name}.json")) + container.add_item(ui.Separator()) + + description: str = f"`{rule.description}`" if rule.description else "**None!**" + fields: dict[str, str] = { + "Name": f"`{rule.name}`", + "Description": description, + "Enabled": "❌" if rule.disabled else "✅", + "Hits": f"`{metadata.hits}`", + "Added by": f"<@{metadata.added_by_id}>({format_dt(metadata.added_on, style='R')})", + "Modified by": f"<@{metadata.modified_by_id}>({format_dt(metadata.modified_on, style='R')})", + } + + for field_name, field_value in fields.items(): + container.add_item(ui.TextDisplay(f"**{field_name}**: {field_value}")) + + +class RuleList(ui.LayoutView): + def __init__(self, rules: list[AutomodRule]): + super().__init__() + + container = ui.Container(accent_color=0x00ACED) + self.add_item(container) + + container.add_item(ui.TextDisplay("### 📜 All rules")) + container.add_item(ui.Separator()) + + formatted_rules: list[str] = [] + enabled_rules: int = 0 + disabled_rules: int = 0 + for rule in rules: + if not rule.disabled: + formatted_rules.append(rule.name) + enabled_rules += 1 + else: + formatted_rules.append(f"{rule.name} (Disabled)") + disabled_rules += 1 + + if formatted_rules: + lines = "\n".join(("```", "\n".join(formatted_rules), "```")) + container.add_item(ui.TextDisplay(lines)) + else: + container.add_item(ui.TextDisplay("**None!**")) + + container.add_item(ui.Separator()) + container.add_item( + ui.TextDisplay(f"-# Enabled: {enabled_rules} | Disabled: {disabled_rules}") + ) From 631a484e70a7f119ee2068700b46d47416f72a6a Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sat, 15 Aug 2026 02:57:35 -0700 Subject: [PATCH 125/158] Added missing entry in `__all__` --- commanderbot/ext/automod/ui/log.py | 1 + 1 file changed, 1 insertion(+) diff --git a/commanderbot/ext/automod/ui/log.py b/commanderbot/ext/automod/ui/log.py index d094b92..fb047e8 100644 --- a/commanderbot/ext/automod/ui/log.py +++ b/commanderbot/ext/automod/ui/log.py @@ -18,6 +18,7 @@ from commanderbot.lib.log_channel import LogChannel __all__ = ( + "DefaultLogDetails", "ModifyDefaultLog", "SetDefaultLog", ) From 5febcbd2c20b27a3e17186e58eca13a70aa37279 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sat, 15 Aug 2026 03:44:02 -0700 Subject: [PATCH 126/158] Made `custom_id` optional --- .../ui/buckets/flagged_image_attachments.py | 12 ++----- .../ext/automod/ui/buckets/message_history.py | 14 ++------ commanderbot/ext/automod/ui/log.py | 18 +++------- commanderbot/ext/automod/ui/rule.py | 36 +++++++++---------- commanderbot/lib/cogs/views.py | 11 ++++-- 5 files changed, 35 insertions(+), 56 deletions(-) diff --git a/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py index 4b4285d..db73381 100644 --- a/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py +++ b/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py @@ -25,10 +25,7 @@ class AddFlaggedImageAttachmentsBucket( ): def __init__(self, interaction: Interaction, state: AutomodGuildState): super().__init__( - interaction, - state, - title="Add a new flagged image attachments bucket", - custom_id="commanderbot_ext:automod.buckets.flagged_image_attachments.add", + interaction, state, title="Add a new flagged image attachments bucket" ) self.name_input = ui.TextInput( @@ -97,12 +94,7 @@ def __init__( if len(title) > MAX_MODAL_TITLE_LENGTH: title = f"{title[:42]}..." - super().__init__( - interaction, - state, - title=title, - custom_id="commanderbot_ext:automod.buckets.flagged_image_attachments.modify", - ) + super().__init__(interaction, state, title=title) self.description_input = ui.TextInput( style=TextStyle.short, diff --git a/commanderbot/ext/automod/ui/buckets/message_history.py b/commanderbot/ext/automod/ui/buckets/message_history.py index a44756f..21d035c 100644 --- a/commanderbot/ext/automod/ui/buckets/message_history.py +++ b/commanderbot/ext/automod/ui/buckets/message_history.py @@ -24,12 +24,7 @@ class AddMessageHistoryBucket(CogStateModal["AutomodGuildState", AutomodStore]): def __init__(self, interaction: Interaction, state: AutomodGuildState): - super().__init__( - interaction, - state, - title="Add a new message history bucket", - custom_id="commanderbot_ext:automod.buckets.message_history.add", - ) + super().__init__(interaction, state, title="Add a new message history bucket") self.name_input = ui.TextInput( style=TextStyle.short, @@ -108,12 +103,7 @@ def __init__( if len(title) > MAX_MODAL_TITLE_LENGTH: title = f"{title[:42]}..." - super().__init__( - interaction, - state, - title=title, - custom_id="commanderbot_ext:automod.buckets.message_history.modify", - ) + super().__init__(interaction, state, title=title) self.description_input = ui.TextInput( style=TextStyle.short, diff --git a/commanderbot/ext/automod/ui/log.py b/commanderbot/ext/automod/ui/log.py index fb047e8..e941ced 100644 --- a/commanderbot/ext/automod/ui/log.py +++ b/commanderbot/ext/automod/ui/log.py @@ -29,12 +29,7 @@ class SetDefaultLog(CogStateModal["AutomodGuildState", AutomodStore]): def __init__(self, interaction: Interaction, state: AutomodGuildState): - super().__init__( - interaction, - state, - title="Set the default log channel", - custom_id="commanderbot_ext:automod.log.set", - ) + super().__init__(interaction, state, title="Set the default log channel") self.channel_input = ui.ChannelSelect( channel_types=[ChannelType.text, ChannelType.public_thread], @@ -70,7 +65,7 @@ def __init__(self, interaction: Interaction, state: AutomodGuildState): ) self.stacktrace_input = ui.CheckboxGroup(required=False) - self.stacktrace_input.add_option(label="\u200B", default=False) + self.stacktrace_input.add_option(label="\u200b", default=False) self.stacktrace_label = ui.Label( text="Print exception stacktraces", component=self.stacktrace_input ) @@ -120,12 +115,7 @@ class ModifyDefaultLog(CogStateModal["AutomodGuildState", AutomodStore]): def __init__( self, interaction: Interaction, state: AutomodGuildState, log: LogChannel ): - super().__init__( - interaction, - state, - title="Modifying default log channel", - custom_id="commanderbot_ext:automod.log.modify", - ) + super().__init__(interaction, state, title="Modifying default log channel") self.channel_input = ui.ChannelSelect( channel_types=[ChannelType.text, ChannelType.public_thread], @@ -167,7 +157,7 @@ def __init__( self.stacktrace_input = ui.CheckboxGroup(required=False) self.stacktrace_input.add_option( - label="\u200B", default=log.stacktrace or False + label="\u200b", default=log.stacktrace or False ) self.stacktrace_label = ui.Label( text="Print exception stacktraces", component=self.stacktrace_input diff --git a/commanderbot/ext/automod/ui/rule.py b/commanderbot/ext/automod/ui/rule.py index 8a40aef..57ec222 100644 --- a/commanderbot/ext/automod/ui/rule.py +++ b/commanderbot/ext/automod/ui/rule.py @@ -25,24 +25,24 @@ class AddRuleModal(CogStateModal["AutomodGuildState", AutomodStore]): def __init__(self, interaction: Interaction, state: AutomodGuildState): - super().__init__( - interaction, - state, - title="Add a new rule", - custom_id="commanderbot_ext:automod.rule.add", - ) + super().__init__(interaction, state, title="Add a new rule") self.rule_input = ui.TextInput( style=TextStyle.paragraph, placeholder="{}", required=True, ) - self.rule_input_label = ui.Label( + self.rule_label = ui.Label( text="The rule in Json format", - description=f"Run {state.get_schema_command()} if you need the schema", component=self.rule_input, ) - self.add_item(self.rule_input_label) + + self.rule_schema_help = ui.TextDisplay( + f"-# Run {state.get_schema_command()} if you need the schema" + ) + + self.add_item(self.rule_label) + self.add_item(self.rule_schema_help) @override async def on_submit(self, interaction: Interaction): @@ -62,12 +62,7 @@ def __init__( if len(title) > MAX_MODAL_TITLE_LENGTH: title = f"{title[:42]}..." - super().__init__( - interaction, - state, - title=title, - custom_id="commanderbot_ext:automod.rule.modify", - ) + super().__init__(interaction, state, title=title) self.rule_input = ui.TextInput( style=TextStyle.paragraph, @@ -75,12 +70,17 @@ def __init__( default=rule.model_dump_json(indent=4, exclude_defaults=True), required=True, ) - self.rule_input_label = ui.Label( + self.rule_label = ui.Label( text="The rule in Json format", - description=f"Run {state.get_schema_command()} if you need the schema", component=self.rule_input, ) - self.add_item(self.rule_input_label) + + self.rule_schema_help = ui.TextDisplay( + f"-# Run {state.get_schema_command()} if you need the schema" + ) + + self.add_item(self.rule_label) + self.add_item(self.rule_schema_help) @override async def on_submit(self, interaction: Interaction): diff --git a/commanderbot/lib/cogs/views.py b/commanderbot/lib/cogs/views.py index 3c4bb13..5ddeee7 100644 --- a/commanderbot/lib/cogs/views.py +++ b/commanderbot/lib/cogs/views.py @@ -3,6 +3,8 @@ from discord import Guild, Interaction, ui from discord.ext.commands import Bot, Cog +from commanderbot.lib.utils import dict_without_nones + __all__ = ("CogStateModal", "CogStateView") CogStateType = TypeVar("CogStateType") @@ -38,7 +40,7 @@ def __init__( state: CogStateType, *, title: str, - custom_id: str, + custom_id: Optional[str] = None, timeout: Optional[float] = None, ): self.original_interaction: Interaction = interaction @@ -50,7 +52,12 @@ def __init__( self.guild: Guild = getattr(state, "guild") self.store: CogStoreType = getattr(state, "store") - super().__init__(title=title, custom_id=custom_id, timeout=timeout) + params = dict_without_nones( + title=title, + custom_id=custom_id, + timeout=timeout, + ) + super().__init__(**params) @override async def interaction_check(self, interaction: Interaction) -> bool: From 53a4262c1c18f1593beb23e5cc5352278513e984 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sat, 15 Aug 2026 03:46:20 -0700 Subject: [PATCH 127/158] Wrong exception was being thrown --- .../ext/automod/ui/buckets/flagged_image_attachments.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py index db73381..3e01822 100644 --- a/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py +++ b/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py @@ -4,6 +4,7 @@ from commanderbot.ext.automod import buckets from commanderbot.ext.automod.automod_exceptions import ( + CouldNotValidateModifiedFlaggedImageAttachmentsBucket, CouldNotValidateNewFlaggedImageAttachmentsBucket, ) from commanderbot.ext.automod.automod_store import AutomodStore @@ -142,7 +143,7 @@ async def on_submit(self, interaction: Interaction): f"Modified flagged image attachments bucket `{bucket.name}`" ) except ValueError as ex: - raise CouldNotValidateNewFlaggedImageAttachmentsBucket(ex) + raise CouldNotValidateModifiedFlaggedImageAttachmentsBucket(ex) class FlaggedImageAttachmentsBucketDetails(ui.LayoutView): From 0751089785fd9e9b916f940c65a2851c614385dd Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sat, 15 Aug 2026 04:09:41 -0700 Subject: [PATCH 128/158] Added missing `await` --- commanderbot/ext/automod/conditions/inverted.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/conditions/inverted.py b/commanderbot/ext/automod/conditions/inverted.py index db85379..f1ff563 100644 --- a/commanderbot/ext/automod/conditions/inverted.py +++ b/commanderbot/ext/automod/conditions/inverted.py @@ -21,4 +21,4 @@ class Inverted(AutomodCondition): @override async def check(self, context: AutomodContext) -> bool: - return not self.condition.check(context) + return not await self.condition.check(context) From 8a54019562dc3c3bc8a3b4ba410ac71cfaaa9882 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sat, 15 Aug 2026 04:27:11 -0700 Subject: [PATCH 129/158] Renamed some classes and functions --- commanderbot/ext/automod/automod_cog.py | 4 ++-- commanderbot/ext/automod/automod_guild_state.py | 8 ++++---- commanderbot/ext/automod/events/__init__.py | 4 ++-- .../events/{user_banned.py => member_banned.py} | 4 ++-- .../{user_unbanned.py => member_unbanned.py} | 4 ++-- commanderbot/ext/automod/trigger/types.py | 10 +++++----- commanderbot/ext/automod/triggers/__init__.py | 4 ++-- .../ext/automod/triggers/member_banned.py | 15 +++++++++++++++ .../ext/automod/triggers/member_unbanned.py | 15 +++++++++++++++ commanderbot/ext/automod/triggers/user_banned.py | 15 --------------- .../ext/automod/triggers/user_unbanned.py | 15 --------------- 11 files changed, 49 insertions(+), 49 deletions(-) rename commanderbot/ext/automod/events/{user_banned.py => member_banned.py} (84%) rename commanderbot/ext/automod/events/{user_unbanned.py => member_unbanned.py} (83%) create mode 100644 commanderbot/ext/automod/triggers/member_banned.py create mode 100644 commanderbot/ext/automod/triggers/member_unbanned.py delete mode 100644 commanderbot/ext/automod/triggers/user_banned.py delete mode 100644 commanderbot/ext/automod/triggers/user_unbanned.py diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index 8d202b3..70ed5ae 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -408,7 +408,7 @@ async def on_member_ban(self, guild: Guild, user: User): https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_ban """ - await self.state[guild].on_user_banned(user) + await self.state[guild].on_member_banned(user) @Cog.listener() async def on_member_unban(self, guild: Guild, user: User): @@ -416,7 +416,7 @@ async def on_member_unban(self, guild: Guild, user: User): https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_unban """ - await self.state[guild].on_user_unbanned(user) + await self.state[guild].on_member_unbanned(user) # @@ MESSAGES diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index f910d18..4a0b83b 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -432,8 +432,8 @@ async def on_thread_member_left(self, member: ThreadMember): async def on_user_updated(self, before: User, after: User): await self.dispatch_event(events.UserUpdated(before, after)) - async def on_user_banned(self, user: User): - await self.dispatch_event(events.UserBanned(user)) + async def on_member_banned(self, user: User): + await self.dispatch_event(events.MemberBanned(user)) - async def on_user_unbanned(self, user: User): - await self.dispatch_event(events.UserUnbanned(user)) + async def on_member_unbanned(self, user: User): + await self.dispatch_event(events.MemberUnbanned(user)) diff --git a/commanderbot/ext/automod/events/__init__.py b/commanderbot/ext/automod/events/__init__.py index e235f97..c074c22 100644 --- a/commanderbot/ext/automod/events/__init__.py +++ b/commanderbot/ext/automod/events/__init__.py @@ -5,9 +5,11 @@ from .guild_channel_deleted import * from .guild_channel_pins_updated import * from .guild_channel_updated import * +from .member_banned import * from .member_joined import * from .member_left import * from .member_typing import * +from .member_unbanned import * from .member_updated import * from .message_deleted import * from .message_edited import * @@ -22,6 +24,4 @@ from .thread_member_left import * from .thread_removed import * from .thread_updated import * -from .user_banned import * -from .user_unbanned import * from .user_updated import * diff --git a/commanderbot/ext/automod/events/user_banned.py b/commanderbot/ext/automod/events/member_banned.py similarity index 84% rename from commanderbot/ext/automod/events/user_banned.py rename to commanderbot/ext/automod/events/member_banned.py index cf2ba53..35d96d0 100644 --- a/commanderbot/ext/automod/events/user_banned.py +++ b/commanderbot/ext/automod/events/member_banned.py @@ -5,11 +5,11 @@ from commanderbot.ext.automod.event import AutomodEvent -__all__ = ("UserBanned",) +__all__ = ("MemberBanned",) @dataclass -class UserBanned(AutomodEvent): +class MemberBanned(AutomodEvent): _user: User @property diff --git a/commanderbot/ext/automod/events/user_unbanned.py b/commanderbot/ext/automod/events/member_unbanned.py similarity index 83% rename from commanderbot/ext/automod/events/user_unbanned.py rename to commanderbot/ext/automod/events/member_unbanned.py index a99643a..07575ed 100644 --- a/commanderbot/ext/automod/events/user_unbanned.py +++ b/commanderbot/ext/automod/events/member_unbanned.py @@ -5,11 +5,11 @@ from commanderbot.ext.automod.event import AutomodEvent -__all__ = ("UserUnbanned",) +__all__ = ("MemberUnbanned",) @dataclass -class UserUnbanned(AutomodEvent): +class MemberUnbanned(AutomodEvent): _user: User @property diff --git a/commanderbot/ext/automod/trigger/types.py b/commanderbot/ext/automod/trigger/types.py index d4fad3e..b30c272 100644 --- a/commanderbot/ext/automod/trigger/types.py +++ b/commanderbot/ext/automod/trigger/types.py @@ -10,24 +10,24 @@ triggers.DiscordAutomodBlockedMemberInteractions | triggers.DiscordAutomodBlockedMessage | triggers.DiscordAutomodTimedOut + | triggers.MemberBanned + | triggers.MemberGainedFlags | triggers.MemberJoined | triggers.MemberLeft + | triggers.MemberLostFlags | triggers.MemberTyping + | triggers.MemberUnbanned | triggers.MentionsRemovedFromMessage - | triggers.MemberGainedFlags - | triggers.MemberLostFlags | triggers.MessageDeleted | triggers.MessageEdited | triggers.MessageFrequency | triggers.MessageSent | triggers.Message - | triggers.Reaction | triggers.ReactionAdded | triggers.ReactionRemoved + | triggers.Reaction | triggers.ThreadCreated | triggers.ThreadDeleted - | triggers.UserBanned - | triggers.UserUnbanned ) type AutomodTriggerType = Annotated[_AutomodTriggerType, Field(discriminator="type")] diff --git a/commanderbot/ext/automod/triggers/__init__.py b/commanderbot/ext/automod/triggers/__init__.py index 7562394..6ea09b0 100644 --- a/commanderbot/ext/automod/triggers/__init__.py +++ b/commanderbot/ext/automod/triggers/__init__.py @@ -1,11 +1,13 @@ from .discord_automod.blocked_member_interactions import * from .discord_automod.blocked_message import * from .discord_automod.timed_out import * +from .member_banned import * from .member_gained_flags import * from .member_joined import * from .member_left import * from .member_lost_flags import * from .member_typing import * +from .member_unbanned import * from .mentions_removed_from_message import * from .message import * from .message_deleted import * @@ -17,5 +19,3 @@ from .reaction_removed import * from .thread_created import * from .thread_deleted import * -from .user_banned import * -from .user_unbanned import * diff --git a/commanderbot/ext/automod/triggers/member_banned.py b/commanderbot/ext/automod/triggers/member_banned.py new file mode 100644 index 0000000..068ebb8 --- /dev/null +++ b/commanderbot/ext/automod/triggers/member_banned.py @@ -0,0 +1,15 @@ +from typing import Literal + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("MemberBanned",) + + +class MemberBanned(AutomodTrigger): + """ + Triggers when a member is banned. + """ + + type: Literal["member_banned"] + event_types = (events.MemberBanned,) diff --git a/commanderbot/ext/automod/triggers/member_unbanned.py b/commanderbot/ext/automod/triggers/member_unbanned.py new file mode 100644 index 0000000..6ce214b --- /dev/null +++ b/commanderbot/ext/automod/triggers/member_unbanned.py @@ -0,0 +1,15 @@ +from typing import Literal + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("MemberUnbanned",) + + +class MemberUnbanned(AutomodTrigger): + """ + Triggers when a member is unbanned. + """ + + type: Literal["member_unbanned"] + event_types = (events.MemberUnbanned,) diff --git a/commanderbot/ext/automod/triggers/user_banned.py b/commanderbot/ext/automod/triggers/user_banned.py deleted file mode 100644 index 40117a6..0000000 --- a/commanderbot/ext/automod/triggers/user_banned.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Literal - -from commanderbot.ext.automod import events -from commanderbot.ext.automod.trigger import AutomodTrigger - -__all__ = ("UserBanned",) - - -class UserBanned(AutomodTrigger): - """ - Triggers when a user is banned. - """ - - type: Literal["user_banned"] - event_types = (events.UserBanned,) diff --git a/commanderbot/ext/automod/triggers/user_unbanned.py b/commanderbot/ext/automod/triggers/user_unbanned.py deleted file mode 100644 index 15f4fcd..0000000 --- a/commanderbot/ext/automod/triggers/user_unbanned.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Literal - -from commanderbot.ext.automod import events -from commanderbot.ext.automod.trigger import AutomodTrigger - -__all__ = ("UserUnbanned",) - - -class UserUnbanned(AutomodTrigger): - """ - Triggers when a user is unbanned. - """ - - type: Literal["user_unbanned"] - event_types = (events.UserUnbanned,) From 744e15bb47182c031cb48264eee6521a2370eea5 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Sat, 15 Aug 2026 04:50:33 -0700 Subject: [PATCH 130/158] Updated `.gitignore` --- .gitignore | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index f54764d..340ed8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,16 @@ -.pytest_cache/ -**/__pycache__ +.vscode/ +*.code-workspace + venv/ .venv/ -bot/ -dist/ +.ruff_cache/ +.pytest_cache/ +**/__pycache__/ *.egg-info/ -.vscode/ -*.code-workspace - +bot/ +dist/ .python-version -.venv - .env *.sh *.bat From 62442ec02ffcbcef492662dee2d27f274ea5fe2a Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 18 Aug 2026 22:06:17 -0700 Subject: [PATCH 131/158] Updated formatting --- commanderbot/ext/automod/ui/log.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/commanderbot/ext/automod/ui/log.py b/commanderbot/ext/automod/ui/log.py index e941ced..7c1c4dc 100644 --- a/commanderbot/ext/automod/ui/log.py +++ b/commanderbot/ext/automod/ui/log.py @@ -220,7 +220,7 @@ def __init__(self, log: LogChannel): self.add_item(container) container.add_item( - ui.TextDisplay(f"### 🧾 Details for default log channel <#{log.channel}>") + ui.TextDisplay(f"### 📘 Details for default log channel <#{log.channel}>") ) container.add_item(ui.Separator()) @@ -232,10 +232,10 @@ def __init__(self, log: LogChannel): "Allowed Mentions": "\n".join( ( "", - f"- Everyone: {'✅' if log.allowed_mentions.everyone else '❌'}", - f"- Users: {'✅' if log.allowed_mentions.users else '❌'}", - f"- Roles: {'✅' if log.allowed_mentions.roles else '❌'}", - f"- Replied User: {'✅' if log.allowed_mentions.replied_user else '❌'}", + f"- **Everyone**: {'✅' if log.allowed_mentions.everyone else '❌'}", + f"- **Users**: {'✅' if log.allowed_mentions.users else '❌'}", + f"- **Roles**: {'✅' if log.allowed_mentions.roles else '❌'}", + f"- **Replied User**: {'✅' if log.allowed_mentions.replied_user else '❌'}", ) ), } From 4d63c945a9e30f4d60a8fb1c884624f46f22968b Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 18 Aug 2026 22:06:39 -0700 Subject: [PATCH 132/158] Added triggers for timeouts --- commanderbot/ext/automod/trigger/types.py | 2 ++ commanderbot/ext/automod/triggers/__init__.py | 2 ++ .../automod/triggers/member_timeout_ended.py | 32 +++++++++++++++++++ .../triggers/member_timeout_started.py | 32 +++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 commanderbot/ext/automod/triggers/member_timeout_ended.py create mode 100644 commanderbot/ext/automod/triggers/member_timeout_started.py diff --git a/commanderbot/ext/automod/trigger/types.py b/commanderbot/ext/automod/trigger/types.py index b30c272..c0c8e29 100644 --- a/commanderbot/ext/automod/trigger/types.py +++ b/commanderbot/ext/automod/trigger/types.py @@ -15,6 +15,8 @@ | triggers.MemberJoined | triggers.MemberLeft | triggers.MemberLostFlags + | triggers.MemberTimeoutEnded + | triggers.MemberTimeoutStarted | triggers.MemberTyping | triggers.MemberUnbanned | triggers.MentionsRemovedFromMessage diff --git a/commanderbot/ext/automod/triggers/__init__.py b/commanderbot/ext/automod/triggers/__init__.py index 6ea09b0..468f820 100644 --- a/commanderbot/ext/automod/triggers/__init__.py +++ b/commanderbot/ext/automod/triggers/__init__.py @@ -6,6 +6,8 @@ from .member_joined import * from .member_left import * from .member_lost_flags import * +from .member_timeout_ended import * +from .member_timeout_started import * from .member_typing import * from .member_unbanned import * from .mentions_removed_from_message import * diff --git a/commanderbot/ext/automod/triggers/member_timeout_ended.py b/commanderbot/ext/automod/triggers/member_timeout_ended.py new file mode 100644 index 0000000..4f26b53 --- /dev/null +++ b/commanderbot/ext/automod/triggers/member_timeout_ended.py @@ -0,0 +1,32 @@ +from typing import Literal, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("MemberTimeoutEnded",) + + +class MemberTimeoutEnded(AutomodTrigger): + """ + Triggers when a member's timeout ends. + """ + + type: Literal["member_timeout_ended"] + event_types = (events.MemberUpdated,) + + @override + async def poll(self, context: AutomodContext) -> bool: + event = context.event + assert isinstance(event, events.MemberUpdated) + + timeout_before = event.before.timed_out_until + timeout_after = event.after.timed_out_until + + if timeout_before is None: + return False + + if timeout_after is not None: + return False + + return True diff --git a/commanderbot/ext/automod/triggers/member_timeout_started.py b/commanderbot/ext/automod/triggers/member_timeout_started.py new file mode 100644 index 0000000..b400627 --- /dev/null +++ b/commanderbot/ext/automod/triggers/member_timeout_started.py @@ -0,0 +1,32 @@ +from typing import Literal, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("MemberTimeoutStarted",) + + +class MemberTimeoutStarted(AutomodTrigger): + """ + Triggers when a member's timeout starts. + """ + + type: Literal["member_timeout_started"] + event_types = (events.MemberUpdated,) + + @override + async def poll(self, context: AutomodContext) -> bool: + event = context.event + assert isinstance(event, events.MemberUpdated) + + timeout_before = event.before.timed_out_until + timeout_after = event.after.timed_out_until + + if timeout_before is not None: + return False + + if timeout_after is None: + return False + + return True From bff1ef297a02c8dbd6edaee382f08faa289f7bcb Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 18 Aug 2026 22:07:42 -0700 Subject: [PATCH 133/158] Added autocomplete --- commanderbot/ext/automod/automod_cog.py | 123 ++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index 70ed5ae..bff05e5 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -21,6 +21,7 @@ Group, allowed_contexts, allowed_installs, + autocomplete, choices, command, default_permissions, @@ -36,6 +37,7 @@ from commanderbot.ext.automod.enums import BucketTypeChoices from commanderbot.ext.automod.rule import AutomodRule from commanderbot.lib.cogs import CogGuildStateManager +from commanderbot.lib.constants import MAX_AUTOCOMPLETE_CHOICES from commanderbot.lib.databases.json_db import JsonDB from commanderbot.lib.predicates import ( is_bot, @@ -77,6 +79,116 @@ def __init__(self, bot: Bot, **options): store=self.store, ) + # @@ AUTOCOMPLETE + + async def rule_autocomplete( + self, interaction: Interaction, value: str + ) -> list[Choice[str]]: + assert is_guild(interaction.guild) + guild: Guild = interaction.guild + value: str = value.lower() + rules: list[Choice[str]] = [] + async for rule in self.store.yield_rules(guild): + # Append rule if the query is in the rule's name + rule_name: str = rule.name.lower() + if value in rule_name: + rules.append(Choice(name=f"📜 {rule.name}", value=rule.name)) + + # Break out of loop if we've reached the max autocomplete choices + if len(rules) == MAX_AUTOCOMPLETE_CHOICES: + break + return rules + + async def enabled_rule_autocomplete( + self, interaction: Interaction, value: str + ) -> list[Choice[str]]: + assert is_guild(interaction.guild) + guild: Guild = interaction.guild + value: str = value.lower() + rules: list[Choice[str]] = [] + async for rule in self.store.yield_rules(guild): + # Append rule if it's enabled and the query is in the rule's name + rule_name: str = rule.name.lower() + if not rule.disabled and value in rule_name: + rules.append(Choice(name=f"📜 {rule.name}", value=rule.name)) + + # Break out of loop if we've reached the max autocomplete choices + if len(rules) == MAX_AUTOCOMPLETE_CHOICES: + break + return rules + + async def disabled_rule_autocomplete( + self, interaction: Interaction, value: str + ) -> list[Choice[str]]: + assert is_guild(interaction.guild) + guild: Guild = interaction.guild + value: str = value.lower() + rules: list[Choice[str]] = [] + async for rule in self.store.yield_rules(guild): + # Append rule if it's disabled and the query is in the rule's name + rule_name: str = rule.name.lower() + if rule.disabled and value in rule_name: + rules.append(Choice(name=f"📜 {rule.name}", value=rule.name)) + + # Break out of loop if we've reached the max autocomplete choices + if len(rules) == MAX_AUTOCOMPLETE_CHOICES: + break + return rules + + async def bucket_autocomplete( + self, interaction: Interaction, value: str + ) -> list[Choice[str]]: + assert is_guild(interaction.guild) + guild: Guild = interaction.guild + value: str = value.lower() + buckets: list[Choice[str]] = [] + async for bucket in self.store.yield_buckets(guild): + # Append bucket if the query is in the bucket's name + bucket_name: str = bucket.name.lower() + if value in bucket_name: + buckets.append(Choice(name=f"🪣 {bucket.name}", value=bucket.name)) + + # Break out of loop if we've reached the max autocomplete choices + if len(buckets) == MAX_AUTOCOMPLETE_CHOICES: + break + return buckets + + async def enabled_bucket_autocomplete( + self, interaction: Interaction, value: str + ) -> list[Choice[str]]: + assert is_guild(interaction.guild) + guild: Guild = interaction.guild + value: str = value.lower() + buckets: list[Choice[str]] = [] + async for bucket in self.store.yield_buckets(guild): + # Append bucket if the query is in the bucket's name + bucket_name: str = bucket.name.lower() + if not bucket.disabled and value in bucket_name: + buckets.append(Choice(name=f"🪣 {bucket.name}", value=bucket.name)) + + # Break out of loop if we've reached the max autocomplete choices + if len(buckets) == MAX_AUTOCOMPLETE_CHOICES: + break + return buckets + + async def disabled_bucket_autocomplete( + self, interaction: Interaction, value: str + ) -> list[Choice[str]]: + assert is_guild(interaction.guild) + guild: Guild = interaction.guild + value: str = value.lower() + buckets: list[Choice[str]] = [] + async for bucket in self.store.yield_buckets(guild): + # Append bucket if the query is in the bucket's name + bucket_name: str = bucket.name.lower() + if bucket.disabled and value in bucket_name: + buckets.append(Choice(name=f"🪣 {bucket.name}", value=bucket.name)) + + # Break out of loop if we've reached the max autocomplete choices + if len(buckets) == MAX_AUTOCOMPLETE_CHOICES: + break + return buckets + # @@ COMMANDS # @@ automod schema @@ -133,6 +245,7 @@ async def cmd_automod_rules_all(self, interaction: Interaction): # @@ automod rules modify @cmd_automod_rules.command(name="modify", description="Modify a rule") @describe(rule="The rule to modify") + @autocomplete(rule=rule_autocomplete) async def cmd_automod_rules_modify(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) await self.state[interaction.guild].modify_rule(interaction, rule) @@ -151,6 +264,7 @@ async def cmd_automod_rules_upload( # @@ automod rules remove @cmd_automod_rules.command(name="remove", description="Remove a rule") @describe(rule="The rule to remove") + @autocomplete(rule=rule_autocomplete) async def cmd_automod_rules_remove(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) await self.state[interaction.guild].remove_rule(interaction, rule) @@ -158,6 +272,7 @@ async def cmd_automod_rules_remove(self, interaction: Interaction, rule: str): # @@ automod rules details @cmd_automod_rules.command(name="details", description="Show details about a rule") @describe(rule="The rule to show details about") + @autocomplete(rule=rule_autocomplete) async def cmd_automod_rules_details(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) await self.state[interaction.guild].show_rule_details(interaction, rule) @@ -165,6 +280,7 @@ async def cmd_automod_rules_details(self, interaction: Interaction, rule: str): # @@ automod rules enable @cmd_automod_rules.command(name="enable", description="Enable a rule") @describe(rule="The rule to enable") + @autocomplete(rule=disabled_rule_autocomplete) async def cmd_automod_rules_enable(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) await self.state[interaction.guild].enable_rule(interaction, rule) @@ -172,6 +288,7 @@ async def cmd_automod_rules_enable(self, interaction: Interaction, rule: str): # @@ automod rules disable @cmd_automod_rules.command(name="disable", description="Disable a rule") @describe(rule="The rule to disable") + @autocomplete(rule=enabled_rule_autocomplete) async def cmd_automod_rules_disable(self, interaction: Interaction, rule: str): assert is_guild(interaction.guild) await self.state[interaction.guild].disable_rule(interaction, rule) @@ -211,6 +328,7 @@ async def cmd_automod_buckets_add( # @@ automod buckets modify @cmd_automod_buckets.command(name="modify", description="Modify a bucket") @describe(bucket="The bucket to modify") + @autocomplete(bucket=bucket_autocomplete) async def cmd_automod_buckets_modify(self, interaction: Interaction, bucket: str): assert is_guild(interaction.guild) await self.state[interaction.guild].modify_bucket(interaction, bucket) @@ -218,6 +336,7 @@ async def cmd_automod_buckets_modify(self, interaction: Interaction, bucket: str # @@ automod buckets remove @cmd_automod_buckets.command(name="remove", description="Remove a bucket") @describe(bucket="The bucket to remove") + @autocomplete(bucket=bucket_autocomplete) async def cmd_automod_buckets_remove(self, interaction: Interaction, bucket: str): assert is_guild(interaction.guild) await self.state[interaction.guild].remove_bucket(interaction, bucket) @@ -227,6 +346,7 @@ async def cmd_automod_buckets_remove(self, interaction: Interaction, bucket: str name="details", description="Show details about a bucket" ) @describe(bucket="The bucket to show details about") + @autocomplete(bucket=bucket_autocomplete) async def cmd_automod_buckets_details(self, interaction: Interaction, bucket: str): assert is_guild(interaction.guild) await self.state[interaction.guild].show_bucket_details(interaction, bucket) @@ -236,6 +356,7 @@ async def cmd_automod_buckets_details(self, interaction: Interaction, bucket: st name="clear", description="Clear the saved data in a bucket" ) @describe(bucket="The bucket to clear the saved data of") + @autocomplete(bucket=bucket_autocomplete) async def cmd_automod_buckets_clear(self, interaction: Interaction, bucket: str): assert is_guild(interaction.guild) await self.state[interaction.guild].clear_bucket(interaction, bucket) @@ -243,6 +364,7 @@ async def cmd_automod_buckets_clear(self, interaction: Interaction, bucket: str) # @@ automod buckets enable @cmd_automod_buckets.command(name="enable", description="Enable a bucket") @describe(bucket="The bucket to enable") + @autocomplete(bucket=disabled_bucket_autocomplete) async def cmd_automod_buckets_enable(self, interaction: Interaction, bucket: str): assert is_guild(interaction.guild) await self.state[interaction.guild].enable_bucket(interaction, bucket) @@ -250,6 +372,7 @@ async def cmd_automod_buckets_enable(self, interaction: Interaction, bucket: str # @@ automod buckets disable @cmd_automod_buckets.command(name="disable", description="Disable a bucket") @describe(bucket="The bucket to disable") + @autocomplete(bucket=enabled_bucket_autocomplete) async def cmd_automod_buckets_disable(self, interaction: Interaction, bucket: str): assert is_guild(interaction.guild) await self.state[interaction.guild].disable_bucket(interaction, bucket) From 0d0f198239826d239c54277b6cc12516e18669e8 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 18 Aug 2026 22:09:19 -0700 Subject: [PATCH 134/158] Updated dependencies --- pyproject.toml | 2 +- uv.lock | 56 +++++++++++++++++++++++++------------------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 451a336..eac676e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ commanderbot = "commanderbot:__main__" [dependency-groups] dev = [ "ruff>=0.16.0", - "ty>=0.0.72", + "ty>=0.0.70", ] [tool.ruff.lint] diff --git a/uv.lock b/uv.lock index f7c02d1..c8bece7 100644 --- a/uv.lock +++ b/uv.lock @@ -241,7 +241,7 @@ provides-extras = ["ocr"] [package.metadata.requires-dev] dev = [ { name = "ruff", specifier = ">=0.16.0" }, - { name = "ty", specifier = ">=0.0.72" }, + { name = "ty", specifier = ">=0.0.70" }, ] [[package]] @@ -323,11 +323,11 @@ wheels = [ [[package]] name = "idna" -version = "3.18" +version = "3.19" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -617,11 +617,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] @@ -779,27 +779,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.72" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" }, - { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" }, - { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" }, - { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" }, - { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" }, - { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" }, - { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" }, - { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" }, - { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" }, - { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" }, - { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" }, - { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" }, - { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" }, +version = "0.0.73" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/90/c4e1bb4cead3b644c3e258a27f9b05c7dc5eb0ec96a4f5282194edae9e0d/ty-0.0.73.tar.gz", hash = "sha256:823d4ce0d237bfc7eb6bcee70842f2c0706113813a16951077840743712f4b74", size = 6712739, upload-time = "2026-08-19T03:12:43.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/0f/f5e1801e55cc631f2db193276675b30561b963a2403da832bffb5d100267/ty-0.0.73-py3-none-linux_armv6l.whl", hash = "sha256:90a946082bf9bc446b5e72973d9f4ff1222a240b2ca4c9e6eed61eb913e30810", size = 12715452, upload-time = "2026-08-19T03:12:06.673Z" }, + { url = "https://files.pythonhosted.org/packages/54/32/515dd05074c213b433524ab97eb003b0132ae7e358e0d75633ba7a314ed8/ty-0.0.73-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b7d6b5c6a6db7ea95fbbc16af514ef44a27a29a2fe1dc798900790364d170209", size = 12301870, upload-time = "2026-08-19T03:12:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/50/4d/085b4889f0d4bbe4af8b96242d4a1cb209fff95967cfa239ea141983719b/ty-0.0.73-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6f657f463e01372d8688f235be164750c8db722c97da27fa4903aa8d40b203", size = 12111741, upload-time = "2026-08-19T03:12:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/95/f6/d6ec277cadfecf03ad4c18551b67c4c6eb7807a0560d801db14be99d7a89/ty-0.0.73-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2de468e33fd44c9ff1c43473a7316f4289480f5cba8995a67b6d22aee39ca9", size = 12196124, upload-time = "2026-08-19T03:12:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/75/b7/ce78d8707563af9cae9bbd25328bfbc4931035085bd20089adf0c418f70e/ty-0.0.73-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2942fa0ef795a66034cdc8d75a72f453442f3b58ff2f69b4da05b7b954765b55", size = 12488557, upload-time = "2026-08-19T03:12:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e8/329b9851b23502758c5c98e8cc875ea2a1b4c9674b4ca3a86da56a5063d3/ty-0.0.73-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e0f1ef14f642e18ac4e7a616a2796dcf7a5d82e28cd17f9796494acc7c4aabb", size = 13215606, upload-time = "2026-08-19T03:12:17.225Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/67fedfd2cb77516ef0066b1642f487dba0eb3006493cf3475b15f5b8b228/ty-0.0.73-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16981e15fdceedb37d0aff76c5ac25914595dfee2675af95335550064251ad22", size = 13665497, upload-time = "2026-08-19T03:12:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b3/154f4dd48ec5eebc186ab4b822c6e62f982fc5ddfd262d6e3903c2acba44/ty-0.0.73-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644b2bec8a2e2e4957a942ae81d6cff5571c489bb5a8675e4d3886de537a694d", size = 13351231, upload-time = "2026-08-19T03:12:21.353Z" }, + { url = "https://files.pythonhosted.org/packages/35/5f/d462496903fbe453fb76363f8478be929c8e6ff21e6928c57dcd7e5fa21f/ty-0.0.73-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338d565be3186f50ff8e9d10483685549c2d23f0754485d5ede3b54f4319188a", size = 12782586, upload-time = "2026-08-19T03:12:23.667Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/ec6d24b74abe3ec324204c1c71e6d0c6c76a17ffc15fd51d603b0a302abe/ty-0.0.73-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:11c7b6d839309d2c102cb3a4c03d817176bbfab5b2fccc95a75ec5c9597421c9", size = 13247134, upload-time = "2026-08-19T03:12:25.956Z" }, + { url = "https://files.pythonhosted.org/packages/26/20/cc74650fec56a54786c6d7c89e09576fcad3092be34cf21715d39a406a9b/ty-0.0.73-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:488572db7ff97fb50ea36a76250f2d617c9727d143da6c7bf0623276eb0fc507", size = 12309344, upload-time = "2026-08-19T03:12:28.122Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/4b0a9087f4315d7fbadf77a3ce44c816cc9ffabed1ced06cc5be81fbc414/ty-0.0.73-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1b958ebceefbbf594e59eb8d3d55bbd033ce634026fcba3e4bc3179e78e45bb7", size = 12502319, upload-time = "2026-08-19T03:12:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/11/80/0a925074911fe111912ea29d9eed309bcc183f43d2fb3eef07db056a0beb/ty-0.0.73-py3-none-musllinux_1_2_i686.whl", hash = "sha256:91a32993b3c34e42c3f323ad6c0399cb596bd1c27e9b7f20db7cd64c1067b68e", size = 12753688, upload-time = "2026-08-19T03:12:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/6b/aeccaf89efbc2e112bd415340a22e2669ec998aa397242503e747b712ca4/ty-0.0.73-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bab8a19fbf51f479bddb2a12c5fabfe52f918a5590362321ed5d89b44eb62c15", size = 13069050, upload-time = "2026-08-19T03:12:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3e/eae485fd86c1585943fd4e1746b0757b2da01e2c43136ebe8c686fe1c7f1/ty-0.0.73-py3-none-win32.whl", hash = "sha256:03347a612f0fa020b19bfd8dbd521db6ecc75d377a3e4d4f6e6c2e62871da4cc", size = 12053187, upload-time = "2026-08-19T03:12:37.565Z" }, + { url = "https://files.pythonhosted.org/packages/a7/01/9b8b983786e3ce34924e372e8b76b92b508273ab65c589fc7e88cc03ee17/ty-0.0.73-py3-none-win_amd64.whl", hash = "sha256:cedd05122ded0b5dcc55431a370e974b747f99c41c290a3d2ab8c1867f197519", size = 12693838, upload-time = "2026-08-19T03:12:39.483Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/25333bbfea6a5dc064371d2002d3d4807db90b84d5448f9106b2712b0fbc/ty-0.0.73-py3-none-win_arm64.whl", hash = "sha256:e47068f8369dea5d641a26a2ad0a947a320b02ff87099b07e95de0323245a4dc", size = 12443573, upload-time = "2026-08-19T03:12:41.449Z" }, ] [[package]] From d1579ccfd633a87332a47628d8f31c5dca85c4b1 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 00:40:27 -0700 Subject: [PATCH 135/158] Updated changelog --- CHANGELOG.md | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d01bc8..a7392d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,105 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Removed +## [0.23.0] + +### Added + +- Added a new type of database (`JsonDB`) that uses Pydantic models + - This should be used going forward + - The old database classes can be accessed from `commanderbot.lib.databases.v1` +- Added an optional dependency group for OCR (Optical Character Recognition) + - Uses [tesserocr](https://github.com/sirfz/tesserocr) + - This is optional because it requires quite a bit of setup (Especially on Windows) + - Query `SUPPORTS_OCR` in `commanderbot.lib.constants` to check if it was added + - If added, it will add extra capabilities to the following extensions: + - `automod` +- The following library classes now use Pydantic: + - `AllowedMentions` + - `Color` + - `Intents` +- `sudo`: + - Now supports exporting `JsonDB` databases + +### Changed + +- Updated discord.py to 2.7 +- The bot config now uses Pydantic +- `automod`: + - Completely rewritten from scratch to fix tons of issues that have piled up over the years + - Now uses slash commands + - Now uses components v2 + - Now uses `JsonDB` and Pydantic models + - Rule metadata now has it's own class instead of being combined with the rule + - Rules **must** be written in Json now (Sorry Arcensoth) + - You can now export the Json schema for rules + - This is useful for creating rules in a text editor + - Rules now receive an `AutomodContext` instead of an `AutomodEvent` + - This gives you access to: + - The bot + - The guild state that received the event + - The logger instance for that guild state + - The rule being ran + - The event + - Metadata now uses a dataclass instead of a `dict` + - Added support for caching attachment data + - String formatting using context data now uses `string.Template` + - Added support for buckets + - Closes ([Old repo #63](https://github.com/CommanderBot-Dev/commanderbot-py/pull/63)) + - These can be used to persist data between events + - Available bucket types: + - `flagged_image_attachments` + - `message_history` + - New triggers: + - `discord_automod_blocked_member_interactions` + - `discord_automod_blocked_message` + - `discord_automod_timed_out` + - `member_gained_flags` + - `member_lost_flags` + - `member_timeout_started` + - `member_timeout_ended` + - `message_frequency` + - New conditions: + - `actor_has_all_flags` + - `actor_has_any_flags` + - `author_has_all_flags` + - `author_has_any_flags` + - `has_flagged_image_attachments` + - `image_attachments_contain` + - `random_chance` + - `randomize` + - `return_false` + - `return_true` + - New actions: + - `add_to_bucket` + - `delete_actor_messages` + - `delete_author_messages` + - `randomize` + - `sequence` + - `timeout_actor` + - `timeout_author` + - `type_in_channel` + - New events: + - `DiscordAutomodBlockedMemberInteractions` + - `DiscordAutomodBlockedMessage` + - `DiscordAutomodTimedOut` + - `GuildChannelPinsUpdated` + - `MessageFrequency` + - New guards: + - `CategoriesGuard` + - `ChannelTypesGuard` + - `DiscordAutomodRulesGuard` + - `FlagsGuard` +- `help_forum`: + - Now uses `JsonDB` and Pydantic models (This is just a direct port with no optimizations) + +### Fixed + +### Removed + +- `help_chat`: + - It hasn't been used in ages and is pretty outdated now + ## [0.22.0] - 2026-01-06 ### Fixed @@ -410,7 +509,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Implemented `status` and `faq` extensions as an exercise for developing `commanderbot-lib` -[unreleased]: https://github.com/MinecraftCommands/commanderbot-py/compare/v0.22.0...HEAD +[unreleased]: https://github.com/MinecraftCommands/commanderbot-py/compare/v0.23.0...HEAD +[0.23.0]: https://github.com/MinecraftCommands/commanderbot-py/compare/v0.22.0...v0.23.0 [0.22.0]: https://github.com/MinecraftCommands/commanderbot-py/compare/v0.21.0...v0.22.0 [0.21.0]: https://github.com/MinecraftCommands/commanderbot-py/compare/v0.20.0...v0.21.0 [0.20.0]: https://github.com/MinecraftCommands/commanderbot-py/compare/v0.19.0...v0.20.0 From 0208add7386552519b1e77bf74650947669044f3 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 00:41:03 -0700 Subject: [PATCH 136/158] Global process pool now uses pebble --- commanderbot/core/commander_bot.py | 17 +++++++++++++++-- pyproject.toml | 2 ++ uv.lock | 11 +++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/commanderbot/core/commander_bot.py b/commanderbot/core/commander_bot.py index 727a9cf..918099e 100644 --- a/commanderbot/core/commander_bot.py +++ b/commanderbot/core/commander_bot.py @@ -1,10 +1,11 @@ import importlib.util +import os import sys -from concurrent.futures import ProcessPoolExecutor from datetime import datetime, timedelta from logging import Logger, getLogger from typing import Any, Optional, override +import pebble from discord import AppInfo, Asset, Attachment, User from discord.ext.commands import Bot, Cog, Context, ExtensionNotFound from discord.interactions import Interaction @@ -50,7 +51,10 @@ def __init__(self, config: Config, sync_tree_on_login: bool = False): self.application_emojis = ApplicationEmojiManager(self) # Create a process pool that anything can use. - self.pool = ProcessPoolExecutor() + # `max_workers` is the number of logical processors minus one. + cpu_count: int = os.cpu_count() or 0 + max_workers: int = max(1, cpu_count - 1) + self.pool = pebble.ProcessPool(max_workers) # Remember when we started and the last time we connected. self.started_at: datetime = utcnow() @@ -237,6 +241,15 @@ async def setup_hook(self): await self.command_tree.build_global_cache() await self.command_tree.build_guild_cache(self.guilds) + @override + async def close(self): + # Clean up process pool + self.pool.close() + self.pool.join() + + # Actually shut down the bot + await super().close() + # Event handler for `Bot` async def on_connect(self): self.log.warning("Connected to Discord.") diff --git a/pyproject.toml b/pyproject.toml index eac676e..222f60f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "python-dotenv>=1.0.0", "pydantic>=2.13.0", "aiohttp>=3.14.0", + "pebble>=5.2.0", # These are used for logging. "colorama>=0.4.0", @@ -68,6 +69,7 @@ ignore = [ "UP007", # https://docs.astral.sh/ruff/rules/non-pep604-annotation-union/ "E722", # https://docs.astral.sh/ruff/rules/bare-except/ "S110", # https://docs.astral.sh/ruff/rules/try-except-pass/ + "S112", # https://docs.astral.sh/ruff/rules/try-except-continue/ "BLE001", # https://docs.astral.sh/ruff/rules/blind-except/ "SIM102", # https://docs.astral.sh/ruff/rules/collapsible-if/ "SIM103", # https://docs.astral.sh/ruff/rules/needless-bool/ diff --git a/uv.lock b/uv.lock index c8bece7..7aa6d32 100644 --- a/uv.lock +++ b/uv.lock @@ -193,6 +193,7 @@ dependencies = [ { name = "mime-enum" }, { name = "numpy" }, { name = "opencv-python-headless" }, + { name = "pebble" }, { name = "pillow" }, { name = "psutil" }, { name = "pydantic" }, @@ -227,6 +228,7 @@ requires-dist = [ { name = "mime-enum", specifier = ">=0.0.3" }, { name = "numpy", specifier = ">=2.5.0" }, { name = "opencv-python-headless", specifier = ">=5.0.0.0" }, + { name = "pebble", specifier = ">=5.2.0" }, { name = "pillow", specifier = ">=12.3.0" }, { name = "psutil", specifier = ">=7.2.0" }, { name = "pydantic", specifier = ">=2.13.0" }, @@ -465,6 +467,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, ] +[[package]] +name = "pebble" +version = "5.2.1" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/6b/fba8cb6ca12e6bbcfac48da790b6a9d262d5beb6f34dc1b63fccc32a9ff8/pebble-5.2.1.tar.gz", hash = "sha256:9743537aa0e40751a1cbf297928e141bd2202a24624a7c0f9fc14818a7f227b1", size = 40496, upload-time = "2026-07-19T20:18:17.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/cf/f838e54b229bb202575daf7542d39da3c38434959d7a9252d4be66cb7d9a/pebble-5.2.1-py3-none-any.whl", hash = "sha256:154feb48f187ffff842cbc2fe0768ed1524d6fa72e90577a78bcea21a2f929f9", size = 34984, upload-time = "2026-07-19T20:18:15.692Z" }, +] + [[package]] name = "pillow" version = "12.3.0" From faa9bf97d639be1aaa47145e5c0d05dd5a415a66 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 00:41:57 -0700 Subject: [PATCH 137/158] Handled an edge case where the image couldn't be decoded --- commanderbot/ext/automod/conditions/ocr_utils/pipeline.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py index 6159261..632c83f 100644 --- a/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py +++ b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py @@ -26,7 +26,10 @@ def get_text( # Turn the bytes into a Numpy array buffer = np.frombuffer(image_data, np.uint8) image = cv2.imdecode(buffer, cv2.IMREAD_COLOR_RGB) - assert image is not None + + # Return `None` if we couldn't decode the image + if image is None: + return # Process the image to make the text stand out more processed = preprocess(image) From bf10c0356ae7456650ec2468d5388d66d0d14e18 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 00:42:31 -0700 Subject: [PATCH 138/158] Simplified phash calculation --- commanderbot/ext/automod/automod_context.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/commanderbot/ext/automod/automod_context.py b/commanderbot/ext/automod/automod_context.py index 07154b5..6eab970 100644 --- a/commanderbot/ext/automod/automod_context.py +++ b/commanderbot/ext/automod/automod_context.py @@ -1,3 +1,4 @@ +import io import string from collections import defaultdict from collections.abc import Iterable @@ -6,9 +7,7 @@ from logging import Logger from typing import TYPE_CHECKING, Any, Optional, cast -import cv2 import imagehash -import numpy as np from discord import Attachment, Member, User from discord.ext.commands import Bot from discord.utils import format_dt, utcnow @@ -155,12 +154,9 @@ async def _fetch_attachment( return (data, None) # Calculate phash - buffer = np.frombuffer(data, np.uint8) - image = cv2.imdecode(buffer, cv2.IMREAD_COLOR_RGB) - assert image is not None - - image = Image.fromarray(image) - phash = imagehash.phash(image) + phash: Optional[ImageHash] = None + with Image.open(io.BytesIO(data)) as image: + phash = imagehash.phash(image) # Store image attachment phash self.metadata.image_attachment_phashes[attachment.id] = phash From 88402808754eb56ae6042ec968ffb48a0d9fab73 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 00:43:05 -0700 Subject: [PATCH 139/158] handled an edge case where `phash` is `None` --- commanderbot/ext/automod/buckets/flagged_image_attachments.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/buckets/flagged_image_attachments.py b/commanderbot/ext/automod/buckets/flagged_image_attachments.py index 45dd16d..7347d1e 100644 --- a/commanderbot/ext/automod/buckets/flagged_image_attachments.py +++ b/commanderbot/ext/automod/buckets/flagged_image_attachments.py @@ -67,7 +67,8 @@ async def add(self, context: AutomodContext): flagged_at = utcnow() for image_attachment, _, phash in image_attachments: # The image attachment needs to have a phash - assert phash is not None + if phash is None: + continue # Skip image attachments that weren't flagged if image_attachment.id not in context.metadata.flagged_attachments: From f645322d17a48f1aa21bc19e78a3cb3687bff8b8 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 00:52:49 -0700 Subject: [PATCH 140/158] Improved the code that submits tasks to the global process pool --- .../conditions/image_attachments_contain.py | 87 +++++++++++++++---- 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py index 8427336..f05f96e 100644 --- a/commanderbot/ext/automod/conditions/image_attachments_contain.py +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -1,9 +1,11 @@ import asyncio import unicodedata from itertools import chain -from typing import Any, Literal, Optional, cast, override +from logging import Logger +from typing import Any, ClassVar, Literal, Optional, cast, override -from pydantic import Field, PositiveInt +import pebble +from pydantic import Field, PositiveFloat, PositiveInt from commanderbot.core.utils import is_commander_bot from commanderbot.ext.automod.automod_context import AutomodContext @@ -23,6 +25,8 @@ if SUPPORTS_OCR: from commanderbot.ext.automod.conditions.ocr_utils.pipeline import get_text +MAX_OCR_WORKERS: int = 4 + class ImageAttachmentsContain(AutomodCondition): """ @@ -65,9 +69,18 @@ class ImageAttachmentsContain(AutomodCondition): normalization_form: UnicodeNormalizationForms = Field(default="NFKD") """If enabled, the type of normalization to apply. Defaults to NFKD.""" - timeout: PositiveInt = 10 + timeout: PositiveFloat = 10 """The max amount of time in seconds to wait for OCR results.""" + _semaphore: ClassVar[Optional[asyncio.Semaphore]] = None + """Used to limit how many OCR workers get sumbitted to the pool.""" + + @classmethod + def get_semaphore(cls) -> asyncio.Semaphore: + if cls._semaphore is None: + cls._semaphore = asyncio.Semaphore(MAX_OCR_WORKERS) + return cls._semaphore + @override def model_post_init(self, context: Any): if self.ignore_case: @@ -91,6 +104,40 @@ def _check_ocr_result(self, content: str) -> bool: return False + async def _ocr_worker( + self, + pool: pebble.ProcessPool, + log: Logger, + attachment_id: AttachmentID, + data: bytes, + lang: str, + ) -> Optional[tuple[str, AttachmentID]]: + future: Optional[pebble.ProcessFuture] = None + try: + async with self.get_semaphore(): + future = pool.schedule( + get_text, args=[data, lang, attachment_id], timeout=self.timeout + ) + log.debug( + f"OCR worker for attachment '{attachment_id}' was submitted to the pool" + ) + result = await asyncio.wrap_future(future) + log.debug(f"OCR worker for attachment '{attachment_id}' is done") + return result + except TimeoutError: + log.warning(f"OCR worker for attachment '{attachment_id}' timed out") + return + except asyncio.CancelledError: + if future and not future.done(): + future.cancel() + log.debug(f"OCR worker for attachment '{attachment_id}' was cancelled") + raise + except Exception: + log.exception( + f"OCR worker for attachment '{attachment_id}' ran into an error" + ) + return + async def _ocr(self, context: AutomodContext) -> Optional[AttachmentID]: # We need image attachments in context image_attachments = await context.fetch_attachments_with_type(*IMAGE_MIME_TYPES) @@ -103,28 +150,32 @@ async def _ocr(self, context: AutomodContext) -> Optional[AttachmentID]: # Submit OCR tasks to the process pool assert is_commander_bot(context.bot) pool = context.bot.pool - loop = asyncio.get_running_loop() + log = context.log tasks = [ - loop.run_in_executor(pool, get_text, data, lang, attachment.id) - for (attachment, data, _) in image_attachments + asyncio.create_task(self._ocr_worker(pool, log, attachment.id, data, lang)) + for attachment, data, _ in image_attachments ] # Check OCR results as they come in # This may raise a `TimeoutError`, but it *should* be caught in the guild state try: - async for completed_task in asyncio.as_completed( - tasks, timeout=self.timeout - ): - # Check if the text passes the condition and return the attachment - if result := completed_task.result(): - assert isinstance(result, tuple) - text, attachment_id = result - if self._check_ocr_result(text): - return attachment_id + async for completed_task in asyncio.as_completed(tasks): + try: + # Check if the text passes the condition and return the attachment ID + if task_result := await completed_task: + text, attachment_id = task_result + if self._check_ocr_result(text): + return attachment_id + except Exception: + continue finally: - for task in tasks: - if not task.done(): - task.cancel() + # Cancel any remaining tasks + for t in tasks: + if not t.done(): + t.cancel() + + # Wait for those tasks to be cleaned up + await asyncio.gather(*tasks, return_exceptions=True) @override async def check(self, context: AutomodContext) -> bool: From 9e0422a9effd64cc0ca84af0fcc91d30ce406be8 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 00:59:57 -0700 Subject: [PATCH 141/158] Removed unused dependencies --- commanderbot/lib/utils/__init__.py | 1 - commanderbot/lib/utils/json_path.py | 68 ----------------------------- 2 files changed, 69 deletions(-) delete mode 100644 commanderbot/lib/utils/json_path.py diff --git a/commanderbot/lib/utils/__init__.py b/commanderbot/lib/utils/__init__.py index 7fef7e4..7b47157 100644 --- a/commanderbot/lib/utils/__init__.py +++ b/commanderbot/lib/utils/__init__.py @@ -1,5 +1,4 @@ from .channels import * from .datetimes import * -from .json_path import * from .timedeltas import * from .utils import * diff --git a/commanderbot/lib/utils/json_path.py b/commanderbot/lib/utils/json_path.py deleted file mode 100644 index ea02d04..0000000 --- a/commanderbot/lib/utils/json_path.py +++ /dev/null @@ -1,68 +0,0 @@ -from enum import Enum -from typing import Any, cast - -from jsonpath_ng import JSONPath as JsonPath -from jsonpath_ng import parse - -from commanderbot.lib.exceptions import ResponsiveException - -__all__ = ( - "JsonPath", - "JsonPathOp", - "parse_json_path", - "parse_json_path_op", - "query_json_path", - "update_json_with_path", -) - - -class JsonPathOp(Enum): - set = "set" - merge = "merge" - append = "append" - prepend = "prepend" - - -def parse_json_path_op(op: str) -> JsonPathOp: - try: - return JsonPathOp[op] - except: - raise ResponsiveException(f"No such operation: `{op}`") - - -def parse_json_path(path: str) -> JsonPath: - try: - return cast(JsonPath, parse(path)) - except: - raise ResponsiveException(f"Malformed JSON path: `{path}`") - - -def query_json_path(target: Any, path: JsonPath) -> Any: - nodes = list(path.find(target)) - if not nodes: - raise ResponsiveException(f"No such value: `{path}`") - if len(nodes) == 1: - return nodes[0].value - values = [node.value for node in nodes] - return values - - -def update_json_with_path(target: Any, path: JsonPath, op: JsonPathOp, value: Any): - if op == JsonPathOp.set: - path.update_or_create(target, value) - elif op == JsonPathOp.merge: - if not isinstance(value, dict): - raise ValueError(f"Expected `dict`, got `{type(target).__name__}`") - for node in path.find_or_create(target): - if isinstance(node.value, dict): - node.value.update(value) - elif op == JsonPathOp.append: - for node in path.find_or_create(target): - if isinstance(node.value, list): - node.value.append(value) - elif op == JsonPathOp.prepend: - for node in path.find_or_create(target): - if isinstance(node.value, list): - node.value.insert(0, value) - else: - raise ResponsiveException(f"Unsupported operation: `{op.value}`") From f44d05f66f9c9949e355ac04333d5f086507d5c5 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 01:00:22 -0700 Subject: [PATCH 142/158] Removed unused dependencies --- pyproject.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 222f60f..402d00b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,10 +28,6 @@ dependencies = [ "colorama>=0.4.0", "colorlog>=6.11.0", - # These are used for handling text-based data (json, yaml, etc). - "jsonpath-ng>=1.8.0", - "pyyaml>=6.0.0", - # The rest are for various extensions. "emoji>=2.14.0", "allay>=1.3.0", From 615e7b7095ff5c490928840540bc23cceac776cd Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 01:03:40 -0700 Subject: [PATCH 143/158] Updated `uv.lock` --- uv.lock | 81 +++++++++++++++------------------------------------------ 1 file changed, 21 insertions(+), 60 deletions(-) diff --git a/uv.lock b/uv.lock index 7aa6d32..995ab05 100644 --- a/uv.lock +++ b/uv.lock @@ -188,7 +188,6 @@ dependencies = [ { name = "discord-py" }, { name = "emoji" }, { name = "imagehash" }, - { name = "jsonpath-ng" }, { name = "mccq" }, { name = "mime-enum" }, { name = "numpy" }, @@ -198,7 +197,6 @@ dependencies = [ { name = "psutil" }, { name = "pydantic" }, { name = "python-dotenv" }, - { name = "pyyaml" }, { name = "types-psutil" }, ] @@ -223,7 +221,6 @@ requires-dist = [ { name = "discord-py", specifier = ">=2.7.0" }, { name = "emoji", specifier = ">=2.14.0" }, { name = "imagehash", specifier = ">=4.3.0" }, - { name = "jsonpath-ng", specifier = ">=1.8.0" }, { name = "mccq", specifier = ">=1.0.0" }, { name = "mime-enum", specifier = ">=0.0.3" }, { name = "numpy", specifier = ">=2.5.0" }, @@ -233,7 +230,6 @@ requires-dist = [ { name = "psutil", specifier = ">=7.2.0" }, { name = "pydantic", specifier = ">=2.13.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "pyyaml", specifier = ">=6.0.0" }, { name = "tesserocr", marker = "sys_platform == 'win32' and extra == 'ocr'", url = "https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.10.0-tesseract-5.5.2/tesserocr-2.10.0-cp314-cp314-win_amd64.whl" }, { name = "tesserocr", marker = "sys_platform != 'win32' and extra == 'ocr'", specifier = ">=2.10.0" }, { name = "types-psutil", specifier = ">=7.2.0" }, @@ -347,15 +343,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/2c/5f0903a53a62029875aaa3884c38070cc388248a2c1b9aa935632669e5a7/ImageHash-4.3.2-py2.py3-none-any.whl", hash = "sha256:02b0f965f8c77cd813f61d7d39031ea27d4780e7ebcad56c6cd6a709acc06e5f", size = 296657, upload-time = "2025-02-01T08:45:36.102Z" }, ] -[[package]] -name = "jsonpath-ng" -version = "1.8.0" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/32/58/250751940d75c8019659e15482d548a4aa3b6ce122c515102a4bfdac50e3/jsonpath_ng-1.8.0.tar.gz", hash = "sha256:54252968134b5e549ea5b872f1df1168bd7defe1a52fed5a358c194e1943ddc3", size = 74513, upload-time = "2026-02-24T14:42:06.182Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/99/33c7d78a3fb70d545fd5411ac67a651c81602cc09c9cf0df383733f068c5/jsonpath_ng-1.8.0-py3-none-any.whl", hash = "sha256:b8dde192f8af58d646fc031fac9c99fe4d00326afc4148f1f043c601a8cfe138", size = 67844, upload-time = "2026-02-28T00:53:19.637Z" }, -] - [[package]] name = "mccq" version = "1.0.2" @@ -662,55 +649,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/d2/a8065103f5e2e613b916489e6c85af6402a1ec64f346d1429e2d32cb8d03/pywavelets-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3b6ff6ba4f625d8c955f68c2c39b0a913776d406ab31ee4057f34ad4019fb33b", size = 4306793, upload-time = "2025-08-04T16:20:02.934Z" }, ] -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - [[package]] name = "ruff" -version = "0.16.3" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +version = "0.16.4" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, ] [[package]] From 5faf26b47557cc8c967d5e324e3365b9bead136b Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 01:03:47 -0700 Subject: [PATCH 144/158] Added missing comment --- commanderbot/core/commander_bot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/commanderbot/core/commander_bot.py b/commanderbot/core/commander_bot.py index 918099e..2b57e00 100644 --- a/commanderbot/core/commander_bot.py +++ b/commanderbot/core/commander_bot.py @@ -52,6 +52,7 @@ def __init__(self, config: Config, sync_tree_on_login: bool = False): # Create a process pool that anything can use. # `max_workers` is the number of logical processors minus one. + # The minimum number of workers is `1`. cpu_count: int = os.cpu_count() or 0 max_workers: int = max(1, cpu_count - 1) self.pool = pebble.ProcessPool(max_workers) From 21242e829c75fc2a6415af19ca01efdbb1c71698 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 01:22:11 -0700 Subject: [PATCH 145/158] Simplified logic that detect timeouts --- .../ext/automod/triggers/member_timeout_ended.py | 10 +++------- .../ext/automod/triggers/member_timeout_started.py | 10 +++------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/commanderbot/ext/automod/triggers/member_timeout_ended.py b/commanderbot/ext/automod/triggers/member_timeout_ended.py index 4f26b53..1d52de1 100644 --- a/commanderbot/ext/automod/triggers/member_timeout_ended.py +++ b/commanderbot/ext/automod/triggers/member_timeout_ended.py @@ -23,10 +23,6 @@ async def poll(self, context: AutomodContext) -> bool: timeout_before = event.before.timed_out_until timeout_after = event.after.timed_out_until - if timeout_before is None: - return False - - if timeout_after is not None: - return False - - return True + if timeout_before is not None and timeout_after is None: + return True + return False diff --git a/commanderbot/ext/automod/triggers/member_timeout_started.py b/commanderbot/ext/automod/triggers/member_timeout_started.py index b400627..97b518f 100644 --- a/commanderbot/ext/automod/triggers/member_timeout_started.py +++ b/commanderbot/ext/automod/triggers/member_timeout_started.py @@ -23,10 +23,6 @@ async def poll(self, context: AutomodContext) -> bool: timeout_before = event.before.timed_out_until timeout_after = event.after.timed_out_until - if timeout_before is not None: - return False - - if timeout_after is None: - return False - - return True + if timeout_before is None and timeout_after is not None: + return True + return False From dec39187af5a6329e498d5dc70553a6466988e5c Mon Sep 17 00:00:00 2001 From: Ersatz Date: Fri, 21 Aug 2026 01:38:22 -0700 Subject: [PATCH 146/158] Updated changelog --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7392d1..5931738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,10 +27,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Query `SUPPORTS_OCR` in `commanderbot.lib.constants` to check if it was added - If added, it will add extra capabilities to the following extensions: - `automod` -- The following library classes now use Pydantic: +- The following library classes now support Pydantic: - `AllowedMentions` - `Color` + - `ChannelType` - `Intents` + - `ImageHash` + - `Timedelta` - `sudo`: - Now supports exporting `JsonDB` databases @@ -108,6 +111,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- `mcdoc`: + - Fixed an issue where application emojis weren't being used + ### Removed - `help_chat`: From 8193788f1a2a7724b7871719401f6b7419fff15c Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 02:31:10 -0700 Subject: [PATCH 147/158] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5931738..8dfffee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - `Timedelta` - `sudo`: - Now supports exporting `JsonDB` databases +- Added a process pool to `CommanderBot` that any extension can use ### Changed From 1f4ad54d46e5bd278efa022b28ad1e4a91b52d4b Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 02:31:30 -0700 Subject: [PATCH 148/158] Updated timeout logic --- .../ext/automod/triggers/member_timeout_ended.py | 10 +++++----- .../ext/automod/triggers/member_timeout_started.py | 8 +++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/commanderbot/ext/automod/triggers/member_timeout_ended.py b/commanderbot/ext/automod/triggers/member_timeout_ended.py index 1d52de1..7c13bc6 100644 --- a/commanderbot/ext/automod/triggers/member_timeout_ended.py +++ b/commanderbot/ext/automod/triggers/member_timeout_ended.py @@ -10,6 +10,8 @@ class MemberTimeoutEnded(AutomodTrigger): """ Triggers when a member's timeout ends. + + Due to a Discord limitation, this will not trigger when a member's timeout expires. """ type: Literal["member_timeout_ended"] @@ -20,9 +22,7 @@ async def poll(self, context: AutomodContext) -> bool: event = context.event assert isinstance(event, events.MemberUpdated) - timeout_before = event.before.timed_out_until - timeout_after = event.after.timed_out_until + timeout_before = event.before.is_timed_out() + timeout_after = event.after.is_timed_out() - if timeout_before is not None and timeout_after is None: - return True - return False + return timeout_before and not timeout_after diff --git a/commanderbot/ext/automod/triggers/member_timeout_started.py b/commanderbot/ext/automod/triggers/member_timeout_started.py index 97b518f..fe4b537 100644 --- a/commanderbot/ext/automod/triggers/member_timeout_started.py +++ b/commanderbot/ext/automod/triggers/member_timeout_started.py @@ -20,9 +20,7 @@ async def poll(self, context: AutomodContext) -> bool: event = context.event assert isinstance(event, events.MemberUpdated) - timeout_before = event.before.timed_out_until - timeout_after = event.after.timed_out_until + timeout_before = event.before.is_timed_out() + timeout_after = event.after.is_timed_out() - if timeout_before is None and timeout_after is not None: - return True - return False + return not timeout_before and timeout_after From 477998133b4508360168ded6741db3db5d5ba4ab Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 02:32:01 -0700 Subject: [PATCH 149/158] `ReactionsGuard` can now filter an interable of reactions --- commanderbot/ext/automod/guards/reactions.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/commanderbot/ext/automod/guards/reactions.py b/commanderbot/ext/automod/guards/reactions.py index 1adf5c6..eb82c82 100644 --- a/commanderbot/ext/automod/guards/reactions.py +++ b/commanderbot/ext/automod/guards/reactions.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable from typing import Optional from discord import Reaction @@ -49,3 +50,18 @@ def ignore(self, reaction: Optional[Reaction]) -> bool: or self._ignore_by_excludes(reaction) or self._ignore_by_count(reaction) ) + + def reaction_matches(self, reaction: Reaction) -> bool: + if self.count and self.count.excludes(reaction.count): + return False + + reaction_emoji = str(reaction.emoji) + if self.include: + return reaction_emoji in self.include + if self.exclude: + return reaction_emoji not in self.exclude + return True + + def filter_reactions(self, reactions: Iterable[Reaction]) -> list[Reaction]: + """Filter a sequence of reactions based on the guard.""" + return [reaction for reaction in reactions if self.reaction_matches(reaction)] From ce2948cd8b90d55bc08b979171b8827dc331b8db Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 02:33:23 -0700 Subject: [PATCH 150/158] Changed the max number of OCR workers to the max number of attachments --- .../ext/automod/conditions/image_attachments_contain.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/commanderbot/ext/automod/conditions/image_attachments_contain.py b/commanderbot/ext/automod/conditions/image_attachments_contain.py index f05f96e..b3f1107 100644 --- a/commanderbot/ext/automod/conditions/image_attachments_contain.py +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -12,7 +12,7 @@ from commanderbot.ext.automod.automod_exceptions import OCRNotSupported from commanderbot.ext.automod.condition import AutomodCondition from commanderbot.ext.automod.constants import IMAGE_MIME_TYPES -from commanderbot.lib.constants import SUPPORTS_OCR +from commanderbot.lib.constants import MAX_ATTACHMENTS, SUPPORTS_OCR from commanderbot.lib.types import ( AttachmentID, TesseractLanguages, @@ -25,8 +25,6 @@ if SUPPORTS_OCR: from commanderbot.ext.automod.conditions.ocr_utils.pipeline import get_text -MAX_OCR_WORKERS: int = 4 - class ImageAttachmentsContain(AutomodCondition): """ @@ -78,7 +76,7 @@ class ImageAttachmentsContain(AutomodCondition): @classmethod def get_semaphore(cls) -> asyncio.Semaphore: if cls._semaphore is None: - cls._semaphore = asyncio.Semaphore(MAX_OCR_WORKERS) + cls._semaphore = asyncio.Semaphore(MAX_ATTACHMENTS) return cls._semaphore @override From 76b1e2f3a8f7e0ed3c8618f5e92d914f29831c82 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 02:33:39 -0700 Subject: [PATCH 151/158] Added a constant for the max number of attachments --- commanderbot/lib/constants.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/commanderbot/lib/constants.py b/commanderbot/lib/constants.py index 563f8cf..cba7827 100644 --- a/commanderbot/lib/constants.py +++ b/commanderbot/lib/constants.py @@ -9,6 +9,7 @@ "COMMANDERBOT_VERSION", "DISCORD_PY_VERSION", "MAX_APPLICATION_EMOJIS", + "MAX_ATTACHMENTS", "MAX_AUTOCOMPLETE_CHOICES", "MAX_EMBED_DESCRIPTION_LENGTH", "MAX_EMBED_TITLE_LENGTH", @@ -45,6 +46,8 @@ MAX_EMBED_TITLE_LENGTH: int = 256 MAX_EMBED_DESCRIPTION_LENGTH: int = 4096 +MAX_ATTACHMENTS: int = 10 + MAX_MODAL_TITLE_LENGTH: int = 45 MAX_AUTOCOMPLETE_CHOICES: int = 25 From 8f1b7357f141eddc20577a8f4144f80d2d404130 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 02:34:48 -0700 Subject: [PATCH 152/158] Removed `none_of` condition. Use `inverted` with `all_of` or `any_of` --- .../ext/automod/conditions/none_of.py | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 commanderbot/ext/automod/conditions/none_of.py diff --git a/commanderbot/ext/automod/conditions/none_of.py b/commanderbot/ext/automod/conditions/none_of.py deleted file mode 100644 index 13cf7de..0000000 --- a/commanderbot/ext/automod/conditions/none_of.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import TYPE_CHECKING, Literal, override - -from pydantic import Field - -from commanderbot.ext.automod.automod_context import AutomodContext -from commanderbot.ext.automod.condition import AutomodCondition - -__all__ = ("NoneOf",) - -if TYPE_CHECKING: - from commanderbot.ext.automod.condition import AutomodConditionType - - -class NoneOf(AutomodCondition): - """ - Check if no sub-conditions pass. - """ - - type: Literal["none_of"] - - conditions: list[AutomodConditionType] = Field(min_length=1) - """The sub-conditions to check.""" - - @override - async def check(self, context: AutomodContext) -> bool: - for condition in self.conditions: - if condition.disabled: - continue - if await condition.check(context): - return False - return True From 42da7873688fbe98de137bfb001a923523781553 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 02:35:25 -0700 Subject: [PATCH 153/158] Forgot to change a literal --- commanderbot/ext/automod/actions/type_in_channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/actions/type_in_channel.py b/commanderbot/ext/automod/actions/type_in_channel.py index 87f5ce6..7a2ba0d 100644 --- a/commanderbot/ext/automod/actions/type_in_channel.py +++ b/commanderbot/ext/automod/actions/type_in_channel.py @@ -17,7 +17,7 @@ class TypeInChannel(AutomodAction): Make the bot type in the channel in context for a certain amount of time. """ - type: Literal["start_typing"] + type: Literal["type_in_channel"] min_time: PositiveFloat = Field(default=1) """The minimum amount of time to type for, in seconds. Defaults to 1 second.""" From 6b5219efae7d2e1cd9e866077a7c6e46cfeca638 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 02:36:08 -0700 Subject: [PATCH 154/158] Added a condition that checks if a message has reactions --- commanderbot/ext/automod/condition/types.py | 2 +- .../ext/automod/conditions/__init__.py | 2 +- .../conditions/message_has_reactions.py | 33 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 commanderbot/ext/automod/conditions/message_has_reactions.py diff --git a/commanderbot/ext/automod/condition/types.py b/commanderbot/ext/automod/condition/types.py index d27b792..a628716 100644 --- a/commanderbot/ext/automod/condition/types.py +++ b/commanderbot/ext/automod/condition/types.py @@ -34,9 +34,9 @@ | conditions.MessageHasEmbeds | conditions.MessageHasLinks | conditions.MessageHasMentions + | conditions.MessageHasReactions | conditions.MessageMentionsRoles | conditions.MessageMentionsUsers - | conditions.NoneOf | conditions.RandomChance | conditions.Randomize | conditions.ReturnFalse diff --git a/commanderbot/ext/automod/conditions/__init__.py b/commanderbot/ext/automod/conditions/__init__.py index 3fcc8f3..234be90 100644 --- a/commanderbot/ext/automod/conditions/__init__.py +++ b/commanderbot/ext/automod/conditions/__init__.py @@ -25,9 +25,9 @@ from .message_has_embeds import * from .message_has_links import * from .message_has_mentions import * +from .message_has_reactions import * from .message_mentions_roles import * from .message_mentions_users import * -from .none_of import * from .random_chance import * from .randomize import * from .return_false import * diff --git a/commanderbot/ext/automod/conditions/message_has_reactions.py b/commanderbot/ext/automod/conditions/message_has_reactions.py new file mode 100644 index 0000000..1c09a57 --- /dev/null +++ b/commanderbot/ext/automod/conditions/message_has_reactions.py @@ -0,0 +1,33 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.condition import AutomodCondition +from commanderbot.ext.automod.guards import ReactionsGuard + +__all__ = ("MessageHasReactions",) + + +class MessageHasReactions(AutomodCondition): + """ + Check if the message has reactions. + """ + + type: Literal["message_has_reactions"] + + reactions: Optional[ReactionsGuard] = None + """The reactions to match against. If empty, all reactions will match.""" + + @override + async def check(self, context: AutomodContext) -> bool: + # We need a message in context + message = context.event.message + if not message: + return False + + # Get the reactions from the message and maybe filter out the ones we care about + reactions = message.reactions + if self.reactions: + reactions = self.reactions.filter_reactions(reactions) + + # Check if we have any reactions + return len(reactions) > 0 From 375079a48626afc52dd5a40f7aa6676fd38fd810 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 02:50:29 -0700 Subject: [PATCH 155/158] Added `max_pool_workers` to `CommanderBot` --- commanderbot/core/commander_bot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/commanderbot/core/commander_bot.py b/commanderbot/core/commander_bot.py index 2b57e00..39d95b7 100644 --- a/commanderbot/core/commander_bot.py +++ b/commanderbot/core/commander_bot.py @@ -1,11 +1,11 @@ import importlib.util -import os import sys from datetime import datetime, timedelta from logging import Logger, getLogger from typing import Any, Optional, override import pebble +import psutil from discord import AppInfo, Asset, Attachment, User from discord.ext.commands import Bot, Cog, Context, ExtensionNotFound from discord.interactions import Interaction @@ -53,9 +53,9 @@ def __init__(self, config: Config, sync_tree_on_login: bool = False): # Create a process pool that anything can use. # `max_workers` is the number of logical processors minus one. # The minimum number of workers is `1`. - cpu_count: int = os.cpu_count() or 0 - max_workers: int = max(1, cpu_count - 1) - self.pool = pebble.ProcessPool(max_workers) + cpu_count: int = psutil.cpu_count() or 0 + self.max_pool_workers: int = max(1, cpu_count - 1) + self.pool = pebble.ProcessPool(self.max_pool_workers) # Remember when we started and the last time we connected. self.started_at: datetime = utcnow() From 8a1f7c481d7b86904d20d84fed81aa2a617c4cc9 Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 02:50:38 -0700 Subject: [PATCH 156/158] Updated dependencies --- pyproject.toml | 9 ++---- uv.lock | 88 +++++++++++++++++++++++++------------------------- 2 files changed, 47 insertions(+), 50 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 402d00b..dce3f9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ dependencies = [ "pydantic>=2.13.0", "aiohttp>=3.14.0", "pebble>=5.2.0", + "psutil>=7.2.0", + "types-psutil>=7.2.0", # These are used for logging. "colorama>=0.4.0", @@ -32,8 +34,6 @@ dependencies = [ "emoji>=2.14.0", "allay>=1.3.0", "mccq>=1.0.0", - "psutil>=7.2.0", - "types-psutil>=7.2.0", "numpy>=2.5.0", "opencv-python-headless>=5.0.0.0", "pillow>=12.3.0", @@ -53,10 +53,7 @@ Changelog = "https://github.com/MinecraftCommands/commanderbot-py/blob/main/CHAN commanderbot = "commanderbot:__main__" [dependency-groups] -dev = [ - "ruff>=0.16.0", - "ty>=0.0.70", -] +dev = ["ruff>=0.16.0", "ty>=0.0.70"] [tool.ruff.lint] ignore = [ diff --git a/uv.lock b/uv.lock index 995ab05..51d7e91 100644 --- a/uv.lock +++ b/uv.lock @@ -676,33 +676,33 @@ wheels = [ [[package]] name = "scipy" -version = "1.18.0" +version = "1.18.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, - { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, - { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, - { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, - { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, - { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, - { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, - { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, - { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, ] [[package]] @@ -751,27 +751,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.73" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/90/c4e1bb4cead3b644c3e258a27f9b05c7dc5eb0ec96a4f5282194edae9e0d/ty-0.0.73.tar.gz", hash = "sha256:823d4ce0d237bfc7eb6bcee70842f2c0706113813a16951077840743712f4b74", size = 6712739, upload-time = "2026-08-19T03:12:43.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/0f/f5e1801e55cc631f2db193276675b30561b963a2403da832bffb5d100267/ty-0.0.73-py3-none-linux_armv6l.whl", hash = "sha256:90a946082bf9bc446b5e72973d9f4ff1222a240b2ca4c9e6eed61eb913e30810", size = 12715452, upload-time = "2026-08-19T03:12:06.673Z" }, - { url = "https://files.pythonhosted.org/packages/54/32/515dd05074c213b433524ab97eb003b0132ae7e358e0d75633ba7a314ed8/ty-0.0.73-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b7d6b5c6a6db7ea95fbbc16af514ef44a27a29a2fe1dc798900790364d170209", size = 12301870, upload-time = "2026-08-19T03:12:08.924Z" }, - { url = "https://files.pythonhosted.org/packages/50/4d/085b4889f0d4bbe4af8b96242d4a1cb209fff95967cfa239ea141983719b/ty-0.0.73-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6f657f463e01372d8688f235be164750c8db722c97da27fa4903aa8d40b203", size = 12111741, upload-time = "2026-08-19T03:12:11.067Z" }, - { url = "https://files.pythonhosted.org/packages/95/f6/d6ec277cadfecf03ad4c18551b67c4c6eb7807a0560d801db14be99d7a89/ty-0.0.73-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2de468e33fd44c9ff1c43473a7316f4289480f5cba8995a67b6d22aee39ca9", size = 12196124, upload-time = "2026-08-19T03:12:13.14Z" }, - { url = "https://files.pythonhosted.org/packages/75/b7/ce78d8707563af9cae9bbd25328bfbc4931035085bd20089adf0c418f70e/ty-0.0.73-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2942fa0ef795a66034cdc8d75a72f453442f3b58ff2f69b4da05b7b954765b55", size = 12488557, upload-time = "2026-08-19T03:12:15.252Z" }, - { url = "https://files.pythonhosted.org/packages/d8/e8/329b9851b23502758c5c98e8cc875ea2a1b4c9674b4ca3a86da56a5063d3/ty-0.0.73-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e0f1ef14f642e18ac4e7a616a2796dcf7a5d82e28cd17f9796494acc7c4aabb", size = 13215606, upload-time = "2026-08-19T03:12:17.225Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/67fedfd2cb77516ef0066b1642f487dba0eb3006493cf3475b15f5b8b228/ty-0.0.73-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16981e15fdceedb37d0aff76c5ac25914595dfee2675af95335550064251ad22", size = 13665497, upload-time = "2026-08-19T03:12:19.286Z" }, - { url = "https://files.pythonhosted.org/packages/8e/b3/154f4dd48ec5eebc186ab4b822c6e62f982fc5ddfd262d6e3903c2acba44/ty-0.0.73-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644b2bec8a2e2e4957a942ae81d6cff5571c489bb5a8675e4d3886de537a694d", size = 13351231, upload-time = "2026-08-19T03:12:21.353Z" }, - { url = "https://files.pythonhosted.org/packages/35/5f/d462496903fbe453fb76363f8478be929c8e6ff21e6928c57dcd7e5fa21f/ty-0.0.73-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338d565be3186f50ff8e9d10483685549c2d23f0754485d5ede3b54f4319188a", size = 12782586, upload-time = "2026-08-19T03:12:23.667Z" }, - { url = "https://files.pythonhosted.org/packages/87/52/ec6d24b74abe3ec324204c1c71e6d0c6c76a17ffc15fd51d603b0a302abe/ty-0.0.73-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:11c7b6d839309d2c102cb3a4c03d817176bbfab5b2fccc95a75ec5c9597421c9", size = 13247134, upload-time = "2026-08-19T03:12:25.956Z" }, - { url = "https://files.pythonhosted.org/packages/26/20/cc74650fec56a54786c6d7c89e09576fcad3092be34cf21715d39a406a9b/ty-0.0.73-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:488572db7ff97fb50ea36a76250f2d617c9727d143da6c7bf0623276eb0fc507", size = 12309344, upload-time = "2026-08-19T03:12:28.122Z" }, - { url = "https://files.pythonhosted.org/packages/89/bd/4b0a9087f4315d7fbadf77a3ce44c816cc9ffabed1ced06cc5be81fbc414/ty-0.0.73-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1b958ebceefbbf594e59eb8d3d55bbd033ce634026fcba3e4bc3179e78e45bb7", size = 12502319, upload-time = "2026-08-19T03:12:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/11/80/0a925074911fe111912ea29d9eed309bcc183f43d2fb3eef07db056a0beb/ty-0.0.73-py3-none-musllinux_1_2_i686.whl", hash = "sha256:91a32993b3c34e42c3f323ad6c0399cb596bd1c27e9b7f20db7cd64c1067b68e", size = 12753688, upload-time = "2026-08-19T03:12:32.433Z" }, - { url = "https://files.pythonhosted.org/packages/24/6b/aeccaf89efbc2e112bd415340a22e2669ec998aa397242503e747b712ca4/ty-0.0.73-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bab8a19fbf51f479bddb2a12c5fabfe52f918a5590362321ed5d89b44eb62c15", size = 13069050, upload-time = "2026-08-19T03:12:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/d7/3e/eae485fd86c1585943fd4e1746b0757b2da01e2c43136ebe8c686fe1c7f1/ty-0.0.73-py3-none-win32.whl", hash = "sha256:03347a612f0fa020b19bfd8dbd521db6ecc75d377a3e4d4f6e6c2e62871da4cc", size = 12053187, upload-time = "2026-08-19T03:12:37.565Z" }, - { url = "https://files.pythonhosted.org/packages/a7/01/9b8b983786e3ce34924e372e8b76b92b508273ab65c589fc7e88cc03ee17/ty-0.0.73-py3-none-win_amd64.whl", hash = "sha256:cedd05122ded0b5dcc55431a370e974b747f99c41c290a3d2ab8c1867f197519", size = 12693838, upload-time = "2026-08-19T03:12:39.483Z" }, - { url = "https://files.pythonhosted.org/packages/ea/88/25333bbfea6a5dc064371d2002d3d4807db90b84d5448f9106b2712b0fbc/ty-0.0.73-py3-none-win_arm64.whl", hash = "sha256:e47068f8369dea5d641a26a2ad0a947a320b02ff87099b07e95de0323245a4dc", size = 12443573, upload-time = "2026-08-19T03:12:41.449Z" }, +version = "0.0.74" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/88/0f/c767853e88567a2ec7e996dd95e3105b1bc62c95d103689311ef0f4a603c/ty-0.0.74.tar.gz", hash = "sha256:da14344fc8625fc9ff359bafb856ad575636ea86d9bb6a629b146bff27b380e6", size = 6786318, upload-time = "2026-08-22T15:05:54.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/95/6ded58bc97885c6d88fa1f9cd815031489200738f961cbf0466663213f80/ty-0.0.74-py3-none-linux_armv6l.whl", hash = "sha256:8969ef4e508debf00cf58f9ea85a539f799b1732c59cdfcecd037630b9755b30", size = 12790043, upload-time = "2026-08-22T15:05:05.015Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8a/5e323603b6ab8731144421877ee8a0f8ac5a5511e67857127caa09f6730e/ty-0.0.74-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:51fb6cf5b98e1e1140825b2430943f78d744876a735231656eafbb4c3f7eca3c", size = 12371748, upload-time = "2026-08-22T15:05:08.609Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/ee72e08cb705281e8d8c42917dd577aa598a8a098008495fda5176ee3f6e/ty-0.0.74-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ebe60b1f0a948c793d6c77fc9e9ddda599e4f023c04ab16e8e03bcb428c3fa0", size = 12282403, upload-time = "2026-08-22T15:05:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/fd935b694ff68bc278af50f7ad04770b36ce6306399baef7e1847b553a9d/ty-0.0.74-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa97f407a695c890a53615966a663c7d2167e2cabe88db7ca1a24d62635cdfc8", size = 12345164, upload-time = "2026-08-22T15:05:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/54/5c/5b5825268e029ebb164c909780103dbbae367f069801410068bf1cef29b3/ty-0.0.74-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:673ddb733d4a0db31385ba1ed9ff1f6bd9dc5565413ce57b1ca5ac4c7803da5d", size = 12556646, upload-time = "2026-08-22T15:05:16.994Z" }, + { url = "https://files.pythonhosted.org/packages/56/e7/515914e571d62ce0101744fed3f881936eeb1b30dc37beb72b4f7ca1e289/ty-0.0.74-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1028e7c6b4f6145e9704552f43a5fffdcd51b42263ffdcd9c9677762bc395a4a", size = 13311653, upload-time = "2026-08-22T15:05:20.254Z" }, + { url = "https://files.pythonhosted.org/packages/b0/07/d1452babb6f9266c2122cabc095180b70ed306fb770b2996753814d2237d/ty-0.0.74-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79841a8890493021fb308772474983316eb91f7b56cb227a6a05a06b262a36f0", size = 13768284, upload-time = "2026-08-22T15:05:23.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/60/8d4a2fc7842a47210a1cb0a16a187d9de39ad5d509a00fb74c1c073afcde/ty-0.0.74-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94859d321f3c6a6c8f7bfc3f40e8319cda7e6e012e613440f3dfd145d5010e2e", size = 13422306, upload-time = "2026-08-22T15:05:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/76/ebbc269a8c4efcc4d44624993bd188145f20d60ebda9680b15aaec42cc50/ty-0.0.74-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:970a8b2c09ff3be04c8a1c6767332d861be4fce85efe7bb205e4ade7c8655274", size = 12970637, upload-time = "2026-08-22T15:05:29.15Z" }, + { url = "https://files.pythonhosted.org/packages/9e/dd/b99f7236acbf856780ca1779a48143d2d9f2c24d7f531a0ce15a022b8a87/ty-0.0.74-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:795f763b3ded85574c2c2846a6fb8acf2aa76e9e83d761143e92b1f0c7ffa2cd", size = 13344891, upload-time = "2026-08-22T15:05:32.033Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/9ff7449a4c7e6428f2c6f298e74cf24b70668f29d45c249507a723ff3782/ty-0.0.74-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dc086db5367d912c31c0cc872deb7387290e779a4b9b54fcb944673a7cd52c7b", size = 12395272, upload-time = "2026-08-22T15:05:34.702Z" }, + { url = "https://files.pythonhosted.org/packages/b1/dd/b23a5b6b35d37df89dc8dc5daa09efd9245a668b50c4c81c25de21567dc1/ty-0.0.74-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0314d7b391cf684e47c2fa093d2ce4c597cfc9b01d9a315fe204aed6359b271b", size = 12573079, upload-time = "2026-08-22T15:05:37.683Z" }, + { url = "https://files.pythonhosted.org/packages/23/c5/ccba16239d6129533c8b3603458d0f4dd2ba69478e47059073968e74261d/ty-0.0.74-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4a45dd2e991e8bdae82ba78c8cd051b253f60bc71a6536598fa3ef580b4fc9b", size = 12832506, upload-time = "2026-08-22T15:05:40.505Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1c/2390912634dff4f341f97b397f2aee341ff062be0a66cda37d59375454f2/ty-0.0.74-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:210e2eac6b018fb934e2b8dac3956a0ba076a3fb1fa6f135058c825e5b759b81", size = 13154752, upload-time = "2026-08-22T15:05:43.355Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/a8c12188227e6f74f91853a7374e01ed81d6ad21c16c8b70e92dbebfe46a/ty-0.0.74-py3-none-win32.whl", hash = "sha256:db0bb6a8f098ef9bd1be861f73b4f7c0320d40d4c05c7ae0a8677d4e7aa4f6e5", size = 12130002, upload-time = "2026-08-22T15:05:46.058Z" }, + { url = "https://files.pythonhosted.org/packages/21/5c/064f28ccb9c234cfce5a2f7aa69a256663d5ae5bb0290b3a9706cc4d1e4c/ty-0.0.74-py3-none-win_amd64.whl", hash = "sha256:bebff181515255b3c78bd2e7693ae66fab6064ad4feea2065c68bc01022aa678", size = 12771435, upload-time = "2026-08-22T15:05:48.811Z" }, + { url = "https://files.pythonhosted.org/packages/fe/06/d6becdaca0315346c26b6df97cb0eafa81de4f870945d6989e88704374ed/ty-0.0.74-py3-none-win_arm64.whl", hash = "sha256:1a3469eaaf8c85b1c0a15bede25d36daea4b09fce1d913e965b24e24b3f1d6c6", size = 12558299, upload-time = "2026-08-22T15:05:51.543Z" }, ] [[package]] From 885ce4a0d79d0a0b978cd320fbdd3d3d31d365da Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 20:21:39 -0700 Subject: [PATCH 157/158] Changed how `log_message` resolves channels --- .../ext/automod/actions/log_message.py | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/commanderbot/ext/automod/actions/log_message.py b/commanderbot/ext/automod/actions/log_message.py index 9571b7b..29e96f2 100644 --- a/commanderbot/ext/automod/actions/log_message.py +++ b/commanderbot/ext/automod/actions/log_message.py @@ -27,7 +27,11 @@ class LogMessage(AutomodAction): """The content of the message to send.""" channel: Optional[ChannelID] = None - """The channel to send the message in. Defaults to the channel in context.""" + """ + The channel to send the message in. If not set, the action will try to resolve + other log channels that may be in context. The order for that is: the rule's log, the default log, + and lastly the channel in context. + """ emoji: Optional[str] = None """The emoji used to represent the type of message.""" @@ -44,7 +48,7 @@ class LogMessage(AutomodAction): fields: Optional[dict[ContextFields, str]] = None """ A custom set of fields to display as part of the message. The key should - correspond to an event field, and the value is the title to use for it. + correspond to a context field, and the value is the title to use for it. """ allowed_mentions: AllowedMentions = Field(default_factory=AllowedMentions.none) @@ -53,15 +57,33 @@ class LogMessage(AutomodAction): mentions will be suppressed. """ - def _resolve_channel( + async def _resolve_channel( self, context: AutomodContext ) -> Optional[MessageableGuildChannel | Thread]: - channel = context.event.channel - if self.channel: + # Try to resolve the action's log channel + if self.channel is not None: channel = context.bot.get_channel(self.channel) - - if is_messagable_guild_channel(channel) or is_thread(channel): - return channel + if is_messagable_guild_channel(channel) or is_thread(channel): + return channel + + # Try to resolve the rule's log channel + if (rule_log := context.rule.log) and (channel_id := rule_log.channel): + channel = context.bot.get_channel(channel_id) + if is_messagable_guild_channel(channel) or is_thread(channel): + return channel + + # Try to resolve the default log channel + default_log = await context.state.store.get_default_log(context.state.guild) + if default_log and (channel_id := default_log.channel): + channel = context.bot.get_channel(channel_id) + if is_messagable_guild_channel(channel) or is_thread(channel): + return channel + + # Try to resolve the channel in context + if context.event.channel and (channel_id := context.event.channel.id): + channel = context.bot.get_channel(channel_id) + if is_messagable_guild_channel(channel) or is_thread(channel): + return channel def _build_log_view( self, context: AutomodContext @@ -129,7 +151,7 @@ def _build_log_view( @override async def apply(self, context: AutomodContext): - channel = self._resolve_channel(context) + channel = await self._resolve_channel(context) if not channel: return From 60eeab73819158122f09a72b0432b1f53ed0e43f Mon Sep 17 00:00:00 2001 From: Ersatz Date: Tue, 25 Aug 2026 20:42:05 -0700 Subject: [PATCH 158/158] Added `send_view` and fixed a few issues --- commanderbot/lib/log_channel.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/commanderbot/lib/log_channel.py b/commanderbot/lib/log_channel.py index baeda16..3ba5497 100644 --- a/commanderbot/lib/log_channel.py +++ b/commanderbot/lib/log_channel.py @@ -73,7 +73,7 @@ async def send( If the message is too big, it will be sent as a file instead. """ channel = await self._require_channel(bot) - allowed_mentions = self.allowed_mentions or AllowedMentions.none() + allowed_mentions = allowed_mentions or self.allowed_mentions # Create log message view log_view = ui.LayoutView() @@ -122,3 +122,18 @@ async def send_error( file_name=file_name, allowed_mentions=allowed_mentions, ) + + async def send_view( + self, + bot: Bot, + view: ui.View | ui.LayoutView, + *, + files: Optional[list[File]] = None, + allowed_mentions: Optional[AllowedMentions] = None, + ): + """Send a view to the configured log channel.""" + channel = await self._require_channel(bot) + files = files or [] + allowed_mentions = allowed_mentions or self.allowed_mentions + + await channel.send(view=view, files=files, allowed_mentions=allowed_mentions)