diff --git a/.gitignore b/.gitignore index f54764dd..340ed8ca 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d01bc88..8dfffee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,112 @@ 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 support Pydantic: + - `AllowedMentions` + - `Color` + - `ChannelType` + - `Intents` + - `ImageHash` + - `Timedelta` +- `sudo`: + - Now supports exporting `JsonDB` databases +- Added a process pool to `CommanderBot` that any extension can use + +### 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 + +- `mcdoc`: + - Fixed an issue where application emojis weren't being used + +### Removed + +- `help_chat`: + - It hasn't been used in ages and is pretty outdated now + ## [0.22.0] - 2026-01-06 ### Fixed @@ -410,7 +516,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 diff --git a/README.md b/README.md index 8ca220f1..bab6f7c5 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,16 @@ 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 - Python 3.14+ -- discord.py 2.6+ +- discord.py 2.7+ ## Running your bot @@ -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 diff --git a/commanderbot/core/application_emoji_manager.py b/commanderbot/core/application_emoji_manager.py index 35fd8142..3858f0f1 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/core/cli.py b/commanderbot/core/cli.py index 137b7a9b..fcee1e66 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 @@ -58,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: @@ -78,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/command_tree.py b/commanderbot/core/command_tree.py index bcbf758f..e918157e 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, override 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 @@ -47,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 @@ -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 @@ -154,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, @@ -174,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]: @@ -188,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" @@ -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}...") @@ -215,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 1dd83917..39d95b77 100644 --- a/commanderbot/core/commander_bot.py +++ b/commanderbot/core/commander_bot.py @@ -2,8 +2,10 @@ import sys from datetime import datetime, timedelta from logging import Logger, getLogger -from typing import Any, Optional, Type +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 @@ -11,8 +13,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, @@ -42,24 +43,23 @@ 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 + 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) - @property - def started_at(self) -> datetime: - return self._started_at + # 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 = psutil.cpu_count() or 0 + self.max_pool_workers: int = max(1, cpu_count - 1) + self.pool = pebble.ProcessPool(self.max_pool_workers) - @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]: @@ -68,8 +68,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) @@ -80,7 +80,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) @@ -171,7 +171,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. @@ -182,11 +182,11 @@ 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 + @override async def unload_extension(self, name: str, *, package: Optional[str] = None): try: # Resolve the extension name and get the extension @@ -197,11 +197,11 @@ 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 + @override async def reload_extension(self, name: str, *, package: Optional[str] = None): try: # Resolve the extension name and get the extension @@ -211,11 +211,11 @@ 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 + @override async def setup_hook(self): # Build application emoji cache before we process extensions. self.log.info("Building application emoji cache...") @@ -242,16 +242,25 @@ async def setup_hook(self): await self.command_tree.build_global_cache() await self.command_tree.build_guild_cache(self.guilds) - # @overrides Bot + @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.") 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): @@ -260,7 +269,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 391c92fa..145f880e 100644 --- a/commanderbot/core/config.py +++ b/commanderbot/core/config.py @@ -1,135 +1,187 @@ -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, + 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 +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="before") + @classmethod + 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 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) - extensions: dict[str, ConfiguredExtension] - enabled_extensions: list[ConfiguredExtension] = field( - init=False, default_factory=list + 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 ) - disabled_extensions: list[ConfiguredExtension] = field( - init=False, default_factory=list + allowed_mentions: AllowedMentions = Field( + default_factory=AllowedMentions.not_everyone ) + extensions: list[ConfiguredExtension] = Field(default_factory=list) - # @implements FromDataMixin - @classmethod - def try_from_data(cls, data: Any) -> Optional[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}") - - # 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...") - - 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 - - if extensions: - log.info(f"Processed {len(extensions)} extensions...") - else: - log.warning("No extensions configured.") - - return cls( - command_prefix=command_prefix, - intents=intents, - allowed_mentions=allowed_mentions, - extensions=extensions, - ) + _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) - # @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()], - ) + @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 - @classmethod - def from_file(cls, path: Path) -> Self: - raw_config: JsonObject = {} - with open(path) as file: - raw_config = json.load(file) + @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 - return cls.from_data(raw_config) + @property + def enabled_extensions(self) -> list[ConfiguredExtension]: + """ + A list of enabled extensions. + """ + return self._enabled_extensions - def __post_init__(self): - self._rebuild_extension_states() + @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} + + @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: + 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: 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. @@ -145,7 +197,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 +257,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 +277,6 @@ 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() diff --git a/commanderbot/core/configured_extension.py b/commanderbot/core/configured_extension.py deleted file mode 100644 index 01dc6bf9..00000000 --- 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/core/error_handling.py b/commanderbot/core/error_handling.py index d52fcb60..cfc9517a 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/help_command.py b/commanderbot/core/help_command.py index 0a42faa8..584a72cc 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/commanderbot/core/logging.py b/commanderbot/core/logging.py index 7588ddcf..55dd3572 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( diff --git a/commanderbot/ext/allay/allay_cog.py b/commanderbot/ext/allay/allay_cog.py index 6c519e0e..1f8be6b5 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/automod/action/__init__.py b/commanderbot/ext/automod/action/__init__.py new file mode 100644 index 00000000..a5830641 --- /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 00000000..8c57bc4b --- /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.""" + ... diff --git a/commanderbot/ext/automod/action/types.py b/commanderbot/ext/automod/action/types.py new file mode 100644 index 00000000..f9b27d09 --- /dev/null +++ b/commanderbot/ext/automod/action/types.py @@ -0,0 +1,38 @@ +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.AddToBucket + | actions.AddUsersToThread + | actions.DeleteActorMessages + | actions.DeleteAuthorMessages + | 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.TypeInChannel + | 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 e69de29b..78f4cb03 100644 --- a/commanderbot/ext/automod/actions/__init__.py +++ b/commanderbot/ext/automod/actions/__init__.py @@ -0,0 +1,26 @@ +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_actor_messages import * +from .delete_author_messages 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 .type_in_channel import * +from .wait import * diff --git a/commanderbot/ext/automod/actions/abc/__init__.py b/commanderbot/ext/automod/actions/abc/__init__.py index e69de29b..773edd9a 100644 --- a/commanderbot/ext/automod/actions/abc/__init__.py +++ b/commanderbot/ext/automod/actions/abc/__init__.py @@ -0,0 +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/add_roles_to_target.py b/commanderbot/ext/automod/actions/abc/add_roles_to_target.py new file mode 100644 index 00000000..ce7a38c3 --- /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(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]: + ... + + @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 20a51ff9..00000000 --- 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/delete_target_messages.py b/commanderbot/ext/automod/actions/abc/delete_target_messages.py new file mode 100644 index 00000000..2d4dfb15 --- /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.timedelta 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 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. + """ + + @abstractmethod + def get_target(self, context: AutomodContext) -> Optional[User]: + ... + + @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/abc/remove_roles_from_target.py b/commanderbot/ext/automod/actions/abc/remove_roles_from_target.py new file mode 100644 index 00000000..9d726042 --- /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(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]: + ... + + @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 d2051972..00000000 --- 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 b7ccf44b..4e823879 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(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 0e2a8002..1c50988b 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.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 9a7392c7..bc751be9 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.author) and is_member(member): + return member 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 00000000..a03c8bb7 --- /dev/null +++ b/commanderbot/ext/automod/actions/add_to_bucket.py @@ -0,0 +1,25 @@ +from typing import Literal, override + +from commanderbot.ext.automod.action import AutomodAction +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.bucket import AutomodBucketRef + +__all__ = ("AddToBucket",) + + +class AddToBucket(AutomodAction): + """ + Add any relevant data in context to a bucket. + """ + + type: Literal["add_to_bucket"] + + bucket: AutomodBucketRef + """The bucket to add to.""" + + @override + async def apply(self, context: AutomodContext): + # Get the bucket and add context data to it + bucket = await self.bucket.resolve(context) + if not bucket.disabled: + await bucket.add(context) diff --git a/commanderbot/ext/automod/actions/add_users_to_thread.py b/commanderbot/ext/automod/actions/add_users_to_thread.py index 48aaaeeb..e7c18ac1 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 7e2090b2..00000000 --- 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_actor_messages.py b/commanderbot/ext/automod/actions/delete_actor_messages.py new file mode 100644 index 00000000..9fb1a9df --- /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 00000000..0c5640bf --- /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/ext/automod/actions/delete_message.py b/commanderbot/ext/automod/actions/delete_message.py index 2127b251..c7bbd987 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 a7d42be3..14e93e10 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.timedelta 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 e0d215fc..7333c8d0 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, 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. + """ + + 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 3081e460..bf0a67f2 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 d5b76a7a..29e96f2d 100644 --- a/commanderbot/ext/automod/actions/log_message.py +++ b/commanderbot/ext/automod/actions/log_message.py @@ -1,108 +1,161 @@ -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. 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.""" + 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]: + fields: Optional[dict[ContextFields, str]] = None + """ + A custom set of fields to display as part of the message. The key should + correspond to a context 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. + """ + + async def _resolve_channel( + self, context: AutomodContext + ) -> Optional[MessageableGuildChannel | Thread]: + # Try to resolve the action's log channel 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) + channel = context.bot.get_channel(self.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 + ) -> 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 and (attachments := context.event.attachments): + gallery = ui.MediaGallery() + has_media: bool = False + for attachment in 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 = await 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 00000000..f5046c54 --- /dev/null +++ b/commanderbot/ext/automod/actions/randomize.py @@ -0,0 +1,69 @@ +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(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 4121aefc..12d5b002 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 cdc62bea..6c73b855 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(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 310549c0..0e30463b 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(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 b89b6b76..b76d79ed 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.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 b9b0cb1b..bb6d9373 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.author) 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 ce9e2d5e..86be326f 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.timedelta 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 44e23464..c537a8b2 100644 --- a/commanderbot/ext/automod/actions/send_message.py +++ b/commanderbot/ext/automod/actions/send_message.py @@ -1,73 +1,70 @@ -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.timedelta import Timedelta +from commanderbot.lib.types import ChannelID, MessageableGuildChannel +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 00000000..b6be1268 --- /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(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 dde86a79..58cc3476 100644 --- a/commanderbot/ext/automod/actions/throw_error.py +++ b/commanderbot/ext/automod/actions/throw_error.py @@ -1,28 +1,24 @@ -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 +from commanderbot.ext.automod.automod_exceptions import AutomodException +__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. """ - error: str - - async def apply(self, event: AutomodEvent): - raise Exception(self.error) + type: Literal["throw_error"] + error: str + """A human-readable error message.""" -def create_action(data: JsonObject) -> AutomodAction: - return ThrowError.from_data(data) + @override + async def apply(self, context: AutomodContext): + raise AutomodException(self.error) diff --git a/commanderbot/ext/automod/actions/timeout_actor.py b/commanderbot/ext/automod/actions/timeout_actor.py new file mode 100644 index 00000000..8ceeaae9 --- /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.timedelta 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 00000000..ee9d5e8d --- /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.timedelta 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/type_in_channel.py b/commanderbot/ext/automod/actions/type_in_channel.py new file mode 100644 index 00000000..7a2ba0d4 --- /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["type_in_channel"] + + 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) diff --git a/commanderbot/ext/automod/actions/wait.py b/commanderbot/ext/automod/actions/wait.py index 6fc56b72..2fc5c173 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.timedelta 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/automod_action.py b/commanderbot/ext/automod/automod_action.py deleted file mode 100644 index f5b05387..00000000 --- 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_cog.py b/commanderbot/ext/automod/automod_cog.py index 383156b1..bff05e5c 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -1,101 +1,71 @@ +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 ( + Choice, + Group, + allowed_contexts, + allowed_installs, + autocomplete, + choices, + 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.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, 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.cogs.database 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 +73,634 @@ 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] + # @@ 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 - def _guild_state_for_member(self, member: Member) -> Optional[AutomodGuildState]: - if not is_bot(self.bot, member): - return self.state[member.guild] + # @@ COMMANDS - 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 schema + @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 rules:", file=file, ephemeral=True + ) - def _guild_state_for_thread(self, thread: Thread) -> Optional[AutomodGuildState]: - if (parent := thread.parent) and (guild := parent.guild): - return self.state[guild] + # @@ automod log + cmd_automod_log = Group(name="log", description="Configure the default log channel") - def _guild_state_for_thread_member( - self, member: ThreadMember - ) -> Optional[AutomodGuildState]: - return self._guild_state_for_thread(member.thread) + # @@ 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) - 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 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) - # @@ EVENT LISTENERS + # @@ 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) - @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 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) - @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 + cmd_automod_rules = Group(name="rules", description="Manage rules") + + # @@ automod rules add + @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 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) + + # @@ automod rules upload + @cmd_automod_rules.command(name="upload", description="Upload a rule") + @describe( + 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 + ): + assert is_guild(interaction.guild) + await self.state[interaction.guild].upload_rule(interaction, file) + + # @@ 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) + + # @@ 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) + + # @@ 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) + + # @@ 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) + + # @@ 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 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") + 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 buckets") + + # @@ automod buckets add + @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") + @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) + + # @@ 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) + + # @@ automod buckets details + @cmd_automod_buckets.command( + 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) + + # @@ 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") + @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) + + # @@ 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) + + # @@ 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) + + # @@ 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) - @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 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) - @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 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 + + # @@ 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_member_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_member_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) - - @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) + @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="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) + if guild_state := self._guild_state_for_thread(after): + await guild_state.on_thread_updated(before, after) - @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) + @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="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) + if guild_state := self._guild_state_for_thread(thread): + await guild_state.on_thread_removed(thread) - @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) - - @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_delete(self, thread: Thread): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_delete + """ - @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(thread): + await guild_state.on_thread_deleted(thread) + + @Cog.listener() + async def on_thread_member_join(self, member: ThreadMember): + """ + https://discordpy.readthedocs.io/en/latest/api.html#discord.on_thread_member_join + """ + + 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_condition.py b/commanderbot/ext/automod/automod_condition.py deleted file mode 100644 index 0eb84123..00000000 --- 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_context.py b/commanderbot/ext/automod/automod_context.py new file mode 100644 index 00000000..6eab9702 --- /dev/null +++ b/commanderbot/ext/automod/automod_context.py @@ -0,0 +1,326 @@ +import io +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, Optional, cast + +import imagehash +from discord import Attachment, Member, User +from discord.ext.commands import Bot +from discord.utils import format_dt, utcnow +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 + +__all__ = ("AutomodContext",) + +if TYPE_CHECKING: + 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 AutomodContextMetadata: + attachment_data: dict[AttachmentID, bytes] = field(default_factory=dict) + 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 + 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: + """ + The execution context that's given to all triggers, conditions, and actions. + """ + + 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 that the rule is processing.""" + + metadata: AutomodContextMetadata = field( + init=False, default_factory=AutomodContextMetadata + ) + """Any additional data attached to the execution context.""" + + 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. + """ + + # Return if we have no attachments in context + if not self.event.attachments: + return [] + + attachments: list[ContextAttachment] = [] + for attachment in self.event.attachments: + 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, phash = result + attachments.append((attachment, data, phash)) + return attachments + + async def fetch_attachments_with_type( + self, *content_types: Optional[MimeType] + ) -> list[ContextAttachment]: + """ + 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 + fetched once. + """ + + # Return if we have no attachments in context + if not self.event.attachments: + return [] + + attachments: list[ContextAttachment] = [] + for attachment in self.event.attachments: + # 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 + + # 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, phash = result + attachments.append((attachment, data, phash)) + return attachments + + 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 + ) -> 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._is_image_attachment(attachment): + return (data, None) + + # Calculate phash + 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 + return (data, phash) + + 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: + 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 + ) -> 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 asdict(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 attachments := self.event.attachments: + yield ("attachments", attachments) + + 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 asdict(self.metadata).items() + if not self._is_value_safe(v) + ) diff --git a/commanderbot/ext/automod/automod_data.py b/commanderbot/ext/automod/automod_data.py index c6b9d087..c24ba8b3 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -1,323 +1,276 @@ 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, + AutomodBucketAlreadyExists, + AutomodBucketDoesNotExist, + AutomodRuleAlreadyDisabled, + AutomodRuleAlreadyEnabled, + AutomodRuleAlreadyExists, + AutomodRuleDoesNotExist, + AutomodRuleMetadataAlreadyExists, + AutomodRuleMetadataDoesNotExist, + DefaultLogChannelNotConfigured, ) +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 +from commanderbot.lib.types import GuildID, UserID -RulesByEventType = defaultdict[Type[AutomodEvent], set[AutomodRule]] +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 + ) -class AutomodRuleWithNameAlreadyExists(ResponsiveException): - def __init__(self, name: str): - self.name: str = name - super().__init__(f"A rule with the name `{name}` already exists") + _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 AutomodNoRuleWithName(ResponsiveException): - def __init__(self, name: str): - self.name: str = name - super().__init__(f"There is no rule with the name `{name}`") + 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( + self, + ) -> LogChannel: + if log := self.default_log: + return log + raise DefaultLogChannelNotConfigured -class AutomodRuleNotRegistered(ResponsiveException): - def __init__(self, rule: AutomodRule): - self.rule: AutomodRule = rule - super().__init__(f"Rule `{rule.name}` is not registered") + def get_default_log( + self, + ) -> Optional[LogChannel]: + return self.default_log + def set_default_log(self, log: LogChannel) -> LogChannel: + self.default_log = log + return self.default_log -class AutomodInvalidFields(ResponsiveException): - def __init__(self, names: set[str]): - self.names: set[str] = names - super().__init__("These fields are invalid: " + "`" + "` `".join(names) + "`") + 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 -class AutomodUnmodifiableFields(ResponsiveException): - def __init__(self, names: set[str]): - self.names: set[str] = names - super().__init__( - "These fields cannot be modified: " + "`" + "` `".join(names) + "`" - ) + return (old_log, self.default_log) + def remove_default_log( + self, + ) -> LogChannel: + old_log = self.require_default_log() + self.default_log = None + return old_log -@dataclass -class AutomodGuildData: - # Default logging configuration for this guild. - default_log_options: Optional[LogOptions] = None + def require_rule(self, name: str) -> AutomodRule: + if rule := self.rules.get(name): + return rule + raise AutomodRuleDoesNotExist(name) - # Roles that are permitted to manage the extension within this guild. - permitted_roles: Optional[RoleSet] = None + def require_rule_metadata(self, name: str) -> AutomodRuleMetadata: + if metadata := self.rule_metadata.get(name): + return metadata + raise AutomodRuleMetadataDoesNotExist(name) - # Index rules by name for faster look-up in commands. - rules: dict[str, AutomodRule] = field(init=False, default_factory=dict) + def has_rule(self, name: str) -> bool: + return name in self.rules - # 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()) - ) + 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 - @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 ..., + 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(), ) - 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 + # Add rule + self.rules[rule.name] = rule + self.rule_metadata[rule.name] = metadata - def set_permitted_roles( - self, permitted_roles: Optional[RoleSet] - ) -> Optional[RoleSet]: - old_value = self.permitted_roles - self.permitted_roles = permitted_roles - return old_value + self._rebuild_mappings() + return (rule, metadata) - def all_rules(self) -> Iterable[AutomodRule]: - yield from self.rules.values() + 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) - 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) + # Replace the old rule with the modified rule + self.rules[rule.name] = rule - def require_rule(self, name: str) -> AutomodRule: - if rule := self.get_rule(name): - return rule - raise AutomodNoRuleWithName(name) + # Update metadata + metadata.modified_by_id = user_id + metadata.modified_on = utcnow() - 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) + self._rebuild_mappings() + return (old_rule, rule, metadata) - 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) + 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) + + # Delete rule and metadata del self.rules[rule.name] + del self.rule_metadata[rule.name] - def remove_rule_by_name(self, name: str) -> AutomodRule: + 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) + + def enable_rule(self, name: str) -> AutomodRule: rule = self.require_rule(name) - self.remove_rule(rule) - return rule + if rule.disabled: + rule.disabled = False + return rule + raise AutomodRuleAlreadyEnabled(name) - def modify_rule_raw( - 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) + def disable_rule(self, name: str) -> AutomodRule: + rule = self.require_rule(name) + if not rule.disabled: + rule.disabled = True + return rule + raise AutomodRuleAlreadyDisabled(name) - # Update the modification timestamp. Note that it may still be overidden. - new_data["modified_on"] = utcnow().isoformat() + def enable_all_rules(self): + for rule in self.rules.values(): + rule.disabled = False - # Update the new rule data using the given changes. - utils.update_json_with_path(new_data, path, op, data) + def disable_all_rules(self): + for rule in self.rules.values(): + rule.disabled = True - # Create a new rule out of the modified data. - new_rule = AutomodRule.from_data(new_data) + def require_bucket(self, name: str) -> AutomodBucket: + if bucket := self.buckets.get(name): + return bucket + raise AutomodBucketDoesNotExist(name) - # Remove the old rule, and then add the new one. - self.remove_rule(old_rule) - self.add_rule(new_rule) + 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) - # Return the new rule. - return new_rule + def add_bucket(self, bucket: AutomodBucket) -> AutomodBucket: + # The bucket name needs to be available + if bucket.name in self.buckets: + raise AutomodBucketAlreadyExists(bucket.name) - def enable_rule_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - rule.disabled = False - return rule + # Add the bucket + self.buckets[bucket.name] = bucket # type: ignore[ty:invalid-assignment] - This is fine since all buckets inherit from `AutomodBucket` - def disable_rule_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - rule.disabled = True - return rule + return bucket - def increment_rule_hits_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - rule.hits += 1 - return rule + def modify_bucket( + self, bucket: AutomodBucket + ) -> tuple[AutomodBucket, AutomodBucket]: + # The bucket need to exist + old_bucket = self.require_bucket_with_type(bucket.name, type(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` -def _guilds_defaultdict_factory() -> defaultdict[GuildID, AutomodGuildData]: - return defaultdict(lambda: AutomodGuildData()) + return (old_bucket, bucket) + def remove_bucket(self, name: str) -> AutomodBucket: + # The bucket need to exist + bucket = self.require_bucket(name) -# @implements AutomodStore -@dataclass -class AutomodData: - """ - Implementation of `AutomodStore` using an in-memory object hierarchy. - """ + # Remove the bucket + del self.buckets[bucket.name] - guilds: defaultdict[GuildID, AutomodGuildData] = field( - default_factory=_guilds_defaultdict_factory - ) + return bucket - @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 yield_buckets(self, *, sort: bool) -> Iterable[AutomodBucket]: + buckets = self.buckets.values() + yield from ( + sorted(buckets, key=lambda bucket: bucket.name) if sort else buckets ) - # @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 - - # @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 - - # @implements AutomodStore - async def get_rule(self, guild: Guild, name: str) -> Optional[AutomodRule]: - return self.guilds[guild.id].get_rule(name) - - # @implements AutomodStore - async def require_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].require_rule(name) - - # @implements AutomodStore - async def add_rule(self, guild: Guild, data: JsonObject) -> AutomodRule: - return self.guilds[guild.id].add_rule_from_data(data) - - # @implements AutomodStore - async def remove_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].remove_rule_by_name(name) - - # @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 bucket_count(self) -> int: + return len(self.buckets) + + 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) -> AutomodBucket: + 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, + ) diff --git a/commanderbot/ext/automod/automod_entity.py b/commanderbot/ext/automod/automod_entity.py deleted file mode 100644 index bc46b98c..00000000 --- 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_event.py b/commanderbot/ext/automod/automod_event.py deleted file mode 100644 index 0b9d7993..00000000 --- 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 diff --git a/commanderbot/ext/automod/automod_exceptions.py b/commanderbot/ext/automod/automod_exceptions.py new file mode 100644 index 00000000..fc5bd56a --- /dev/null +++ b/commanderbot/ext/automod/automod_exceptions.py @@ -0,0 +1,178 @@ +from commanderbot.ext.automod.enums import BucketTypeChoices +from commanderbot.lib.exceptions import ResponsiveException + + +class AutomodException(ResponsiveException): + pass + + +class CouldNotValidateLogChannel(AutomodException): + def __init__(self, exception: ValueError): + self.exception: ValueError = 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: ValueError = 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: ValueError = exception + super().__init__( + f"😵 An error occurred while validating the new rule:\n```\n{exception}\n```" + ) + + +class CouldNotValidateModifiedAutomodRule(AutomodException): + def __init__(self, exception: ValueError): + self.exception: ValueError = exception + super().__init__( + f"😵 An error occurred while validating the modified rule:\n```\n{exception}\n```" + ) + + +class CouldNotValidateUploadedAutomodRule(AutomodException): + def __init__(self, exception: ValueError): + 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") + + +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 + super().__init__(f"🤷 Rule `{self.name}` already exists") + + +class AutomodRuleDoesNotExist(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Rule `{self.name}` does not exist") + + +class AutomodRuleAlreadyEnabled(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Rule `{self.name}` is already enabled") + + +class AutomodRuleAlreadyDisabled(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Rule `{self.name}` is already disabled") + + +class AutomodRuleMetadataAlreadyExists(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__( + f"😬 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"🤷 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"🤷 Bucket `{self.name}` already exists") + + +class AutomodBucketDoesNotExist(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Bucket `{self.name}` does not exist") + + +class AutomodBucketAlreadyEnabled(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Bucket `{self.name}` is already enabled") + + +class AutomodBucketAlreadyDisabled(AutomodException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"😬 Bucket `{self.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" + ) + + +class AutomodValidationError(ValueError): + pass diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index af1411df..4a0b83b7 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -1,449 +1,439 @@ 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, Thread, ThreadMember, User, ) -from discord.ext.commands import Context -from discord.utils import utcnow -from yaml import YAMLError - -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 mime_enum import MimeType + +from commanderbot.core.utils import get_app_command +from commanderbot.ext.automod import buckets, events +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 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 +from commanderbot.lib.allowed_mentions import AllowedMentions from commanderbot.lib.cogs import CogGuildState +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 set_default_log(self, interaction: Interaction): + await interaction.response.send_modal(log_ui.SetDefaultLog(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_default_log(self, interaction: Interaction): + log = await self.store.require_default_log(self.guild) + await interaction.response.send_modal( + log_ui.ModifyDefaultLog(interaction, self, log) + ) - async def _do_event_for_rule(self, event: AutomodEventBase, rule: AutomodRule): - 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) + async def remove_default_log(self, interaction: Interaction): + # Get the default log + log = await self.store.require_default_log(self.guild) - 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(), + # 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, ) - 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, - ) - old_log_options = await self.store.set_default_log_options( - self.guild, new_log_options - ) - 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}") + # 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 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}") - 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") + async def show_default_log_details(self, interaction: Interaction): + log = await self.store.require_default_log(self.guild) + view = log_ui.DefaultLogDetails(log) + await interaction.response.send_message(view=view) + + async def add_rule(self, interaction: Interaction): + await interaction.response.send_modal(rule_ui.AddRuleModal(interaction, self)) - 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 + async def modify_rule(self, interaction: Interaction, name: str): + rule = await self.store.require_rule(self.guild, name) + await interaction.response.send_modal( + rule_ui.ModifyRuleModal(interaction, self, rule) ) - 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 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 - 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}`") + # Try to validate the uploaded file + try: + 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 rule `{rule.name}`") else: - await self.reply(ctx, f"No rules available") + await self.store.add_rule(self.guild, rule, interaction.user.id) + await interaction.response.send_message(f"Added rule `{rule.name}`") - 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 remove_rule(self, interaction: Interaction, name: str): + # Get the rule + rule = await self.store.require_rule(self.guild, name) - else: - await self.reply(ctx, f"No rule found matching `{query}`") + # Respond to this interaction with a confirmation dialog + result: ConfirmationResult = await respond_with_confirmation( + interaction, + f"Are you sure you want to remove the rule `{rule.name}`?", + timeout=10.0, + ) - 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}`") + # 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 rule `{rule.name}`" + ) + except Exception: + await interaction.delete_original_response() + raise + case _: + await interaction.followup.send( + f"Did not remove the rule `{rule.name}`" + ) - async def remove_rule(self, ctx: Context, name: str): - # Get the corresponding rule. + async def show_rule_details(self, interaction: Interaction, name: str): + # Get rule and metadata 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}`?", + 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 the rule details view and respond with it + view = rule_ui.RuleDetails(rule, metadata) + await interaction.response.send_message( + view=view, + file=rule_file, + allowed_mentions=AllowedMentions.none(), ) - # 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): + async def enable_rule(self, interaction: Interaction, name: str): rule = await self.store.enable_rule(self.guild, name) - await self.reply(ctx, f"Enabled automod rule `{rule.name}`") + await interaction.response.send_message(f"Enabled rule `{rule.name}`") - async def disable_rule(self, ctx: Context, name: str): + async def disable_rule(self, interaction: Interaction, name: str): rule = await self.store.disable_rule(self.guild, name) - await self.reply(ctx, f"Disabled automod rule `{rule.name}`") + await interaction.response.send_message(f"Disabled rule `{rule.name}`") + + async def list_rules(self, interaction: Interaction): + 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 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 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}` rules") - # @@ EVENT HANDLERS + 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 on_typing( - self, channel: TextChannel | Thread, member: Member, when: datetime + async def add_bucket( + self, interaction: Interaction, bucket_type: BucketTypeChoices ): - await self._do_event( - events.MemberTyping(self.bot, self.log, channel, member, when) + match bucket_type: + case BucketTypeChoices.FLAGGED_IMAGE_ATTACHMENTS: + await interaction.response.send_modal( + buckets_ui.AddFlaggedImageAttachmentsBucket(interaction, self) + ) + case BucketTypeChoices.MESSAGE_HISTORY: + await interaction.response.send_modal( + buckets_ui.AddMessageHistoryBucket(interaction, self) + ) + case _: + raise UnsupportedBucketTypeChoice(bucket_type) + + 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(): + await interaction.response.send_modal( + buckets_ui.ModifyFlaggedImageAttachmentsBucket( + interaction, self, bucket + ) + ) + case buckets.MessageHistory(): + await interaction.response.send_modal( + buckets_ui.ModifyMessageHistoryBucket(interaction, self, bucket) + ) + case _: + raise UnsupportedBucketType(bucket.type) + + 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, ) - async def on_message(self, message: TextMessage): - await self._do_event(events.MessageSent(self.bot, self.log, message)) + # 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 on_message_delete(self, message: TextMessage): - await self._do_event(events.MessageDeleted(self.bot, self.log, message)) + async def show_bucket_details(self, interaction: Interaction, name: str): + bucket = await self.store.require_bucket(self.guild, name) + + match bucket: + case buckets.FlaggedImageAttachments(): + view = buckets_ui.FlaggedImageAttachmentsBucketDetails(bucket) + await interaction.response.send_message(view=view) + case buckets.MessageHistory(): + view = buckets_ui.MessageHistoryBucketDetails(bucket) + await interaction.response.send_message(view=view) + case _: + raise UnsupportedBucketType(bucket.type) + + 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): + 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): + 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 `{bucket_count}` buckets" + ) - async def on_message_edit(self, before: TextMessage, after: TextMessage): - await self._do_event(events.MessageEdited(self.bot, self.log, before, after)) + # @@ UTILITIES - async def on_reaction_add(self, reaction: TextReaction, member: Member): - await self._do_event(events.ReactionAdded(self.bot, self.log, reaction, member)) + def get_schema_command(self) -> str: + if cmd := get_app_command(self.bot, "automod schema"): + return cmd.mention + return "/automod schema" - async def on_reaction_remove(self, reaction: TextReaction, member: Member): - await self._do_event( - events.ReactionRemoved(self.bot, self.log, reaction, member) - ) + # @@ 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(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 _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) - async def on_channel_create(self, channel: TextChannel | Thread): - await self._do_event(events.GuildChannelCreated(self.bot, self.log, channel)) + 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_channel_delete(self, channel: TextChannel | Thread): - await self._do_event(events.GuildChannelDeleted(self.bot, self.log, channel)) + # @@ DISCORD AUTOMOD + async def on_discord_automod_action_block_message( + self, execution: discord.AutoModAction + ): + 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_channel_update( - self, before: TextChannel | Thread, after: TextChannel | Thread + async def on_discord_automod_action_block_member_interactions( + self, execution: discord.AutoModAction ): - await self._do_event( - events.GuildChannelUpdated(self.bot, self.log, before, after) + await self.dispatch_event( + events.DiscordAutomodBlockedMemberInteractions(execution) ) - # @@ THREADS + # @@ CHANNELS - async def on_thread_create(self, thread: Thread): - await self._do_event(events.ThreadCreated(self.bot, self.log, thread)) + async def on_guild_channel_created(self, channel: GuildChannel): + await self.dispatch_event(events.GuildChannelCreated(channel)) - async def on_thread_join(self, thread: Thread): - await self._do_event(events.ThreadJoined(self.bot, self.log, thread)) + async def on_guild_channel_deleted(self, channel: GuildChannel): + await self.dispatch_event(events.GuildChannelDeleted(channel)) - 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_guild_channel_updated(self, before: GuildChannel, after: GuildChannel): + await self.dispatch_event(events.GuildChannelUpdated(before, after)) - async def on_thread_remove(self, thread: Thread): - await self._do_event(events.ThreadRemoved(self.bot, self.log, thread)) + async def on_guild_channel_pins_updated( + self, channel: MessageableGuildChannel | Thread, last_pin: Optional[datetime] + ): + await self.dispatch_event(events.GuildChannelPinsUpdated(channel, last_pin)) - async def on_thread_delete(self, thread: Thread): - await self._do_event(events.ThreadDeleted(self.bot, self.log, thread)) + # @@ MEMBERS - async def on_thread_member_join(self, member: ThreadMember): - await self._do_event(events.ThreadMemberJoined(self.bot, self.log, member)) + async def on_member_joined(self, member: Member): + await self.dispatch_event(events.MemberJoined(member)) - async def on_thread_member_remove(self, member: ThreadMember): - await self._do_event(events.ThreadMemberLeft(self.bot, self.log, member)) + async def on_member_left(self, member: Member): + await self.dispatch_event(events.MemberLeft(member)) - # @@ MEMBERS + async def on_member_typing( + self, channel: MessageableGuildChannel | Thread, member: Member, when: datetime + ): + await self.dispatch_event(events.MemberTyping(channel, member, when)) - async def on_member_join(self, member: Member): - await self._do_event(events.MemberJoined(self.bot, self.log, member)) + async def on_member_updated(self, before: Member, after: Member): + await self.dispatch_event(events.MemberUpdated(before, after)) - async def on_member_remove(self, member: Member): - await self._do_event(events.MemberLeft(self.bot, self.log, member)) + # @@ MESSAGES - 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_message_sent(self, message: Message): + await self.dispatch_event(events.MessageSent(message)) - 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) - ) + 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_created(self, thread: Thread): + await self.dispatch_event(events.ThreadCreated(thread)) + + async def on_thread_joined(self, thread: Thread): + await self.dispatch_event(events.ThreadJoined(thread)) + + async def on_thread_updated(self, before: Thread, after: Thread): + await self.dispatch_event(events.ThreadUpdated(before, after)) + + async def on_thread_removed(self, thread: Thread): + await self.dispatch_event(events.ThreadRemoved(thread)) - async def on_user_ban(self, user: User): - await self._do_event(events.UserBanned(self.bot, self.log, user)) + async def on_thread_deleted(self, thread: Thread): + await self.dispatch_event(events.ThreadDeleted(thread)) - async def on_user_unban(self, user: User): - await self._do_event(events.UserUnbanned(self.bot, self.log, user)) + async def on_thread_member_joined(self, member: ThreadMember): + await self.dispatch_event(events.ThreadMemberJoined(member)) - # @@ RAW EVENT HANDLERS + async def on_thread_member_left(self, member: ThreadMember): + await self.dispatch_event(events.ThreadMemberLeft(member)) - async def on_raw_message_delete(self, payload: RawMessageDeleteEvent): - await self._do_event(events.RawMessageDeleted(self.bot, self.log, payload)) + # @@ USERS - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent): - await self._do_event(events.RawMessageEdited(self.bot, self.log, payload)) + async def on_user_updated(self, before: User, after: User): + await self.dispatch_event(events.UserUpdated(before, after)) - async def on_raw_reaction_add(self, payload: RawReactionActionEvent): - await self._do_event(events.RawReactionAdded(self.bot, self.log, payload)) + async def on_member_banned(self, user: User): + await self.dispatch_event(events.MemberBanned(user)) - async def on_raw_reaction_remove(self, payload: RawReactionActionEvent): - await self._do_event(events.RawReactionRemoved(self.bot, self.log, payload)) + async def on_member_unbanned(self, user: User): + await self.dispatch_event(events.MemberUnbanned(user)) diff --git a/commanderbot/ext/automod/automod_json_store.py b/commanderbot/ext/automod/automod_json_store.py deleted file mode 100644 index ab80fdb5..00000000 --- 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.cogs.database 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_options.py b/commanderbot/ext/automod/automod_options.py index 0defbebf..0d26e988 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.cogs.database 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 diff --git a/commanderbot/ext/automod/automod_rule.py b/commanderbot/ext/automod/automod_rule.py deleted file mode 100644 index 8e560066..00000000 --- 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_store.py b/commanderbot/ext/automod/automod_store.py index 3c21e8cf..6bba8ac6 100644 --- a/commanderbot/ext/automod/automod_store.py +++ b/commanderbot/ext/automod/automod_store.py @@ -1,56 +1,247 @@ -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( self, guild: Guild, - name: str, - path: utils.JsonPath, - op: utils.JsonPathOp, - data: Any, - ) -> AutomodRule: ... + ) -> LogChannel: + cache = await self.db.get_cache() + guild_data = cache.guilds[guild.id] + return guild_data.require_default_log() - 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( + self, + guild: Guild, + ) -> Optional[LogChannel]: + cache = await self.db.get_cache() + guild_data = cache.guilds[guild.id] + return guild_data.get_default_log() + + async def set_default_log(self, guild: Guild, log: LogChannel) -> LogChannel: + cache = await self.db.get_cache() + guild_data = cache.guilds[guild.id] + result = guild_data.set_default_log(log) + await self.db.commit() + return result + + async def modify_default_log( + self, guild: Guild, log: LogChannel + ) -> tuple[LogChannel, LogChannel]: + cache = await self.db.get_cache() + guild_data = cache.guilds[guild.id] + result = guild_data.modify_default_log(log) + await self.db.commit() + return result + + async def remove_default_log( + self, + guild: Guild, + ) -> LogChannel: + cache = await self.db.get_cache() + 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() + 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() + 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() + 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() + 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() + guild_data = cache.guilds[guild.id] + result = guild_data.add_rule(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() + guild_data = cache.guilds[guild.id] + result = guild_data.modify_rule(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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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 + ) -> tuple[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) -> AutomodBucket: + cache = await self.db.get_cache() + 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() + 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() + 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() + 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() + 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() + guild_data = cache.guilds[guild.id] + guild_data.disable_all_buckets() + await self.db.commit() diff --git a/commanderbot/ext/automod/automod_trigger.py b/commanderbot/ext/automod/automod_trigger.py deleted file mode 100644 index 6571704a..00000000 --- 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/bucket/__init__.py b/commanderbot/ext/automod/bucket/__init__.py new file mode 100644 index 00000000..1f5ab66e --- /dev/null +++ b/commanderbot/ext/automod/bucket/__init__.py @@ -0,0 +1,3 @@ +from .bucket_base import * +from .bucket_ref 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 00000000..37c1c9d6 --- /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.""" + ... + + @abstractmethod + async def clear(self): + """Clear all data in the bucket.""" + ... diff --git a/commanderbot/ext/automod/bucket/bucket_ref.py b/commanderbot/ext/automod/bucket/bucket_ref.py new file mode 100644 index 00000000..d53f5946 --- /dev/null +++ b/commanderbot/ext/automod/bucket/bucket_ref.py @@ -0,0 +1,72 @@ +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 + ), + ) diff --git a/commanderbot/ext/automod/bucket/types.py b/commanderbot/ext/automod/bucket/types.py new file mode 100644 index 00000000..e309d594 --- /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 00000000..9cc456ce --- /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/flagged_image_attachments.py b/commanderbot/ext/automod/buckets/flagged_image_attachments.py new file mode 100644 index 00000000..7347d1e0 --- /dev/null +++ b/commanderbot/ext/automod/buckets/flagged_image_attachments.py @@ -0,0 +1,90 @@ +from datetime import datetime +from typing import Literal, override + +from discord import Attachment +from discord.utils import utcnow +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.timedelta import Timedelta +from commanderbot.lib.types import AttachmentID + +__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, exclude_if=lambda v: not v + ) + + 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, threshold: int = 0) -> bool: + for flagged_attachment in self.attachments.values(): + if flagged_attachment.phash - phash <= threshold: + 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 + if phash is None: + continue + + # 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() diff --git a/commanderbot/ext/automod/buckets/message_history.py b/commanderbot/ext/automod/buckets/message_history.py new file mode 100644 index 00000000..20ceaee5 --- /dev/null +++ b/commanderbot/ext/automod/buckets/message_history.py @@ -0,0 +1,199 @@ +from collections import defaultdict +from collections.abc import Iterable +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import 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.timedelta import Timedelta +from commanderbot.lib.types import ChannelID, MessageID, 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()) + + @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()): + 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, 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/condition/__init__.py b/commanderbot/ext/automod/condition/__init__.py new file mode 100644 index 00000000..cd451c8e --- /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 00000000..575a72b9 --- /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: bool = False + """Is the condition disabled?""" + + @abstractmethod + async def check(self, context: AutomodContext) -> bool: + """Check whether the condition passes.""" + ... diff --git a/commanderbot/ext/automod/condition/types.py b/commanderbot/ext/automod/condition/types.py new file mode 100644 index 00000000..a6287162 --- /dev/null +++ b/commanderbot/ext/automod/condition/types.py @@ -0,0 +1,50 @@ +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.HasFlaggedImageAttachments + | conditions.ImageAttachmentsContain + | conditions.Inverted + | conditions.MessageContentContains + | conditions.MessageContentMatches + | conditions.MessageHasAttachments + | conditions.MessageHasEmbeds + | conditions.MessageHasLinks + | conditions.MessageHasMentions + | conditions.MessageHasReactions + | conditions.MessageMentionsRoles + | conditions.MessageMentionsUsers + | conditions.RandomChance + | conditions.Randomize + | conditions.ReturnFalse + | conditions.ReturnTrue + | 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 e69de29b..234be90f 100644 --- a/commanderbot/ext/automod/conditions/__init__.py +++ b/commanderbot/ext/automod/conditions/__init__.py @@ -0,0 +1,36 @@ +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 .has_flagged_image_attachments import * +from .image_attachments_contain 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_has_reactions import * +from .message_mentions_roles import * +from .message_mentions_users 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/abc/__init__.py b/commanderbot/ext/automod/conditions/abc/__init__.py index e69de29b..852ca2a3 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 00000000..752b71f9 --- /dev/null +++ b/commanderbot/ext/automod/conditions/abc/target_account_age.py @@ -0,0 +1,39 @@ +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.timedelta 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]: ... + + @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 19ed387c..00000000 --- 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 00000000..6771a401 --- /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]: + ... + + @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 00000000..b93b14e4 --- /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]: + ... + + @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 00000000..583878d5 --- /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]: + ... + + @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 cf6463b0..00000000 --- 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 00000000..97a45589 --- /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]: + ... + + @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 05990ecb..00000000 --- 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 00000000..0471c27c --- /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]: + ... + + @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 bd220eb5..00000000 --- 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 00000000..4ed4479e --- /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]: + ... + + @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 b7a929c9..00000000 --- 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 00000000..4473da0a --- /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.timedelta 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]: + ... + + @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 69a4b94e..00000000 --- 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 00000000..51f8024f --- /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]: + ... + + @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 89b1c3ef..00000000 --- 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 c2a065a5..91642b28 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"] -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 00000000..93c6f14d --- /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"] + + @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 00000000..d86bfd28 --- /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"] + + @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 2d0a38c8..e8b068b6 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. + Check if the actor is a bot. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor + type: Literal["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 222712cc..5d0b7c79 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 is not a bot. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor + type: Literal["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 7f2812c3..00000000 --- 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 8d62564f..00000000 --- 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 c7eb3361..84d17917 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"] -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 b501e64b..d1d8f3ba 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. + Check if the actor has certain roles. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.actor - + type: Literal["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 feea5adf..b1d2f1e9 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"] - @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(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 509f03e3..29a39942 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"] - 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(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 9a6012ac..0421caea 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"] -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 00000000..8cb0c5fe --- /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"] + + @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 00000000..1b261a0f --- /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"] + + @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 7c511d55..f53b5fa0 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. + Check if the author is a bot. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author + type: Literal["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 dc18b7e4..fd6bf688 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 is not a bot. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author + type: Literal["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 437b1765..ede15e74 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 is not the bot itself. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author + type: Literal["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 49022643..f60105db 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. + Check if the author is the bot itself. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author + type: Literal["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 db7c1580..a323cd88 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"] -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 b3f43683..5ec21ce8 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. + Check if the author has certain roles. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: - return event.author - + type: Literal["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/has_flagged_image_attachments.py b/commanderbot/ext/automod/conditions/has_flagged_image_attachments.py new file mode 100644 index 00000000..e0d85318 --- /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 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 00000000..b3f11075 --- /dev/null +++ b/commanderbot/ext/automod/conditions/image_attachments_contain.py @@ -0,0 +1,190 @@ +import asyncio +import unicodedata +from itertools import chain +from logging import Logger +from typing import Any, ClassVar, Literal, Optional, cast, override + +import pebble +from pydantic import Field, PositiveFloat, 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.constants import IMAGE_MIME_TYPES +from commanderbot.lib.constants import MAX_ATTACHMENTS, SUPPORTS_OCR +from commanderbot.lib.types import ( + AttachmentID, + 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 in context and check if they contain a number of substrings. + """ + + type: Literal["image_attachments_contain"] + + languages: set[TesseractLanguages] = Field( + default_factory=lambda: cast(set[TesseractLanguages], {"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: 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_ATTACHMENTS) + return cls._semaphore + + @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_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) + if not image_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 + assert is_commander_bot(context.bot) + pool = context.bot.pool + log = context.log + tasks = [ + 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): + 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: + # 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: + # Throw an exception if OCR isn't supported + if not SUPPORTS_OCR: + raise OCRNotSupported + + # OCR image attachments + if attachment_id := await self._ocr(context): + context.metadata.flagged_attachments.append(attachment_id) + return True + + # If we got this far, none of the attachments passed the condition + return False diff --git a/commanderbot/ext/automod/conditions/inverted.py b/commanderbot/ext/automod/conditions/inverted.py new file mode 100644 index 00000000..f1ff5632 --- /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"] + + condition: AutomodConditionType + """The sub-condition to check.""" + + @override + async def check(self, context: AutomodContext) -> bool: + return not await 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 90e45364..00000000 --- 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 c4e90ff8..065e5d1d 100644 --- a/commanderbot/ext/automod/conditions/message_content_contains.py +++ b/commanderbot/ext/automod/conditions/message_content_contains.py @@ -1,95 +1,71 @@ import unicodedata -from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Any, 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 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__ = ("MessageContentContains",) -DEFAULT_NORMALIZATION_FORM = "NFKD" +class MessageContentContains(AutomodCondition): + """ + Check if the message content contains a number of substrings. + """ + + type: Literal["message_content_contains"] + + contains: set[str] = Field(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 - 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): + 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.""" + + @override + def model_post_init(self, context: Any): + if self.ignore_case: + self.contains = {s.lower() for s in self.contains} + + @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 deb07f57..5fef2ecc 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"] + + 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. + """ -@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 - 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: + use_search: bool = False + """Whether to search the entire string instead of using an anchored match.""" + + 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.""" + + 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 7e33f6a6..7aea54e0 100644 --- a/commanderbot/ext/automod/conditions/message_has_attachments.py +++ b/commanderbot/ext/automod/conditions/message_has_attachments.py @@ -1,46 +1,33 @@ -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"] - @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: + # We need to have attachments in context + attachments = context.event.attachments + if not attachments: return False - count_attachments = len(message.attachments or []) - if self.count is not None: - return self.count.includes(count_attachments) - return count_attachments > 0 + # Check if the number of attachments is in the range + if self.count: + attachment_count = len(attachments) + return self.count.includes(attachment_count) -def create_condition(data: JsonObject) -> AutomodCondition: - return MessageHasAttachments.from_data(data) + # Otherwise, return `True` since we have some number of attachments + return True diff --git a/commanderbot/ext/automod/conditions/message_has_embeds.py b/commanderbot/ext/automod/conditions/message_has_embeds.py index bc867d8a..0efeea65 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"] - @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 071ddd98..616bb259 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"] - 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 57570a16..a753c880 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"] - @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_has_reactions.py b/commanderbot/ext/automod/conditions/message_has_reactions.py new file mode 100644 index 00000000..1c09a574 --- /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 diff --git a/commanderbot/ext/automod/conditions/message_mentions_roles.py b/commanderbot/ext/automod/conditions/message_mentions_roles.py index 79680864..2ed410fe 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"] + 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.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.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 f168ece6..c0366997 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"] + + 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.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.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 deleted file mode 100644 index 6e5fffa1..00000000 --- a/commanderbot/ext/automod/conditions/none_of.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 NoneOf(AutomodConditionBase): - """ - Passes if and only if none of the sub-conditions pass. - - 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 await condition.check(event): - 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 a208b66b..00000000 --- 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/ocr_utils/__init__.py b/commanderbot/ext/automod/conditions/ocr_utils/__init__.py new file mode 100644 index 00000000..821dc6dc --- /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 00000000..632c83f2 --- /dev/null +++ b/commanderbot/ext/automod/conditions/ocr_utils/pipeline.py @@ -0,0 +1,282 @@ +from typing import Optional, overload + +import cv2 +import numpy as np +from cv2.typing import MatLike +from tesserocr import OEM, PSM, RIL, PyResultIterator, PyTessBaseAPI, iterate_level + +from commanderbot.lib.types import AttachmentID + +__all__ = ("get_text",) + + +@overload +def get_text(image_data: bytes, lang: str) -> Optional[str]: ... + + +@overload +def get_text( + image_data: bytes, lang: str, attachment_id: AttachmentID +) -> Optional[tuple[str, AttachmentID]]: ... + + +def get_text( + 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) + + # 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) + 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[ty:invalid-argument-type] - Why is this even expecting a string + width=width, + height=height, + 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] = [] + 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: + """ + 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[ty:invalid-argument-type] - Why is this even expecting a string + 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/conditions/random_chance.py b/commanderbot/ext/automod/conditions/random_chance.py new file mode 100644 index 00000000..18339b05 --- /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"] + + 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/randomize.py b/commanderbot/ext/automod/conditions/randomize.py new file mode 100644 index 00000000..8df0d8c2 --- /dev/null +++ b/commanderbot/ext/automod/conditions/randomize.py @@ -0,0 +1,70 @@ +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"] + + conditions: list[AutomodConditionType | WeightedCondition] = Field(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 00000000..5102a985 --- /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"] + + @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 00000000..4e9db373 --- /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"] + + @override + async def check(self, context: AutomodContext) -> bool: + return True 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 267a3d2c..00000000 --- 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 2d52a3c6..44d21721 100644 --- a/commanderbot/ext/automod/conditions/throw_error.py +++ b/commanderbot/ext/automod/conditions/throw_error.py @@ -1,31 +1,24 @@ -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.automod_exceptions import AutomodException +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. """ - error: str - - async def check(self, event: AutomodEvent) -> bool: - raise Exception(self.error) + type: Literal["throw_error"] + error: str + """A human-readable error message.""" -def create_condition(data: JsonObject) -> AutomodCondition: - return ThrowError.from_data(data) + @override + async def check(self, context: AutomodContext) -> bool: + raise AutomodException(self.error) diff --git a/commanderbot/ext/automod/conditions/wait.py b/commanderbot/ext/automod/conditions/wait.py index 05e61369..7a8cb01e 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.timedelta 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"] - @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/constants.py b/commanderbot/ext/automod/constants.py new file mode 100644 index 00000000..566392fa --- /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/enums.py b/commanderbot/ext/automod/enums.py new file mode 100644 index 00000000..92de18a7 --- /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/event/__init__.py b/commanderbot/ext/automod/event/__init__.py new file mode 100644 index 00000000..b72fc642 --- /dev/null +++ b/commanderbot/ext/automod/event/__init__.py @@ -0,0 +1 @@ +from .event_base import * diff --git a/commanderbot/ext/automod/event/event_base.py b/commanderbot/ext/automod/event/event_base.py new file mode 100644 index 00000000..f3aa7d58 --- /dev/null +++ b/commanderbot/ext/automod/event/event_base.py @@ -0,0 +1,70 @@ +from abc import ABC +from dataclasses import dataclass +from typing import Optional + +from discord import Attachment, CategoryChannel, Member, Message, Reaction, Thread, User + +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. + """ + + @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 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.""" + 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 diff --git a/commanderbot/ext/automod/events/__init__.py b/commanderbot/ext/automod/events/__init__.py index 8ac746bc..c074c22e 100644 --- a/commanderbot/ext/automod/events/__init__.py +++ b/commanderbot/ext/automod/events/__init__.py @@ -1,17 +1,20 @@ +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_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 * +from .message_frequency 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 * @@ -21,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/discord_automod/blocked_member_interactions.py b/commanderbot/ext/automod/events/discord_automod/blocked_member_interactions.py new file mode 100644 index 00000000..339e3ff9 --- /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 00000000..e2677891 --- /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 00000000..9db447c9 --- /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 1bf167e0..7d8bc67d 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 2f89f930..e5145cd3 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 00000000..4ec4ec37 --- /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 efa8fcf9..c17b4aa7 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_banned.py b/commanderbot/ext/automod/events/member_banned.py new file mode 100644 index 00000000..35d96d02 --- /dev/null +++ b/commanderbot/ext/automod/events/member_banned.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import override + +from discord import User + +from commanderbot.ext.automod.event import AutomodEvent + +__all__ = ("MemberBanned",) + + +@dataclass +class MemberBanned(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/member_joined.py b/commanderbot/ext/automod/events/member_joined.py index 1d5f8108..8ec8b33b 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 034350e1..2cde765f 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 be3423fd..3a141654 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_unbanned.py b/commanderbot/ext/automod/events/member_unbanned.py new file mode 100644 index 00000000..07575edc --- /dev/null +++ b/commanderbot/ext/automod/events/member_unbanned.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import override + +from discord import User + +from commanderbot.ext.automod.event import AutomodEvent + +__all__ = ("MemberUnbanned",) + + +@dataclass +class MemberUnbanned(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/member_updated.py b/commanderbot/ext/automod/events/member_updated.py index 4d71688a..6b386faa 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 ada2cf12..6de321f3 100644 --- a/commanderbot/ext/automod/events/message_deleted.py +++ b/commanderbot/ext/automod/events/message_deleted.py @@ -1,33 +1,60 @@ from dataclasses import dataclass +from typing import Optional, override -from discord import Member, TextChannel, Thread +from discord import Attachment, 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: - return self._message.author + @override + def attachments(self) -> list[Attachment]: + return self._message.attachments @property - def actor(self) -> Member: + @override + def author(self) -> Member | User: return self._message.author @property - def member(self) -> Member: + @override + def actor(self) -> Member | User: return self._message.author + + @property + @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 2cbbdac9..cd66ff28 100644 --- a/commanderbot/ext/automod/events/message_edited.py +++ b/commanderbot/ext/automod/events/message_edited.py @@ -1,34 +1,66 @@ from dataclasses import dataclass +from typing import override -from discord import Member, TextChannel, Thread +from discord import Attachment, 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 + @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 channel(self) -> TextChannel | Thread: - return self._after.channel + @override + def message(self) -> Message: + return self.after @property - def message(self) -> TextMessage: - return self._after + @override + def attachments(self) -> list[Attachment]: + return self.after.attachments @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) -> User: + assert is_user(self.after.author) + return self.after.author diff --git a/commanderbot/ext/automod/events/message_frequency.py b/commanderbot/ext/automod/events/message_frequency.py new file mode 100644 index 00000000..18e15a64 --- /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/events/message_sent.py b/commanderbot/ext/automod/events/message_sent.py index e2a97be5..3f6241b8 100644 --- a/commanderbot/ext/automod/events/message_sent.py +++ b/commanderbot/ext/automod/events/message_sent.py @@ -1,33 +1,62 @@ from dataclasses import dataclass +from typing import override -from discord import Member, TextChannel, Thread +from discord import Attachment, 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 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/events/raw_message_deleted.py b/commanderbot/ext/automod/events/raw_message_deleted.py deleted file mode 100644 index 39301005..00000000 --- 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 47269f90..00000000 --- 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 ff3c4b2d..00000000 --- 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 f9046033..00000000 --- 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 4551672f..eb3345b9 100644 --- a/commanderbot/ext/automod/events/reaction_added.py +++ b/commanderbot/ext/automod/events/reaction_added.py @@ -1,38 +1,60 @@ from dataclasses import dataclass +from typing import override -from discord import Member, TextChannel, Thread +from discord import Attachment, 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 attachments(self) -> list[Attachment]: + return self.reaction.message.attachments + + @property + @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 61478341..e5c440e4 100644 --- a/commanderbot/ext/automod/events/reaction_removed.py +++ b/commanderbot/ext/automod/events/reaction_removed.py @@ -1,38 +1,60 @@ from dataclasses import dataclass +from typing import override -from discord import Member, TextChannel, Thread +from discord import Attachment, 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 attachments(self) -> list[Attachment]: + return self.reaction.message.attachments + + @property + @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 8976e28c..1bbd51d1 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 5adc9478..ec1cb037 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 982b8b35..9354543b 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 4211da92..e7a23c09 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 972fa6a1..4f1295ef 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 849a3b2a..12d1fe31 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 aca5154e..f1f76e2d 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 deleted file mode 100644 index a5b3b8a5..00000000 --- a/commanderbot/ext/automod/events/user_banned.py +++ /dev/null @@ -1,16 +0,0 @@ -from dataclasses import dataclass - -from discord import User - -from commanderbot.ext.automod.automod_event import AutomodEventBase - -__all__ = ("UserBanned",) - - -@dataclass -class UserBanned(AutomodEventBase): - _user: User - - @property - 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 deleted file mode 100644 index 5c418ff5..00000000 --- a/commanderbot/ext/automod/events/user_unbanned.py +++ /dev/null @@ -1,16 +0,0 @@ -from dataclasses import dataclass - -from discord import User - -from commanderbot.ext.automod.automod_event import AutomodEventBase - -__all__ = ("UserUnbanned",) - - -@dataclass -class UserUnbanned(AutomodEventBase): - _user: User - - @property - 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 78e1c2b2..22ac8864 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 diff --git a/commanderbot/ext/automod/guards/__init__.py b/commanderbot/ext/automod/guards/__init__.py new file mode 100644 index 00000000..6f62abce --- /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 00000000..066cf89d --- /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 00000000..82d95c19 --- /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 00000000..99232a4e --- /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 00000000..ef133836 --- /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 00000000..25ea91a9 --- /dev/null +++ b/commanderbot/ext/automod/guards/flags.py @@ -0,0 +1,118 @@ +from collections.abc import Generator +from typing import Any, 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, min_length=1) + + 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, *, 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: + set_flags += 1 + if set_flags == count: + 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: + default_schema = handler(source_type) + from_list_schema = core_schema.chain_schema( + [ + core_schema.set_schema( + core_schema.literal_schema( + [*get_args(PublicUserFlagsNames.__value__), *get_args(MemberFlagsNames.__value__)] + ), + 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, + default_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 00000000..56529d0f --- /dev/null +++ b/commanderbot/ext/automod/guards/integer_range.py @@ -0,0 +1,79 @@ +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",) + + +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).""" + + 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: + 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(), + 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 + ), + ) + + 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 00000000..eb82c82c --- /dev/null +++ b/commanderbot/ext/automod/guards/reactions.py @@ -0,0 +1,67 @@ +from collections.abc import Iterable +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 times the reaction was made.""" + + 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) + ) + + 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)] diff --git a/commanderbot/ext/automod/guards/roles.py b/commanderbot/ext/automod/guards/roles.py new file mode 100644 index 00000000..819b8420 --- /dev/null +++ b/commanderbot/ext/automod/guards/roles.py @@ -0,0 +1,84 @@ +from collections.abc import Iterable +from typing import Optional + +from discord import Member, Role, User +from pydantic import BaseModel, ConfigDict, Field + +from commanderbot.lib.predicates import is_member +from commanderbot.lib.types import RoleID + +__all__ = ("RolesGuard",) + + +class RolesGuard(BaseModel): + """ + Checks whether a role or a member's roles matches a set of roles. + """ + + model_config = ConfigDict(use_attribute_docstrings=True) + + include: set[RoleID] = Field(default_factory=set) + """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 role will match if it's not in this set. + """ + + 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 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 not is_member(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/ext/automod/rule/__init__.py b/commanderbot/ext/automod/rule/__init__.py new file mode 100644 index 00000000..2575fa88 --- /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 00000000..6f5251f1 --- /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 00000000..86a68c02 --- /dev/null +++ b/commanderbot/ext/automod/rule/rule.py @@ -0,0 +1,87 @@ +from typing import Optional + +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 +from commanderbot.lib.log_channel import LogChannel + +__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: bool = False + """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.""" + + conditions: list[AutomodConditionType] = Field(default_factory=list) + """A list of conditions that must *all* pass for the actions to run.""" + + 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: + 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): + for action in self.actions: + if action.disabled: + continue + await action.apply(context) + + async def run(self, context: AutomodContext) -> bool: + if self.disabled: + return False + + if not await self._poll_triggers(context): + return False + + if not await self._check_conditions(context): + return False + + 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() diff --git a/commanderbot/ext/automod/trigger/__init__.py b/commanderbot/ext/automod/trigger/__init__.py new file mode 100644 index 00000000..6a6b05c1 --- /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 00000000..7ef6727e --- /dev/null +++ b/commanderbot/ext/automod/trigger/trigger_base.py @@ -0,0 +1,49 @@ +from abc import ABC +from typing import ClassVar, Optional + +from pydantic import BaseModel, ConfigDict + +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.event import AutomodEvent + +__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: bool = False + """Is the trigger disabled?""" + + event_types: ClassVar[tuple[type[AutomodEvent], ...]] = () + + async def poll(self, context: AutomodContext) -> bool: + """Checks whether an event activates the trigger.""" + + # 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: + return False + + # Skip event if it should be ignored + if await self.ignore(context): + return False + + # We probably care about the event if we got this far + return True + + 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/trigger/types.py b/commanderbot/ext/automod/trigger/types.py new file mode 100644 index 00000000..c0c8e29f --- /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__ = ("AutomodTriggerType",) + +type _AutomodTriggerType = ( + triggers.DiscordAutomodBlockedMemberInteractions + | triggers.DiscordAutomodBlockedMessage + | triggers.DiscordAutomodTimedOut + | triggers.MemberBanned + | triggers.MemberGainedFlags + | triggers.MemberJoined + | triggers.MemberLeft + | triggers.MemberLostFlags + | triggers.MemberTimeoutEnded + | triggers.MemberTimeoutStarted + | triggers.MemberTyping + | triggers.MemberUnbanned + | triggers.MentionsRemovedFromMessage + | triggers.MessageDeleted + | triggers.MessageEdited + | triggers.MessageFrequency + | triggers.MessageSent + | triggers.Message + | triggers.ReactionAdded + | triggers.ReactionRemoved + | triggers.Reaction + | triggers.ThreadCreated + | triggers.ThreadDeleted +) + +type AutomodTriggerType = Annotated[_AutomodTriggerType, Field(discriminator="type")] diff --git a/commanderbot/ext/automod/triggers/__init__.py b/commanderbot/ext/automod/triggers/__init__.py index e69de29b..468f8204 100644 --- a/commanderbot/ext/automod/triggers/__init__.py +++ b/commanderbot/ext/automod/triggers/__init__.py @@ -0,0 +1,23 @@ +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_timeout_ended import * +from .member_timeout_started import * +from .member_typing import * +from .member_unbanned import * +from .mentions_removed_from_message import * +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 .reaction_removed import * +from .thread_created import * +from .thread_deleted import * diff --git a/commanderbot/ext/automod/triggers/abc/__init__.py b/commanderbot/ext/automod/triggers/abc/__init__.py deleted file mode 100644 index e69de29b..00000000 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 014edc63..00000000 --- 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 00000000..7a320ba3 --- /dev/null +++ b/commanderbot/ext/automod/triggers/discord_automod/blocked_member_interactions.py @@ -0,0 +1,35 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.guards import DiscordAutomodRulesGuard, RolesGuard +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("DiscordAutomodBlockedMemberInteractions",) + + +class DiscordAutomodBlockedMemberInteractions(AutomodTrigger): + """ + Triggers when Discord's automod blocks member interactions. + """ + + type: Literal["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, context: AutomodContext) -> bool: + assert isinstance(context.event, events.DiscordAutomodBlockedMemberInteractions) + + if self.rules and self.rules.ignore(context.event.rule_id): + return True + + 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 new file mode 100644 index 00000000..1278fb67 --- /dev/null +++ b/commanderbot/ext/automod/triggers/discord_automod/blocked_message.py @@ -0,0 +1,59 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, + DiscordAutomodRulesGuard, + RolesGuard, +) +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("DiscordAutomodBlockedMessage",) + + +class DiscordAutomodBlockedMessage(AutomodTrigger): + """ + Triggers when Discord's automod blocks a message. + """ + + type: Literal["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, context: AutomodContext) -> bool: + assert isinstance(context.event, events.DiscordAutomodBlockedMessage) + + if self.rules and self.rules.ignore(context.event.rule_id): + return True + + if self.categories and self.categories.ignore(context.event.category): + return True + + if self.channel_types and self.channel_types.ignore(context.event.channel): + return True + + if self.channels and self.channels.ignore(context.event.channel): + return True + + 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 new file mode 100644 index 00000000..0c27413b --- /dev/null +++ b/commanderbot/ext/automod/triggers/discord_automod/timed_out.py @@ -0,0 +1,35 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.guards import DiscordAutomodRulesGuard, RolesGuard +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("DiscordAutomodTimedOut",) + + +class DiscordAutomodTimedOut(AutomodTrigger): + """ + Triggers when Discord's automod times out a member. + """ + + type: Literal["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, context: AutomodContext) -> bool: + assert isinstance(context.event, events.DiscordAutomodTimedOut) + + if self.rules and self.rules.ignore(context.event.rule_id): + return True + + if self.roles and self.roles.ignore(context.event.member): + return True + + return False diff --git a/commanderbot/ext/automod/triggers/member_banned.py b/commanderbot/ext/automod/triggers/member_banned.py new file mode 100644 index 00000000..068ebb81 --- /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_gained_flags.py b/commanderbot/ext/automod/triggers/member_gained_flags.py new file mode 100644 index 00000000..36a67700 --- /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.automod_context import AutomodContext +from commanderbot.ext.automod.guards import FlagsGuard, RolesGuard +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("MemberGainedFlags",) + + +class MemberGainedFlags(AutomodTrigger): + """ + Triggers when a member gains certain flags. + """ + + type: Literal["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, context: AutomodContext) -> bool: + event = context.event + 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 dc850657..46b92aeb 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"] 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 6bd56ba3..71950cc4 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"] 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 00000000..c58711de --- /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.automod_context import AutomodContext +from commanderbot.ext.automod.guards import FlagsGuard, RolesGuard +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("MemberLostFlags",) + + +class MemberLostFlags(AutomodTrigger): + """ + Triggers when a member loses certain flags. + """ + + type: Literal["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, context: AutomodContext) -> bool: + event = context.event + 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_timeout_ended.py b/commanderbot/ext/automod/triggers/member_timeout_ended.py new file mode 100644 index 00000000..7c13bc6a --- /dev/null +++ b/commanderbot/ext/automod/triggers/member_timeout_ended.py @@ -0,0 +1,28 @@ +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. + + Due to a Discord limitation, this will not trigger when a member's timeout expires. + """ + + 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.is_timed_out() + timeout_after = event.after.is_timed_out() + + 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 new file mode 100644 index 00000000..fe4b5370 --- /dev/null +++ b/commanderbot/ext/automod/triggers/member_timeout_started.py @@ -0,0 +1,26 @@ +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.is_timed_out() + timeout_after = event.after.is_timed_out() + + return not timeout_before and timeout_after diff --git a/commanderbot/ext/automod/triggers/member_typing.py b/commanderbot/ext/automod/triggers/member_typing.py index 563092f9..f85a058e 100644 --- a/commanderbot/ext/automod/triggers/member_typing.py +++ b/commanderbot/ext/automod/triggers/member_typing.py @@ -1,72 +1,52 @@ -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.automod_context import AutomodContext +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, + RolesGuard, +) +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"] 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, context: AutomodContext) -> bool: + assert isinstance(context.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(context.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(context.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(context.event.channel): + return True + if self.roles and self.roles.ignore(context.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_unbanned.py b/commanderbot/ext/automod/triggers/member_unbanned.py new file mode 100644 index 00000000..6ce214b3 --- /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/member_updated.py b/commanderbot/ext/automod/triggers/member_updated.py deleted file mode 100644 index c4f22adb..00000000 --- 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 7a2c413e..3b162f7b 100644 --- a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py +++ b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py @@ -1,158 +1,143 @@ -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.automod_context import AutomodContext +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, + RolesGuard, +) +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"] 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, context: AutomodContext) -> bool: + event = context.event + 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 + removed_user_mentions.discard(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 context + context.metadata.removed_mentions = " ".join( + (m.mention for m in removed_mentions) + ) + context.metadata.removed_mention_names = " ".join( + (f"`{m}`" for m in removed_mentions) + ) - # Build removed user mention fields, if any. + # Add removed user mentions fields to context if removed_user_mentions: - event.set_metadata( - "removed_user_mentions", - " ".join(user.mention for user in removed_user_mentions), + context.metadata.removed_mentions = " ".join( + (m.mention for m in removed_user_mentions) ) - event.set_metadata( - "removed_user_mention_names", - " ".join(f"`{user}`" for user in removed_user_mentions), + context.metadata.removed_mention_names = " ".join( + (f"`{m}`" for m in removed_user_mentions) ) - # Build removed role mention fields, if any. + # Add removed role mentions fields to context if removed_role_mentions: - event.set_metadata( - "removed_role_mentions", - " ".join(role.mention for role in removed_role_mentions), - ) - event.set_metadata( - "removed_role_mention_names", - " ".join(f"`{role}`" for role 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), + context.metadata.removed_role_mentions = " ".join( + (m.mention for m in removed_role_mentions) ) - event.set_metadata( - "removed_mention_names", - " ".join(f"`{thing}`" for thing in removed_mentions), + context.metadata.removed_user_mention_names = " ".join( + (f"`{m}`" for m in removed_role_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 151493fe..989cb789 100644 --- a/commanderbot/ext/automod/triggers/message.py +++ b/commanderbot/ext/automod/triggers/message.py @@ -1,89 +1,54 @@ -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.automod_context import AutomodContext +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, + RolesGuard, +) +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"] 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, context: AutomodContext) -> bool: + assert isinstance( + context.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(context.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(context.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(context.event.channel): + return True + if self.author_roles and self.author_roles.ignore(context.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 540979f0..72821486 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"] 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 36a176c9..2f611d6d 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"] event_types = (events.MessageEdited,) - - -def create_trigger(data: JsonObject) -> AutomodTrigger: - return MessageEdited.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_frequency.py b/commanderbot/ext/automod/triggers/message_frequency.py new file mode 100644 index 00000000..1cd53861 --- /dev/null +++ b/commanderbot/ext/automod/triggers/message_frequency.py @@ -0,0 +1,51 @@ +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.timedelta 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 + + # Build the message history out of our timeframe + message = context.event.message + since = message.created_at - self.timeframe + message_history = bucket.build_message_history_since(message.author, since) + + # 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) diff --git a/commanderbot/ext/automod/triggers/message_sent.py b/commanderbot/ext/automod/triggers/message_sent.py index d2167862..394fbcaf 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"] 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 7f46b0e2..dfb86dd5 100644 --- a/commanderbot/ext/automod/triggers/reaction.py +++ b/commanderbot/ext/automod/triggers/reaction.py @@ -1,101 +1,65 @@ -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.automod_context import AutomodContext +from commanderbot.ext.automod.guards import ( + CategoriesGuard, ChannelsGuard, ChannelTypesGuard, ReactionsGuard, RolesGuard, ) +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"] 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, context: AutomodContext) -> bool: + assert isinstance(context.event, (events.ReactionAdded, events.ReactionRemoved)) + + if self.reactions and self.reactions.ignore(context.event.reaction): + return True + + if self.categories and self.categories.ignore(context.event.category): + return True + + if self.channel_types and self.channel_types.ignore(context.event.channel): + return True + + if self.channels and self.channels.ignore(context.event.channel): + return True + + if self.author_roles and self.author_roles.ignore(context.event.author): + return True + + if self.actor_roles and self.actor_roles.ignore(context.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 d101293e..1cfc7051 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"] 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 5b974734..3192a3ca 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"] 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 2ee85c00..16cdd8b2 100644 --- a/commanderbot/ext/automod/triggers/thread_created.py +++ b/commanderbot/ext/automod/triggers/thread_created.py @@ -1,29 +1,50 @@ -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.automod_context import AutomodContext +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, +) +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"] 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, context: AutomodContext) -> bool: + assert isinstance(context.event, events.ThreadCreated) + + # The thread must have a parent + parent = context.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 00000000..206961dc --- /dev/null +++ b/commanderbot/ext/automod/triggers/thread_deleted.py @@ -0,0 +1,50 @@ +from typing import Literal, Optional, override + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_context import AutomodContext +from commanderbot.ext.automod.guards import ( + CategoriesGuard, + ChannelsGuard, + ChannelTypesGuard, +) +from commanderbot.ext.automod.trigger import AutomodTrigger + +__all__ = ("ThreadDeleted",) + + +class ThreadDeleted(AutomodTrigger): + """ + Triggers when a thread is deleted. + """ + + type: Literal["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, context: AutomodContext) -> bool: + assert isinstance(context.event, events.ThreadDeleted) + + # The thread must have a parent + parent = context.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 4791a9eb..00000000 --- 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 deleted file mode 100644 index edfe12c1..00000000 --- a/commanderbot/ext/automod/triggers/user_banned.py +++ /dev/null @@ -1,23 +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 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 - """ - - 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 deleted file mode 100644 index 6dbe94f3..00000000 --- a/commanderbot/ext/automod/triggers/user_unbanned.py +++ /dev/null @@ -1,23 +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 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 - """ - - 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 555f7331..00000000 --- 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) diff --git a/commanderbot/ext/automod/types.py b/commanderbot/ext/automod/types.py new file mode 100644 index 00000000..51a8e142 --- /dev/null +++ b/commanderbot/ext/automod/types.py @@ -0,0 +1,113 @@ +from typing import Literal, Optional + +from discord import Attachment +from imagehash import ImageHash + +__all__ = ( + "ContextAttachment", + "ContextEventFields", + "ContextFields", + "ContextMetadataFields", +) + + +type ContextEventFields = Literal[ + # Channels + "channel_id", + "channel_name", + "channel_mention", + # Threads + "thread_id", + "thread_name", + "thread_mention", + "thread_archived", + "thread_locked", + "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", + "author_mention", + "author_nick", + "author_joined_at", + "author_member_for", + "author_created_at", + # Actors + "actor_id", + "actor_name", + "actor_display_name", + "actor_mention", + "actor_nick", + "actor_joined_at", + "actor_member_for", + "actor_created_at", + # Members + "member_id", + "member_name", + "member_display_name", + "member_mention", + "member_nick", + "member_joined_at", + "member_member_for", + "member_created_at", + # Thread owners + "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", + # Users + "user_id", + "user_name", + "user_display_name", + "user_mention", + "user_created_at", + # Unsafe fields + "channel", + "thread", + "category", + "message", + "attachments", + "reaction", + "author", + "actor", + "member", +] + +type ContextMetadataFields = Literal[ + "attachment_data", + "image_attachment_phashes", + "flagged_attachments", + "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 +type ContextAttachment = tuple[Attachment, bytes, Optional[ImageHash]] diff --git a/commanderbot/ext/automod/ui/__init__.py b/commanderbot/ext/automod/ui/__init__.py new file mode 100644 index 00000000..023f9965 --- /dev/null +++ b/commanderbot/ext/automod/ui/__init__.py @@ -0,0 +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 00000000..997a8332 --- /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/__init__.py b/commanderbot/ext/automod/ui/buckets/__init__.py new file mode 100644 index 00000000..9cc456ce --- /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 00000000..3e018221 --- /dev/null +++ b/commanderbot/ext/automod/ui/buckets/flagged_image_attachments.py @@ -0,0 +1,173 @@ +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 ( + CouldNotValidateModifiedFlaggedImageAttachmentsBucket, + 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__ = ( + "AddFlaggedImageAttachmentsBucket", + "FlaggedImageAttachmentsBucketDetails", + "ModifyFlaggedImageAttachmentsBucket", +) +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + + +class AddFlaggedImageAttachmentsBucket( + CogStateModal["AutomodGuildState", AutomodStore] +): + def __init__(self, interaction: Interaction, state: AutomodGuildState): + super().__init__( + interaction, state, title="Add a new flagged image attachments bucket" + ) + + 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 ModifyFlaggedImageAttachmentsBucket( + 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) + + 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 CouldNotValidateModifiedFlaggedImageAttachmentsBucket(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 new file mode 100644 index 00000000..21d035cb --- /dev/null +++ b/commanderbot/ext/automod/ui/buckets/message_history.py @@ -0,0 +1,199 @@ +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__ = ( + "AddMessageHistoryBucket", + "MessageHistoryBucketDetails", + "ModifyMessageHistoryBucket", +) + +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + + +class AddMessageHistoryBucket(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__(self, interaction: Interaction, state: AutomodGuildState): + super().__init__(interaction, state, title="Add a new message history bucket") + + 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 ModifyMessageHistoryBucket(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) + + 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) + + +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 new file mode 100644 index 00000000..7c1c4dcb --- /dev/null +++ b/commanderbot/ext/automod/ui/log.py @@ -0,0 +1,244 @@ +from typing import TYPE_CHECKING, override + +from discord import ( + ChannelType, + Interaction, + SelectDefaultValue, + SelectDefaultValueType, + TextStyle, + ui, +) + +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__ = ( + "DefaultLogDetails", + "ModifyDefaultLog", + "SetDefaultLog", +) + +if TYPE_CHECKING: + from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + + +class SetDefaultLog(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__(self, interaction: Interaction, state: AutomodGuildState): + super().__init__(interaction, state, title="Set the default log channel") + + 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: dict = { + "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.guild, log) + await interaction.response.send_message( + f"Set default log channel to <#{log.channel}>" + ) + except ValueError as ex: + raise CouldNotValidateLogChannel(ex) + + +class ModifyDefaultLog(CogStateModal["AutomodGuildState", AutomodStore]): + def __init__( + self, interaction: Interaction, state: AutomodGuildState, log: LogChannel + ): + super().__init__(interaction, state, title="Modifying default log channel") + + 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', '0x00ACED', or '44269'", + 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: dict = { + "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.guild, log) + await interaction.response.send_message( + f"Modified default log channel <#{log.channel}>" + ) + 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 new file mode 100644 index 00000000..57ec2223 --- /dev/null +++ b/commanderbot/ext/automod/ui/rule.py @@ -0,0 +1,152 @@ +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, 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: + 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") + + self.rule_input = ui.TextInput( + style=TextStyle.paragraph, + placeholder="{}", + required=True, + ) + self.rule_label = ui.Label( + text="The rule in Json format", + component=self.rule_input, + ) + + 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): + try: + rule = AutomodRule.model_validate_json(self.rule_input.value) + 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) + + +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) + + self.rule_input = ui.TextInput( + style=TextStyle.paragraph, + placeholder="{}", + default=rule.model_dump_json(indent=4, exclude_defaults=True), + required=True, + ) + self.rule_label = ui.Label( + text="The rule in Json format", + component=self.rule_input, + ) + + 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): + try: + rule = AutomodRule.model_validate_json(self.rule_input.value) + 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) + + +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}") + ) diff --git a/commanderbot/ext/automod/utils.py b/commanderbot/ext/automod/utils.py deleted file mode 100644 index 54a0d7b2..00000000 --- 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/ext/faq/faq_cog.py b/commanderbot/ext/faq/faq_cog.py index e3f3d83b..5ecca497 100644 --- a/commanderbot/ext/faq/faq_cog.py +++ b/commanderbot/ext/faq/faq_cog.py @@ -20,10 +20,11 @@ is_bot, is_guild, is_messagable_guild_channel, + is_thread, utils, ) from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, @@ -71,7 +72,7 @@ def __init__(self, bot: Bot, **options): ) # @@ AUTOCOMPLETE - + async def faq_autocomplete( self, interaction: Interaction, value: str ) -> list[Choice[str]]: @@ -115,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)) @@ -159,7 +161,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/faq/faq_data.py b/commanderbot/ext/faq/faq_data.py index c456663f..5eb2475e 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): @@ -713,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/faq/faq_guild_state.py b/commanderbot/ext/faq/faq_guild_state.py index 5af5b463..5f2f8e31 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 a574f656..f83a03c2 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 @@ -8,7 +9,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 +106,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 +130,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 +150,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 +209,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 8965b5b8..921bffa9 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/faq/faq_store.py b/commanderbot/ext/faq/faq_store.py index b7144dd9..b9bb127f 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_cog.py b/commanderbot/ext/feeds/feeds_cog.py index 678e2ae4..1c3a8ba4 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 @@ -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, @@ -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_data.py b/commanderbot/ext/feeds/feeds_data.py index e8e1b6b2..f221a85b 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 da4b1922..a9c0696c 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) @@ -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( @@ -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/feeds/feeds_json_store.py b/commanderbot/ext/feeds/feeds_json_store.py index 397b938d..346d7772 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 @@ -8,7 +9,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 d9fb2c25..adf9bd64 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/feeds/feeds_state.py b/commanderbot/ext/feeds/feeds_state.py index 31158123..daaac22c 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 @@ -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 4742f8a6..d82822fa 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 b5a6bb8e..dfd85ac9 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", ) @@ -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/feeds/providers/minecraft_bedrock_updates.py b/commanderbot/ext/feeds/providers/minecraft_bedrock_updates.py index d4ac971a..358b9665 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 a6667cbf..d217a77c 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 29ce6349..c7caffab 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/feeds/providers/utils.py b/commanderbot/ext/feeds/providers/utils.py index 9c36130d..170ebd31 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/friday/friday_cog.py b/commanderbot/ext/friday/friday_cog.py index 568746d0..fbe1fe72 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,9 +21,10 @@ is_bot, is_guild, is_messagable_guild_channel, + is_thread, ) from commanderbot.lib.cogs import CogGuildStateManager -from commanderbot.lib.cogs.database import ( +from commanderbot.lib.databases.json_db.v1 import ( InMemoryDatabaseOptions, JsonFileDatabaseAdapter, JsonFileDatabaseOptions, @@ -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_data.py b/commanderbot/ext/friday/friday_data.py index a7dd9bd8..ddf36411 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 1199ac2c..dd93e4e7 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 @@ -115,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 09c63c80..7feb12c5 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 @@ -7,7 +8,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 feed3723..913bcd84 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/friday/friday_store.py b/commanderbot/ext/friday/friday_store.py index 37f82280..64fbb9fe 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_chat/__init__.py b/commanderbot/ext/help_chat/__init__.py deleted file mode 100644 index ad66587d..00000000 --- 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 fdd63197..00000000 --- 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 98513abb..00000000 --- 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 0def43ac..00000000 --- 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 9849e54b..00000000 --- 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 884f6f17..00000000 --- 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 e0515bb8..00000000 --- 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 c7332b1b..00000000 --- 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 3fa2fedf..00000000 --- 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 1b6a9411..00000000 --- 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 a0caf32c..00000000 --- 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 diff --git a/commanderbot/ext/help_forum/help_forum_cog.py b/commanderbot/ext/help_forum/help_forum_cog.py index 081d7fb6..e566f921 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.cogs.database 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 58e133c2..792cdffd 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,35 +43,13 @@ 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() + return forum.id in self.help_forums def _require_tag(self, forum: ForumChannel, tag_str: str) -> ForumTag: # Returns the forum tag if it exists @@ -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,53 +140,24 @@ 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: + 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( + 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( + def register_forum_channel( self, guild: Guild, forum: ForumChannel, @@ -260,41 +170,33 @@ async def register_forum_channel( forum, unresolved_emoji, resolved_emoji, unresolved_tag, resolved_tag ) - # @implements HelpForumStore - async def deregister_forum_channel( + 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): + def increment_threads_created(self, help_forum: HelpForum): help_forum.threads_created += 1 - # @implements HelpForumStore - async def increment_resolutions(self, help_forum: HelpForum): + def increment_resolutions(self, help_forum: HelpForum): help_forum.resolutions += 1 - # @implements HelpForumStore - 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) - - # @implements HelpForumStore - 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) - # @implements HelpForumStore - 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) - # @implements HelpForumStore - 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_guild_state.py b/commanderbot/ext/help_forum/help_forum_guild_state.py index ab8a44a0..b422a858 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/help_forum/help_forum_json_store.py b/commanderbot/ext/help_forum/help_forum_json_store.py deleted file mode 100644 index 58cb1c96..00000000 --- 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.cogs.database 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 9f90f468..b4ff313b 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.cogs.database 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 bb09fb94..e69baea1 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 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 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 = 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 = 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() + cache.increment_threads_created(help_forum) + await self.db.commit() + + async def increment_resolutions(self, help_forum: HelpForum): + cache = await self.db.get_cache() + 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 = 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 = 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 = 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 = cache.modify_resolved_tag(guild, forum, tag) + await self.db.commit() + return result diff --git a/commanderbot/ext/invite/invite_cog.py b/commanderbot/ext/invite/invite_cog.py index d8845b87..9698c6e2 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_data.py b/commanderbot/ext/invite/invite_data.py index 833116ff..e7c3958e 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 31d4f4aa..ec7be305 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 ce09c283..87896daf 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 5d923274..aebab067 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 @@ -7,7 +8,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 86e2e545..4471352e 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/invite/invite_store.py b/commanderbot/ext/invite/invite_store.py index 25c33d2c..2f94c55b 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 2024d11b..3b274048 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 e6f1495a..287bc6ff 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 8339cec7..94eb6c01 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 83894924..9f582175 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 142e8f51..b47b400e 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 | 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/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index 1bfde8ab..3edaa5b1 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 b5073aa1..9991a42a 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 b9468f50..95b77e48 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 4c3e4583..16c81b09 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 83707cca..d331dd86 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: @@ -139,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) @@ -153,10 +186,10 @@ def render(self, ctx): @dataclass class IndexedType(McdocBaseType): - child: "McdocType" + 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 @@ -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): @@ -232,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" @@ -246,21 +283,21 @@ 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" 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) @@ -283,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:*" @@ -298,23 +335,25 @@ 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*" 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) @@ -325,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) @@ -334,31 +373,32 @@ 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) 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)] - 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*" @@ -368,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) @@ -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()) @@ -424,37 +493,37 @@ 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)) @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) - 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) @@ -468,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)) @@ -479,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: @@ -494,28 +563,28 @@ 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: 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"): @@ -545,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 @@ -554,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" @@ -592,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 @@ -606,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 @@ -620,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 @@ -634,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 @@ -648,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 @@ -662,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 @@ -677,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 @@ -696,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 @@ -715,37 +784,37 @@ 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 @dataclass class ListType(McdocBaseType): - item: "McdocType" + 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: @@ -761,12 +830,12 @@ def body(self, ctx): @dataclass class TupleType(McdocBaseType): - items: list["McdocType"] + 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)}" @@ -774,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}`)" @@ -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/ext/moderation/moderation_cog.py b/commanderbot/ext/moderation/moderation_cog.py index 5d740866..4f5ed8a4 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, @@ -23,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." @@ -90,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 ) @@ -154,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 ) @@ -198,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/poster_board/poster_board_cog.py b/commanderbot/ext/poster_board/poster_board_cog.py index 01cb2df8..57add045 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 b61ac2f4..eb379dea 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/roles/roles_cog.py b/commanderbot/ext/roles/roles_cog.py index b8b7c338..55acd53d 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.cogs.database import ( +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 9f5fb0ab..5cc2ba12 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( { @@ -181,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/roles/roles_guild_state.py b/commanderbot/ext/roles/roles_guild_state.py index abd739c4..772a46d4 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 90d3f48b..5e02e096 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 @@ -7,7 +8,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 0d2bd02a..0fbdc11e 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, @@ -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 ec8efb58..76bba03e 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 8fdf2f8e..8814ac24 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.cogs.database import ( +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: @@ -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/ext/stacktracer/stacktracer_json_store.py b/commanderbot/ext/stacktracer/stacktracer_json_store.py index 42f48834..c868b5da 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 cc137db6..864e2015 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 1526f89b..f2327bab 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 ( @@ -16,7 +16,6 @@ ) from discord.app_commands import ( AppCommand, - AppCommandContext, AppInstallationType, Choice, Group, @@ -27,10 +26,9 @@ ) 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 -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 @@ -55,7 +53,6 @@ GuildIDNotFound, GuildSyncError, UnknownCog, - UnsupportedStoreExport, ) from commanderbot.lib import ( ConfirmationResult, @@ -65,7 +62,8 @@ utils, ) from commanderbot.lib.app_commands import checks -from commanderbot.lib.cogs.database import JsonFileDatabaseAdapter +from commanderbot.lib.databases.json_db import JsonDB +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter class SyncTypeChoices(Enum): @@ -79,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") @@ -99,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): @@ -118,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): @@ -137,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: @@ -406,7 +415,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 @@ -432,17 +441,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 @@ -484,7 +502,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: @@ -556,7 +574,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: @@ -728,7 +746,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_data.py b/commanderbot/ext/sudo/sudo_data.py index eb5821cc..612d0724 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.cogs.database import JsonFileDatabaseAdapter, SQLDatabaseAdapter +from commanderbot.lib.databases.json_db import JsonDB +from commanderbot.lib.databases.json_db.v1 import JsonFileDatabaseAdapter @runtime_checkable -class DatabaseAdapter(Protocol): - db: JsonFileDatabaseAdapter | SQLDatabaseAdapter +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 d4e5b20e..05122a01 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 @@ -109,11 +107,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") diff --git a/commanderbot/ext/vote/vote_cog.py b/commanderbot/ext/vote/vote_cog.py index 7a1785d2..07bdc74a 100644 --- a/commanderbot/ext/vote/vote_cog.py +++ b/commanderbot/ext/vote/vote_cog.py @@ -1,5 +1,5 @@ import re -from typing import Iterable, cast +from collections.abc import Iterable import emoji from discord import Message @@ -17,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) @@ -66,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 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 diff --git a/commanderbot/ext/xkcd/xkcd_client.py b/commanderbot/ext/xkcd/xkcd_client.py index 95499a28..69113623 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 06cebf7f..42562374 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/commanderbot/lib/__init__.py b/commanderbot/lib/__init__.py index 3be3e53b..cefa41c8 100644 --- a/commanderbot/lib/__init__.py +++ b/commanderbot/lib/__init__.py @@ -2,17 +2,17 @@ from .color import * from .constants import * from .dialogs import * +from .enums import * from .event_data import * from .exceptions import * from .from_data_mixin import * -from .integer_range import * +from .image_hash import * from .intents import * from .json import * from .json_serializable import * +from .log_channel import * from .log_options import * -from .pattern_wrapper import * -from .role_set import * -from .shallow_formatter import * from .predicates import * +from .role_set import * +from .timedelta import * from .types import * -from .value_formatter import * diff --git a/commanderbot/lib/allowed_mentions.py b/commanderbot/lib/allowed_mentions.py index 147d55cc..69d85e52 100644 --- a/commanderbot/lib/allowed_mentions.py +++ b/commanderbot/lib/allowed_mentions.py @@ -1,47 +1,85 @@ -from typing import Any, Self, cast +import inspect +from collections.abc import Callable +from typing import Any, 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. - @classmethod - def all(cls) -> Self: - return cast(Self, super().all()) + Can be de/serialized using Pydantic or `FromDataMixin`/`JsonSerializeable`. + """ - @classmethod - def none(cls) -> Self: - return cast(Self, super().none()) + # @@ FACTORIES + + _factories: ClassVar[dict[str, Callable[[], Self]]] = {} @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 + + @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 +87,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 +95,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.literal_schema([*cls._factories]), + 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/app_commands/checks.py b/commanderbot/lib/app_commands/checks.py index 16355763..22b07321 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 371046b7..497a9c73 100644 --- a/commanderbot/lib/app_commands/transformers.py +++ b/commanderbot/lib/app_commands/transformers.py @@ -1,10 +1,10 @@ from itertools import islice +from typing import override import emoji 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 +12,13 @@ from commanderbot.lib.predicates import is_custom_emoji, is_message_link __all__ = ( + "ColorTransformer", + "EmojiTransformer", + "InvalidColor", "InvalidEmoji", "InvalidMessageLink", - "UnableToFindMessage", - "InvalidColor", - "EmojiTransformer", "MessageTransformer", - "ColorTransformer", + "UnableToFindMessage", ) @@ -59,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 @@ -71,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): @@ -79,9 +81,9 @@ 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 - except (CommandError, BadArgument): + ctx = await Context.from_interaction(interaction) + return await MessageConverter().convert(ctx, value) + except CommandError, BadArgument: raise UnableToFindMessage(value) @@ -92,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/app_commands/utils.py b/commanderbot/lib/app_commands/utils.py index 20908380..50c7538c 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 d92e4b55..9822614d 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 8edbd7b5..5ddeee7f 100644 --- a/commanderbot/lib/cogs/views.py +++ b/commanderbot/lib/cogs/views.py @@ -1,10 +1,9 @@ -from typing import Any, Generic, Optional, TypeVar +from typing import Generic, Optional, TypeVar, override -from discord import Interaction +from discord import Guild, 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 + +from commanderbot.lib.utils import dict_without_nones __all__ = ("CogStateModal", "CogStateView") @@ -12,7 +11,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. @@ -41,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,22 +49,28 @@ 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) + params = dict_without_nones( + title=title, + custom_id=custom_id, + timeout=timeout, + ) + super().__init__(**params) - # @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], 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. @@ -101,16 +106,17 @@ 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) - # @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 - async def on_error(self, interaction: Interaction, error: Exception, item: Item): + @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 diff --git a/commanderbot/lib/color.py b/commanderbot/lib/color.py index bd74f51b..b3f538ce 100644 --- a/commanderbot/lib/color.py +++ b/commanderbot/lib/color.py @@ -1,21 +1,30 @@ import inspect -from typing import Optional, Self, cast +from collections.abc import Callable +from typing import Any, 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]]] = {} + + @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"]: + ) -> 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,162 @@ 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 + + def to_hex(self) -> str: + return str(self) + + @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.literal_schema([*cls._factories]), + 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_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_factory_schema, + from_rgb_dict_schema, + from_hsv_dict_schema, + from_str_schema, + from_int_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/commands/converters.py b/commanderbot/lib/commands/converters.py index 5f66a94c..768ef908 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 @@ -10,8 +11,8 @@ __all__ = ( "CannotDisambiguateRole", - "LenientRoleConverter", "ColorConverter", + "LenientRoleConverter", ) @@ -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/constants.py b/commanderbot/lib/constants.py index 532f559c..cba78279 100644 --- a/commanderbot/lib/constants.py +++ b/commanderbot/lib/constants.py @@ -1,20 +1,25 @@ import ctypes import sys +from datetime import timedelta from importlib.metadata import version __all__ = ( + "BULK_DELETE_MAX_MESSAGES", + "BULK_DELETE_MAX_MESSAGE_AGE", "COMMANDERBOT_VERSION", "DISCORD_PY_VERSION", - "PYTHON_VERSION", - "USER_AGENT", - "MAX_MESSAGE_LENGTH", - "MAX_EMBED_TITLE_LENGTH", + "MAX_APPLICATION_EMOJIS", + "MAX_ATTACHMENTS", + "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") @@ -23,15 +28,26 @@ f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}" ) +try: + import tesserocr # noqa: F401 - Just checking if the import is available +except: + SUPPORTS_OCR: bool = False +else: + SUPPORTS_OCR: bool = True + USER_AGENT: str = ( f"CommanderBuff/{COMMANDERBOT_VERSION} (By the Minecraft Commands community)" ) 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 +MAX_ATTACHMENTS: int = 10 + MAX_MODAL_TITLE_LENGTH: int = 45 MAX_AUTOCOMPLETE_CHOICES: int = 25 diff --git a/commanderbot/lib/databases/json_db/__init__.py b/commanderbot/lib/databases/json_db/__init__.py new file mode 100644 index 00000000..eea0ce09 --- /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 00000000..860a784b --- /dev/null +++ b/commanderbot/lib/databases/json_db/config.py @@ -0,0 +1,49 @@ +from pathlib import Path +from typing import Annotated, Any, Literal, Optional, TypeIs + +from pydantic import BaseModel, BeforeValidator, Field + +__all__ = ( + "InMemoryDatabaseOptions", + "JsonDBOptions", + "JsonFileDatabaseOptions", +) + + +class InMemoryDatabaseOptions(BaseModel): + type: Literal["in_memory"] + + +class JsonFileDatabaseOptions(BaseModel): + type: Literal["json_file"] + path: Path + no_init: bool = False + indent: Optional[int] = None + exclude_defaults: bool = True + exclude_none: bool = False + + +def validate_options(data: Any) -> Any: + 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( + discriminator="type", + default_factory=lambda: InMemoryDatabaseOptions(type="in_memory"), + ), + 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 00000000..ef243eff --- /dev/null +++ b/commanderbot/lib/databases/json_db/db.py @@ -0,0 +1,109 @@ +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(encoding="utf-8") + 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 + 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("{}", encoding="utf-8") + 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, encoding="utf-8") + + 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, + exclude_defaults=self.options.exclude_defaults, + exclude_none=self.options.exclude_none, + ) + await asyncio.to_thread(self._write, data) 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 61% rename from commanderbot/lib/cogs/database/adapters.py rename to commanderbot/lib/databases/json_db/v1/adapters.py index db16be10..0752fb93 100644 --- a/commanderbot/lib/cogs/database/adapters.py +++ b/commanderbot/lib/databases/json_db/v1/adapters.py @@ -1,36 +1,15 @@ import asyncio +from collections.abc import Callable from dataclasses import dataclass, field from logging import Logger, getLogger -from pathlib import Path -from typing import Callable, Optional, Protocol +from typing import 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 @@ -95,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( @@ -111,49 +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 - - -@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) + await json_dump_async(data, self.options.path, indent=self.options.indent) 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 6e5f2e55..da13bbee 100644 --- a/commanderbot/lib/cogs/database/options.py +++ b/commanderbot/lib/databases/json_db/v1/options.py @@ -7,11 +7,10 @@ __all__ = ( "DatabaseOptions", "InMemoryDatabaseOptions", - "JsonFileDatabaseOptions", - "SQLiteDatabaseOptions", "InvalidDatabaseOptions", - "UnknownDatabaseType", + "JsonFileDatabaseOptions", "MissingDatabaseType", + "UnknownDatabaseType", "UnsupportedDatabaseOptions", "make_database_options", ) @@ -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 diff --git a/commanderbot/lib/dialogs.py b/commanderbot/lib/dialogs.py index 3e38d4b7..3dcb9305 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 +from typing import Optional, override 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,21 +73,21 @@ 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 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( @@ -99,11 +96,11 @@ 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: - 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/enums.py b/commanderbot/lib/enums.py new file mode 100644 index 00000000..c4c4171a --- /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/exceptions.py b/commanderbot/lib/exceptions.py index 5ea4b57d..763c3484 100644 --- a/commanderbot/lib/exceptions.py +++ b/commanderbot/lib/exceptions.py @@ -1,17 +1,41 @@ -from typing import Any, Optional, Type +from typing import Any, Optional from discord import AllowedMentions, Interaction from discord.ext.commands import Context __all__ = ( + "InstantiationError", "MalformedData", + "NoSuchFactory", "ResponsiveException", ) class MalformedData(Exception): - def __init__(self, cls: Type, data: Any): - super().__init__(f"Cannot create {cls.__name__} from {type(data).__name__}") + def __init__(self, cls: type, data: Any): + super().__init__(f"Cannot create '{cls.__name__}' from '{type(data).__name__}'") + + +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__( + f"'{cls.__name__}' doesn't have a factory function called '{factory_name}'", + ) + + +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__( + f"An error occurred while instantiating a '{cls.__name__}': {exception}", + ) class ResponsiveException(Exception): diff --git a/commanderbot/lib/from_data_mixin.py b/commanderbot/lib/from_data_mixin.py index b2d1a17f..e88cfbe1 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/guards/__init__.py b/commanderbot/lib/guards/__init__.py deleted file mode 100644 index 549b38b8..00000000 --- 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 b2c28580..00000000 --- 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 156ae69b..00000000 --- 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 8bf876b3..00000000 --- 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 027e21a0..00000000 --- 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)] diff --git a/commanderbot/lib/image_hash.py b/commanderbot/lib/image_hash.py new file mode 100644 index 00000000..defddb42 --- /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, json_schema_input_type=str), + PlainSerializer(lambda value: str(value), return_type=str), +] diff --git a/commanderbot/lib/integer_range.py b/commanderbot/lib/integer_range.py deleted file mode 100644 index 28ce1957..00000000 --- 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) diff --git a/commanderbot/lib/intents.py b/commanderbot/lib/intents.py index 2de8e36b..5e1854e5 100644 --- a/commanderbot/lib/intents.py +++ b/commanderbot/lib/intents.py @@ -1,36 +1,68 @@ -from typing import Any, Optional, Self, cast +import inspect +from collections.abc import Callable +from typing import Any, ClassVar, Optional, Self, 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]]] = {} + + @override @classmethod def all(cls) -> Self: - return cast(Self, super().all()) + """ + A factory method that creates a :class:`Intents` with everything enabled. + """ + return super().all() # type: ignore[ty:invalid-return-type] - The return type in the parent class is `discord.Intents` instead of `Self` + @override @classmethod def none(cls) -> Self: - return cast(Self, super().none()) + """ + A factory method that creates a :class:`Intents` with everything disabled. + """ + return super().none() # type: ignore[ty:invalid-return-type] - The return type in the parent class is `discord.Intents` instead of `Self` + @override @classmethod def default(cls) -> Self: - return cast(Self, super().default()) + """ + A factory method that creates a :class:`Intents` with everything enabled + except :attr:`presences`, :attr:`members`, and :attr:`message_content`. + """ + 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: - """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 +74,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_factory_schema, + from_dict_schema, + from_int_schema, + ] + ), + python_schema=core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + from_factory_schema, + from_dict_schema, + from_int_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`. +""" diff --git a/commanderbot/lib/json.py b/commanderbot/lib/json.py index eb02a82d..c6cb94ee 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", ) @@ -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) diff --git a/commanderbot/lib/json_serializable.py b/commanderbot/lib/json_serializable.py index 9631f803..9d06e4c8 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.""" + ... diff --git a/commanderbot/lib/log_channel.py b/commanderbot/lib/log_channel.py new file mode 100644 index 00000000..3ba54978 --- /dev/null +++ b/commanderbot/lib/log_channel.py @@ -0,0 +1,139 @@ +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 = allowed_mentions or self.allowed_mentions + + # 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, + ) + + 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) diff --git a/commanderbot/lib/log_options.py b/commanderbot/lib/log_options.py index 0643a300..ce30479b 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/pattern_wrapper.py b/commanderbot/lib/pattern_wrapper.py deleted file mode 100644 index 9c5c89a6..00000000 --- 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) diff --git a/commanderbot/lib/predicates.py b/commanderbot/lib/predicates.py index 7cbd676c..31a497d3 100644 --- a/commanderbot/lib/predicates.py +++ b/commanderbot/lib/predicates.py @@ -21,33 +21,39 @@ from commanderbot.lib.types import ( ConnectableChannel, + EmojiID, + GuildChannel, MessageableChannel, MessageableGuildChannel, + ThreadableChannel, UserID, ) __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_emoji_id", + "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_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( @@ -103,7 +109,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]: @@ -146,29 +152,41 @@ 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) 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/commanderbot/lib/role_set.py b/commanderbot/lib/role_set.py index 539e7f12..5a20deec 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/shallow_formatter.py b/commanderbot/lib/shallow_formatter.py deleted file mode 100644 index f92db91c..00000000 --- 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/timedelta.py b/commanderbot/lib/timedelta.py new file mode 100644 index 00000000..51a10f30 --- /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 6ce1424a..35799203 100644 --- a/commanderbot/lib/types.py +++ b/commanderbot/lib/types.py @@ -1,63 +1,70 @@ -from typing import Any, TypeAlias +from typing import Any, Literal from discord import ( CategoryChannel, DMChannel, ForumChannel, GroupChannel, - Guild, Member, - Message, - Reaction, StageChannel, TextChannel, Thread, User, VoiceChannel, ) -from discord.ext.commands import Context, MessageConverter __all__ = ( - "IDType", - "GuildID", - "ChannelID", - "MessageID", - "RoleID", - "UserID", - "ForumTagID", "AppCommandID", + "AttachmentID", + "CategoryID", + "Channel", + "ChannelID", + "ChannelTypeNames", + "ConnectableChannel", + "DiscordAutomodRuleID", "EmojiID", - "RawOptions", + "ForumTagID", + "GuildChannel", + "GuildID", + "IDType", "JsonObject", "MemberOrUser", - "Channel", - "GuildChannel", + "MessageID", "MessageableChannel", "MessageableGuildChannel", - "ConnectableChannel", - "TextMessage", - "TextReaction", + "RawOptions", + "RoleID", + "TesseractLanguages", + "TesseractScripts", + "ThreadID", + "ThreadableChannel", + "UnicodeNormalizationForms", + "UserID", ) -IDType: TypeAlias = int +type IDType = int -GuildID: TypeAlias = IDType -ChannelID: TypeAlias = IDType -MessageID: TypeAlias = IDType -RoleID: TypeAlias = IDType -UserID: TypeAlias = IDType -ForumTagID: TypeAlias = IDType -AppCommandID: TypeAlias = IDType -EmojiID: 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 @@ -67,49 +74,116 @@ | GroupChannel | CategoryChannel ) -GuildChannel: TypeAlias = ( - TextChannel | ForumChannel | Thread | VoiceChannel | StageChannel | CategoryChannel +type GuildChannel = ( + TextChannel | ForumChannel | VoiceChannel | StageChannel | CategoryChannel ) -MessageableChannel: TypeAlias = ( - TextChannel | Thread | VoiceChannel | StageChannel | DMChannel | GroupChannel +type MessageableChannel = ( + TextChannel | VoiceChannel | StageChannel | DMChannel | GroupChannel ) -MessageableGuildChannel: TypeAlias = TextChannel | Thread | VoiceChannel | StageChannel -ConnectableChannel: TypeAlias = VoiceChannel | StageChannel - - -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 +type MessageableGuildChannel = TextChannel | VoiceChannel | StageChannel +type ThreadableChannel = TextChannel | ForumChannel +type ConnectableChannel = VoiceChannel | StageChannel + +type ChannelTypeNames = 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 +""" + +type MemberFlagsNames = 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 +""" + +type PublicUserFlagsNames = 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 +""" + + +type UnicodeNormalizationForms = Literal["NFC", "NFD", "NFKC", "NFKD"] + + +# fmt: off +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", + "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 +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). +""" +# fmt: on diff --git a/commanderbot/lib/utils/__init__.py b/commanderbot/lib/utils/__init__.py index 7fef7e4e..7b471579 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/channels.py b/commanderbot/lib/utils/channels.py index 6b74d325..c813ee0e 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 07d828a1..4ea20647 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 deleted file mode 100644 index de37e075..00000000 --- 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_op", - "parse_json_path", - "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}`") diff --git a/commanderbot/lib/utils/timedeltas.py b/commanderbot/lib/utils/timedeltas.py index 753125d2..81f9fa82 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 ea519725..8486aca5 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, @@ -14,12 +15,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 @@ -103,8 +105,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) @@ -119,18 +121,18 @@ 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)) - file = File(fp=fp, filename=filename) + fp = io.BytesIO(file_content.encode()) + file = File(fp=fp, filename=file_name) 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) async def send_message_or_file( - destination: MessageableChannel | PartialMessageable, + destination: MessageableChannel | Thread | PartialMessageable, content: str, *, file_callback: Callable[[], tuple[str, str, str]], @@ -158,7 +160,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, diff --git a/commanderbot/lib/value_formatter.py b/commanderbot/lib/value_formatter.py deleted file mode 100644 index 350c523c..00000000 --- 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) diff --git a/pyproject.toml b/pyproject.toml index 5be4804f..dce3f9a6 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" }, @@ -14,34 +14,36 @@ keywords = ["discord", "bot"] classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.14", - "License :: OSI Approved :: MIT License", ] dependencies = [ # Core dependencies. - "discord-py>=2.6.0", + "discord-py>=2.7.0", "python-dotenv>=1.0.0", + "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", - "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", + "colorlog>=6.11.0", # The rest are for various extensions. - "aiohttp>=3.13.0", - "emoji>=2.0.0", + "emoji>=2.14.0", "allay>=1.3.0", "mccq>=1.0.0", - "psutil>=7.0.0", + "numpy>=2.5.0", + "opencv-python-headless>=5.0.0.0", + "pillow>=12.3.0", + "imagehash>=4.3.0", + "mime-enum>=0.0.3", ] +[project.optional-dependencies] +ocr = ["tesserocr>=2.10.0"] + [project.urls] Homepage = "https://github.com/MinecraftCommands/commanderbot-py" Repository = "https://github.com/MinecraftCommands/commanderbot-py" @@ -51,18 +53,34 @@ Changelog = "https://github.com/MinecraftCommands/commanderbot-py/blob/main/CHAN commanderbot = "commanderbot:__main__" [dependency-groups] -dev = ["black>=24.0.0", "isort>=5.13.0"] +dev = ["ruff>=0.16.0", "ty>=0.0.70"] -[tool.isort] -profile = "black" - -[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/ + "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/ + "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/ + "PYI041", # https://docs.astral.sh/ruff/rules/redundant-numeric-union/ +] -[build-system] -requires = ["uv_build"] -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 = "ocr" } [tool.uv.build-backend] module-name = "commanderbot" module-root = "" + +[build-system] +requires = ["uv_build<0.13"] +build-backend = "uv_build" diff --git a/uv.lock b/uv.lock index f26e496c..51d7e91a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,19 +1,23 @@ version = 1 revision = 3 requires-python = "==3.14.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform != 'win32'", +] [[package]] name = "aiohappyeyeballs" -version = "2.6.1" +version = "2.7.1" 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/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/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/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.13.2" +version = "3.14.3" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -24,42 +28,49 @@ 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/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]] @@ -74,18 +85,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.21.0" -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" } -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" }, -] - [[package]] name = "allay" version = "1.3.0" @@ -98,13 +97,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.8.0" +source = { registry = "https://pypi.org/simple/" } +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/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]] 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]] @@ -147,39 +155,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 = "25.11.0" -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/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } -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" }, -] - -[[package]] -name = "click" -version = "8.3.1" -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" } -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" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -191,76 +166,107 @@ 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]] name = "commanderbot" -version = "0.21.0" +version = "0.23.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, - { name = "aiosqlite" }, { name = "allay" }, { name = "colorama" }, { name = "colorlog" }, { name = "discord-py" }, { name = "emoji" }, - { name = "jsonpath-ng" }, + { name = "imagehash" }, { name = "mccq" }, + { name = "mime-enum" }, + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pebble" }, + { name = "pillow" }, { name = "psutil" }, + { name = "pydantic" }, { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "sqlalchemy" }, + { name = "types-psutil" }, +] + +[package.optional-dependencies] +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'" }, ] [package.dev-dependencies] dev = [ - { name = "black" }, - { name = "isort" }, + { name = "ruff" }, + { name = "ty" }, ] [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.13.0" }, - { name = "aiosqlite", specifier = ">=0.21.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 = "discord-py", specifier = ">=2.6.0" }, - { name = "emoji", specifier = ">=2.0.0" }, - { name = "jsonpath-ng", specifier = ">=1.7.0" }, + { name = "colorlog", specifier = ">=6.11.0" }, + { name = "discord-py", specifier = ">=2.7.0" }, + { name = "emoji", specifier = ">=2.14.0" }, + { name = "imagehash", specifier = ">=4.3.0" }, { name = "mccq", specifier = ">=1.0.0" }, - { name = "psutil", specifier = ">=7.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 = "pebble", specifier = ">=5.2.0" }, + { name = "pillow", specifier = ">=12.3.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 = "sqlalchemy", specifier = ">=1.4.0,<2.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" }, ] +provides-extras = ["ocr"] [package.metadata.requires-dev] dev = [ - { name = "black", specifier = ">=24.0.0" }, - { name = "isort", specifier = ">=5.13.0" }, + { name = "ruff", specifier = ">=0.16.0" }, + { name = "ty", specifier = ">=0.0.70" }, +] + +[[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.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]] @@ -313,51 +319,28 @@ 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.2.4" -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" } -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" }, -] - [[package]] name = "idna" -version = "3.11" +version = "3.19" 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/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/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/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] -name = "isort" -version = "7.0.0" -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" } -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" }, -] - -[[package]] -name = "jsonpath-ng" -version = "1.7.0" +name = "imagehash" +version = "4.3.2" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "ply" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pywavelets" }, + { name = "scipy" }, ] -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/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/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/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]] @@ -370,267 +353,501 @@ wheels = [ ] [[package]] -name = "multidict" -version = "6.7.0" +name = "mime-enum" +version = "0.0.3" 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" } +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/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" }, + { 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 = "mypy-extensions" -version = "1.1.0" +name = "multidict" +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]] +name = "numpy" +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]] +name = "opencv-python-headless" +version = "5.0.0.93" 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" } +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/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" }, + { 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 = "25.0" +name = "pebble" +version = "5.2.1" 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/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/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/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 = "pathspec" -version = "0.12.1" +name = "pillow" +version = "12.3.0" 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/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/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/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 = "propcache" +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]] -name = "platformdirs" -version = "4.5.0" +name = "psutil" +version = "7.2.2" 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" } +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/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, + { 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 = "ply" -version = "3.11" +name = "pydantic" +version = "2.13.4" 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" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +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/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/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 = "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" }, +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +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]] -name = "psutil" -version = "7.1.3" +name = "python-dotenv" +version = "1.2.3" 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" } +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/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/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]] -name = "python-dotenv" -version = "1.2.1" +name = "pywavelets" +version = "1.9.0" 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" } +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 = "ruff" +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]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "numpy" }, +] +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]] +name = "tesserocr" +version = "2.10.0" +source = { registry = "https://pypi.org/simple/" } +resolution-markers = [ + "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/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/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 = "pytokens" -version = "0.3.0" -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" } +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://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://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 = "pyyaml" -version = "6.0.3" +name = "tokenstream" +version = "1.7.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/83/33/0874abc08b5811aab60818b4596ab3463a4044e59061fa5b8bb8cca53b59/tokenstream-1.7.0.tar.gz", hash = "sha256:9f98387c7e74d224f7cca874ce77bb775ece6cb585e9ba18441960e2b35fae61", size = 17012, upload-time = "2023-08-03T00:21:02.4Z" } 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" }, + { 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 = "sqlalchemy" -version = "1.4.54" +name = "ty" +version = "0.0.74" 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/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" }, ] -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" +name = "types-psutil" +version = "7.2.2.20260518" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/83/33/0874abc08b5811aab60818b4596ab3463a4044e59061fa5b8bb8cca53b59/tokenstream-1.7.0.tar.gz", hash = "sha256:9f98387c7e74d224f7cca874ce77bb775ece6cb585e9ba18441960e2b35fae61", size = 17012, upload-time = "2023-08-03T00:21:02.4Z" } +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/40/e3/ed2623ff7a54bbd45f59c536f9b8923cc797a1125d9d087ea51cc9424e99/tokenstream-1.7.0-py3-none-any.whl", hash = "sha256:fdbb20e8a99b07e94ce88016e5f243a1582630a7459bd2e7a1c786f33543fcf9", size = 16857, upload-time = "2023-08-03T00:21:00.394Z" }, + { 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" +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/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.4" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +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/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/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.22.0" +version = "1.24.5" 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/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" }, ]