From 2c7ef492bbb4d8c3497e6ca026d697774e7991c3 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Sat, 4 Sep 2021 19:26:38 -0400 Subject: [PATCH 01/26] Make triggers async --- commanderbot/ext/automod/automod_data.py | 7 ++++--- commanderbot/ext/automod/automod_rule.py | 4 ++-- commanderbot/ext/automod/automod_trigger.py | 8 ++++---- commanderbot/ext/automod/triggers/member_typing.py | 7 ++----- commanderbot/ext/automod/triggers/member_updated.py | 7 ++----- .../ext/automod/triggers/mentions_removed_from_message.py | 2 +- commanderbot/ext/automod/triggers/message.py | 7 ++----- commanderbot/ext/automod/triggers/reaction.py | 7 ++----- 8 files changed, 19 insertions(+), 30 deletions(-) diff --git a/commanderbot/ext/automod/automod_data.py b/commanderbot/ext/automod/automod_data.py index 81c69c1..22de467 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -107,12 +107,12 @@ def set_permitted_roles( def all_rules(self) -> Iterable[AutomodRule]: yield from self.rules.values() - def rules_for_event(self, event: AutomodEvent) -> Iterable[AutomodRule]: + async 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): + if await rule.poll_triggers(event): yield rule def query_rules(self, query: str) -> Iterable[AutomodRule]: @@ -261,7 +261,8 @@ async def all_rules(self, guild: Guild) -> AsyncIterable[AutomodRule]: async def rules_for_event( self, guild: Guild, event: AutomodEvent ) -> AsyncIterable[AutomodRule]: - for rule in self.guilds[guild.id].rules_for_event(event): + rules = await self.guilds[guild.id].rules_for_event(event) + for rule in rules: yield rule # @implements AutomodStore diff --git a/commanderbot/ext/automod/automod_rule.py b/commanderbot/ext/automod/automod_rule.py index a4c5c64..56cd35e 100644 --- a/commanderbot/ext/automod/automod_rule.py +++ b/commanderbot/ext/automod/automod_rule.py @@ -92,10 +92,10 @@ def build_title(self) -> str: parts.append(self.description) return " ".join(parts) - def poll_triggers(self, event: AutomodEvent) -> bool: + async def poll_triggers(self, event: AutomodEvent) -> bool: """Check whether the event activates any triggers.""" for trigger in self.triggers: - if trigger.poll(event): + if await trigger.poll(event): return True return False diff --git a/commanderbot/ext/automod/automod_trigger.py b/commanderbot/ext/automod/automod_trigger.py index bccd531..35d4776 100644 --- a/commanderbot/ext/automod/automod_trigger.py +++ b/commanderbot/ext/automod/automod_trigger.py @@ -28,7 +28,7 @@ class AutomodTrigger(AutomodEntity, Protocol): description: Optional[str] - def poll(self, event: AutomodEvent) -> Optional[bool]: + async def poll(self, event: AutomodEvent) -> Optional[bool]: """Check whether an event activates the trigger.""" @@ -58,17 +58,17 @@ def from_data(cls: Type[ST], data: JsonObject) -> ST: description=data.get("description"), ) - def poll(self, event: AutomodEvent) -> bool: + async 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): + if await self.ignore(event): return False return True - def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: AutomodEvent) -> bool: """Override this if more than just the event type needs to be checked.""" return False diff --git a/commanderbot/ext/automod/triggers/member_typing.py b/commanderbot/ext/automod/triggers/member_typing.py index 7492ff9..c68f4cf 100644 --- a/commanderbot/ext/automod/triggers/member_typing.py +++ b/commanderbot/ext/automod/triggers/member_typing.py @@ -3,10 +3,7 @@ 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.ext.automod.automod_trigger import AutomodTrigger, AutomodTriggerBase from commanderbot.lib import ChannelsGuard, JsonObject, RolesGuard ST = TypeVar("ST") @@ -52,7 +49,7 @@ def ignore_by_role(self, event: AutomodEvent) -> bool: return False return self.roles.ignore(event.member) - def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: AutomodEvent) -> bool: return self.ignore_by_channel(event) or self.ignore_by_role(event) diff --git a/commanderbot/ext/automod/triggers/member_updated.py b/commanderbot/ext/automod/triggers/member_updated.py index 0cc893c..4b76395 100644 --- a/commanderbot/ext/automod/triggers/member_updated.py +++ b/commanderbot/ext/automod/triggers/member_updated.py @@ -3,10 +3,7 @@ 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.ext.automod.automod_trigger import AutomodTrigger, AutomodTriggerBase from commanderbot.lib import JsonObject, RolesGuard ST = TypeVar("ST") @@ -49,7 +46,7 @@ def ignore_by_role(self, event: AutomodEvent) -> bool: return False return self.roles.ignore(event.member) - def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: AutomodEvent) -> bool: return self.ignore_by_role(event) diff --git a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py index db95b64..664a04a 100644 --- a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py +++ b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py @@ -49,7 +49,7 @@ def from_data(cls: Type[ST], data: JsonObject) -> ST: victim_roles=victim_roles, ) - def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: AutomodEvent) -> bool: # Make sure we care about the channel. if self.channels and self.channels.ignore(event.channel): return True diff --git a/commanderbot/ext/automod/triggers/message.py b/commanderbot/ext/automod/triggers/message.py index 259bd81..1dbb1d6 100644 --- a/commanderbot/ext/automod/triggers/message.py +++ b/commanderbot/ext/automod/triggers/message.py @@ -3,10 +3,7 @@ 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.ext.automod.automod_trigger import AutomodTrigger, AutomodTriggerBase from commanderbot.lib import ChannelsGuard, JsonObject, RolesGuard ST = TypeVar("ST") @@ -68,7 +65,7 @@ def ignore_by_author_role(self, event: AutomodEvent) -> bool: return False return self.author_roles.ignore(event.author) - def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: AutomodEvent) -> bool: return ( self.ignore_by_content(event) or self.ignore_by_channel(event) diff --git a/commanderbot/ext/automod/triggers/reaction.py b/commanderbot/ext/automod/triggers/reaction.py index 0e80426..a7903ff 100644 --- a/commanderbot/ext/automod/triggers/reaction.py +++ b/commanderbot/ext/automod/triggers/reaction.py @@ -3,10 +3,7 @@ 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.ext.automod.automod_trigger import AutomodTrigger, AutomodTriggerBase from commanderbot.lib import ChannelsGuard, JsonObject, ReactionsGuard, RolesGuard ST = TypeVar("ST") @@ -74,7 +71,7 @@ def ignore_by_actor_role(self, event: AutomodEvent) -> bool: return False return self.actor_roles.ignore(event.actor) - def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: AutomodEvent) -> bool: return ( self.ignore_by_reaction(event) or self.ignore_by_channel(event) From 41caa7c72956b2d84e2273c0d8ce73ae35468db3 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Sat, 4 Sep 2021 20:02:07 -0400 Subject: [PATCH 02/26] Include guild state in event context for store access --- commanderbot/ext/automod/automod_event.py | 10 +++--- .../ext/automod/automod_event_state.py | 3 ++ .../ext/automod/automod_event_state.pyi | 5 +++ .../ext/automod/automod_guild_state.py | 32 +++++++++---------- 4 files changed, 28 insertions(+), 22 deletions(-) create mode 100644 commanderbot/ext/automod/automod_event_state.py create mode 100644 commanderbot/ext/automod/automod_event_state.pyi diff --git a/commanderbot/ext/automod/automod_event.py b/commanderbot/ext/automod/automod_event.py index edf82c2..d4c9848 100644 --- a/commanderbot/ext/automod/automod_event.py +++ b/commanderbot/ext/automod/automod_event.py @@ -4,16 +4,13 @@ from discord import Member, TextChannel, Thread, User from discord.ext.commands import Bot -from commanderbot.lib import ( - ShallowFormatter, - TextMessage, - TextReaction, - ValueFormatter, -) +from commanderbot.ext.automod.automod_event_state import AutomodEventState +from commanderbot.lib import ShallowFormatter, TextMessage, TextReaction, ValueFormatter from commanderbot.lib.utils import yield_member_date_fields class AutomodEvent(Protocol): + state: AutomodEventState bot: Bot @property @@ -60,6 +57,7 @@ def format_content(self, content: str, *, unsafe: bool = False) -> str: # @implements AutomodEvent @dataclass class AutomodEventBase: + state: AutomodEventState bot: Bot _metadata: Dict[str, Any] = field(init=False, default_factory=dict) diff --git a/commanderbot/ext/automod/automod_event_state.py b/commanderbot/ext/automod/automod_event_state.py new file mode 100644 index 0000000..eb75447 --- /dev/null +++ b/commanderbot/ext/automod/automod_event_state.py @@ -0,0 +1,3 @@ +from typing import Any, TypeAlias + +AutomodEventState: TypeAlias = Any diff --git a/commanderbot/ext/automod/automod_event_state.pyi b/commanderbot/ext/automod/automod_event_state.pyi new file mode 100644 index 0000000..114f79f --- /dev/null +++ b/commanderbot/ext/automod/automod_event_state.pyi @@ -0,0 +1,5 @@ +from typing import TypeAlias + +from commanderbot.ext.automod.automod_guild_state import AutomodGuildState + +AutomodEventState: TypeAlias = AutomodGuildState diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index 03ecb6f..443196d 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -326,51 +326,51 @@ async def member_has_permission(self, member: Member) -> bool: async def on_typing( self, channel: TextChannel | Thread, member: Member, when: datetime ): - await self._do_event(events.MemberTyping(self.bot, channel, member, when)) + await self._do_event(events.MemberTyping(self, self.bot, channel, member, when)) async def on_message(self, message: TextMessage): - await self._do_event(events.MessageSent(self.bot, message)) + await self._do_event(events.MessageSent(self, self.bot, message)) async def on_message_delete(self, message: TextMessage): - await self._do_event(events.MessageDeleted(self.bot, message)) + await self._do_event(events.MessageDeleted(self, self.bot, message)) async def on_message_edit(self, before: TextMessage, after: TextMessage): - await self._do_event(events.MessageEdited(self.bot, before, after)) + await self._do_event(events.MessageEdited(self, self.bot, before, after)) async def on_reaction_add(self, reaction: TextReaction, member: Member): - await self._do_event(events.ReactionAdded(self.bot, reaction, member)) + await self._do_event(events.ReactionAdded(self, self.bot, reaction, member)) async def on_reaction_remove(self, reaction: TextReaction, member: Member): - await self._do_event(events.ReactionRemoved(self.bot, reaction, member)) + await self._do_event(events.ReactionRemoved(self, self.bot, reaction, member)) async def on_member_join(self, member: Member): - await self._do_event(events.MemberJoined(self.bot, member)) + await self._do_event(events.MemberJoined(self, self.bot, member)) async def on_member_remove(self, member: Member): - await self._do_event(events.MemberLeft(self.bot, member)) + await self._do_event(events.MemberLeft(self, self.bot, member)) async def on_member_update(self, before: Member, after: Member): - await self._do_event(events.MemberUpdated(self.bot, before, after)) + await self._do_event(events.MemberUpdated(self, self.bot, before, after)) async def on_user_update(self, before: User, after: User, member: Member): - await self._do_event(events.UserUpdated(self.bot, before, after, member)) + await self._do_event(events.UserUpdated(self, self.bot, before, after, member)) async def on_user_ban(self, user: User): - await self._do_event(events.UserBanned(self.bot, user)) + await self._do_event(events.UserBanned(self, self.bot, user)) async def on_user_unban(self, user: User): - await self._do_event(events.UserUnbanned(self.bot, user)) + await self._do_event(events.UserUnbanned(self, self.bot, user)) # @@ RAW EVENT HANDLERS async def on_raw_message_delete(self, payload: RawMessageDeleteEvent): - await self._do_event(events.RawMessageDeleted(self.bot, payload)) + await self._do_event(events.RawMessageDeleted(self, self.bot, payload)) async def on_raw_message_edit(self, payload: RawMessageUpdateEvent): - await self._do_event(events.RawMessageEdited(self.bot, payload)) + await self._do_event(events.RawMessageEdited(self, self.bot, payload)) async def on_raw_reaction_add(self, payload: RawReactionActionEvent): - await self._do_event(events.RawReactionAdded(self.bot, payload)) + await self._do_event(events.RawReactionAdded(self, self.bot, payload)) async def on_raw_reaction_remove(self, payload: RawReactionActionEvent): - await self._do_event(events.RawReactionRemoved(self.bot, payload)) + await self._do_event(events.RawReactionRemoved(self, self.bot, payload)) From d1c3686275c9ccd40908b31f1b445e599522b342 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Sat, 4 Sep 2021 20:11:40 -0400 Subject: [PATCH 03/26] Change `AutomodEventBase` -> `AutomodEvent` --- commanderbot/ext/automod/automod_guild_state.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index 443196d..bbddaf8 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -21,7 +21,7 @@ from yaml import YAMLError from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.automod_rule import AutomodRule from commanderbot.ext.automod.automod_store import AutomodStore from commanderbot.lib import ( @@ -87,14 +87,14 @@ async def _handle_rule_error(self, rule: AutomodRule, error: Exception): # If something went wrong here, print another exception to the console. self.log.exception("Failed to log message to error channel") - async def _do_event_for_rule(self, event: AutomodEventBase, rule: AutomodRule): + async def _do_event_for_rule(self, event: AutomodEvent, 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 _do_event(self, event: AutomodEventBase): + async def _do_event(self, event: AutomodEvent): # 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 async_expand(self.store.rules_for_event(self.guild, event)) From 1ca7c2a2ff0cb268cca68d733ec626923c476769 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Mon, 6 Sep 2021 13:14:27 -0400 Subject: [PATCH 04/26] Start on buckets (WIP) --- .../ext/automod/actions/add_to_bucket.py | 41 +++++ commanderbot/ext/automod/automod_bucket.py | 48 ++++++ .../ext/automod/automod_bucket_ref.py | 33 ++++ commanderbot/ext/automod/automod_data.py | 29 +++- .../ext/automod/automod_guild_state.py | 56 ++++--- .../ext/automod/automod_json_store.py | 19 ++- commanderbot/ext/automod/automod_store.py | 15 +- commanderbot/ext/automod/buckets/__init__.py | 0 .../ext/automod/buckets/message_frequency.py | 154 ++++++++++++++++++ commanderbot/ext/automod/events/__init__.py | 1 + .../events/message_frequency_changed.py | 33 ++++ .../triggers/message_frequency_changed.py | 74 +++++++++ 12 files changed, 477 insertions(+), 26 deletions(-) create mode 100644 commanderbot/ext/automod/actions/add_to_bucket.py create mode 100644 commanderbot/ext/automod/automod_bucket.py create mode 100644 commanderbot/ext/automod/automod_bucket_ref.py create mode 100644 commanderbot/ext/automod/buckets/__init__.py create mode 100644 commanderbot/ext/automod/buckets/message_frequency.py create mode 100644 commanderbot/ext/automod/events/message_frequency_changed.py create mode 100644 commanderbot/ext/automod/triggers/message_frequency_changed.py diff --git a/commanderbot/ext/automod/actions/add_to_bucket.py b/commanderbot/ext/automod/actions/add_to_bucket.py new file mode 100644 index 0000000..cddffde --- /dev/null +++ b/commanderbot/ext/automod/actions/add_to_bucket.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from typing import Type, TypeVar + +from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase +from commanderbot.ext.automod.automod_bucket import AutomodBucket +from commanderbot.ext.automod.automod_bucket_ref import BucketRef +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.lib import JsonObject + +ST = TypeVar("ST") + + +@dataclass +class AddToBucket(AutomodActionBase): + """ + Add the event to a bucket. + + Attributes + ---------- + bucket + The bucket to add to. + """ + + bucket: BucketRef[AutomodBucket] + + @classmethod + def from_data(cls: Type[ST], data: JsonObject) -> ST: + bucket = BucketRef.from_field(data, "bucket") + return cls( + description=data.get("description"), + bucket=bucket, + ) + + async def apply(self, event: AutomodEvent): + # Resolve the bucket and add the event to it. + bucket = await self.bucket.resolve(event) + await bucket.add(event) + + +def create_action(data: JsonObject) -> AutomodAction: + return AddToBucket.from_data(data) diff --git a/commanderbot/ext/automod/automod_bucket.py b/commanderbot/ext/automod/automod_bucket.py new file mode 100644 index 0000000..5f4f7ff --- /dev/null +++ b/commanderbot/ext/automod/automod_bucket.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass +from typing import Any, Iterable, List, Optional, Protocol + +from commanderbot.ext.automod import buckets +from commanderbot.ext.automod.automod_entity import ( + AutomodEntity, + AutomodEntityBase, + deserialize_entities, +) +from commanderbot.ext.automod.automod_event import AutomodEvent + + +class AutomodBucket(AutomodEntity, Protocol): + description: Optional[str] + + async def add(self, event: AutomodEvent): + """Add the event to the bucket.""" + + +# @implements AutomodBucket +@dataclass +class AutomodBucketBase(AutomodEntityBase): + """ + Base bucket for inheriting base fields and functionality. + + Attributes + ---------- + description + A human-readable description of the bucket. + """ + + default_module_prefix = buckets.__name__ + module_function_name = "create_bucket" + + description: Optional[str] + + async def add(self, event: AutomodEvent): + """Override this to modify the bucket according to the event.""" + + +def deserialize_buckets(data: Iterable[Any]) -> List[AutomodBucket]: + return deserialize_entities( + entity_type=AutomodBucketBase, + data=data, + defaults={ + "description": None, + }, + ) diff --git a/commanderbot/ext/automod/automod_bucket_ref.py b/commanderbot/ext/automod/automod_bucket_ref.py new file mode 100644 index 0000000..bc8c58a --- /dev/null +++ b/commanderbot/ext/automod/automod_bucket_ref.py @@ -0,0 +1,33 @@ +import typing +from dataclasses import dataclass +from typing import Any, Generic, Optional, Type, TypeVar + +from commanderbot.ext.automod.automod_bucket import AutomodBucket +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.lib import FromDataMixin, JsonSerializable + +ST = TypeVar("ST") +BT = TypeVar("BT", bound=AutomodBucket) + + +@dataclass +class BucketRef(JsonSerializable, FromDataMixin, Generic[BT]): + name: str + + # @overrides FromDataMixin + @classmethod + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: + if isinstance(data, str): + return cls(name=data) + + # @implements JsonSerializable + def to_json(self) -> Any: + return self.name + + async def resolve(self, event: AutomodEvent) -> BT: + bucket_generics = typing.get_args(self) + bucket_type = bucket_generics[0] + bucket = await event.state.store.require_bucket( + event.state.guild, self.name, bucket_type + ) + return bucket diff --git a/commanderbot/ext/automod/automod_data.py b/commanderbot/ext/automod/automod_data.py index 22de467..a42e5af 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -3,10 +3,20 @@ from collections import defaultdict from dataclasses import dataclass, field from datetime import datetime -from typing import AsyncIterable, DefaultDict, Dict, Iterable, Optional, Set, Type +from typing import ( + AsyncIterable, + DefaultDict, + Dict, + Iterable, + Optional, + Set, + Type, + TypeVar, +) from discord import Guild +from commanderbot.ext.automod.automod_bucket import AutomodBucket from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.automod_rule import AutomodRule from commanderbot.lib import ( @@ -19,6 +29,9 @@ from commanderbot.lib.json import to_data from commanderbot.lib.utils import dict_without_ellipsis +BT = TypeVar("BT", bound=AutomodBucket) + + RulesByEventType = DefaultDict[Type[AutomodEvent], Set[AutomodRule]] @@ -303,3 +316,17 @@ async def disable_rule(self, guild: Guild, name: str) -> AutomodRule: # @implements AutomodStore async def increment_rule_hits(self, guild: Guild, name: str) -> AutomodRule: return self.guilds[guild.id].increment_rule_hits_by_name(name) + + # @implements AutomodStore + async def get_bucket( + self, guild: Guild, name: str, bucket_type: Type[BT] + ) -> Optional[BT]: + # IMPL + ... + + # @implements AutomodStore + async def require_bucket( + self, guild: Guild, name: str, bucket_type: Type[BT] + ) -> BT: + # IMPL + ... diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index bbddaf8..9eac629 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -21,6 +21,7 @@ from yaml import YAMLError from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_bucket import AutomodBucket from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.automod_rule import AutomodRule from commanderbot.ext.automod.automod_store import AutomodStore @@ -94,13 +95,6 @@ async def _do_event_for_rule(self, event: AutomodEvent, rule: AutomodRule): except Exception as error: await self._handle_rule_error(rule, error) - async def _do_event(self, event: AutomodEvent): - # 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 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) -> JsonObject: content = body.strip("\n").strip("`") kind, _, content = content.partition("\n") @@ -323,54 +317,70 @@ async def member_has_permission(self, member: Member) -> bool: # @@ EVENT HANDLERS + async def dispatch_event(self, event: AutomodEvent): + # 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 async_expand(self.store.rules_for_event(self.guild, event)) + if rules: + tasks = [self._do_event_for_rule(event, rule) for rule in rules] + await asyncio.gather(*tasks) + async def on_typing( self, channel: TextChannel | Thread, member: Member, when: datetime ): - await self._do_event(events.MemberTyping(self, self.bot, channel, member, when)) + await self.dispatch_event( + events.MemberTyping(self, self.bot, channel, member, when) + ) async def on_message(self, message: TextMessage): - await self._do_event(events.MessageSent(self, self.bot, message)) + await self.dispatch_event(events.MessageSent(self, self.bot, message)) async def on_message_delete(self, message: TextMessage): - await self._do_event(events.MessageDeleted(self, self.bot, message)) + await self.dispatch_event(events.MessageDeleted(self, self.bot, message)) async def on_message_edit(self, before: TextMessage, after: TextMessage): - await self._do_event(events.MessageEdited(self, self.bot, before, after)) + await self.dispatch_event(events.MessageEdited(self, self.bot, before, after)) async def on_reaction_add(self, reaction: TextReaction, member: Member): - await self._do_event(events.ReactionAdded(self, self.bot, reaction, member)) + await self.dispatch_event( + events.ReactionAdded(self, self.bot, reaction, member) + ) async def on_reaction_remove(self, reaction: TextReaction, member: Member): - await self._do_event(events.ReactionRemoved(self, self.bot, reaction, member)) + await self.dispatch_event( + events.ReactionRemoved(self, self.bot, reaction, member) + ) async def on_member_join(self, member: Member): - await self._do_event(events.MemberJoined(self, self.bot, member)) + await self.dispatch_event(events.MemberJoined(self, self.bot, member)) async def on_member_remove(self, member: Member): - await self._do_event(events.MemberLeft(self, self.bot, member)) + await self.dispatch_event(events.MemberLeft(self, self.bot, member)) async def on_member_update(self, before: Member, after: Member): - await self._do_event(events.MemberUpdated(self, self.bot, before, after)) + await self.dispatch_event(events.MemberUpdated(self, self.bot, before, after)) async def on_user_update(self, before: User, after: User, member: Member): - await self._do_event(events.UserUpdated(self, self.bot, before, after, member)) + await self.dispatch_event( + events.UserUpdated(self, self.bot, before, after, member) + ) async def on_user_ban(self, user: User): - await self._do_event(events.UserBanned(self, self.bot, user)) + await self.dispatch_event(events.UserBanned(self, self.bot, user)) async def on_user_unban(self, user: User): - await self._do_event(events.UserUnbanned(self, self.bot, user)) + await self.dispatch_event(events.UserUnbanned(self, self.bot, user)) # @@ RAW EVENT HANDLERS async def on_raw_message_delete(self, payload: RawMessageDeleteEvent): - await self._do_event(events.RawMessageDeleted(self, self.bot, payload)) + await self.dispatch_event(events.RawMessageDeleted(self, self.bot, payload)) async def on_raw_message_edit(self, payload: RawMessageUpdateEvent): - await self._do_event(events.RawMessageEdited(self, self.bot, payload)) + await self.dispatch_event(events.RawMessageEdited(self, self.bot, payload)) async def on_raw_reaction_add(self, payload: RawReactionActionEvent): - await self._do_event(events.RawReactionAdded(self, self.bot, payload)) + await self.dispatch_event(events.RawReactionAdded(self, self.bot, payload)) async def on_raw_reaction_remove(self, payload: RawReactionActionEvent): - await self._do_event(events.RawReactionRemoved(self, self.bot, payload)) + await self.dispatch_event(events.RawReactionRemoved(self, self.bot, payload)) diff --git a/commanderbot/ext/automod/automod_json_store.py b/commanderbot/ext/automod/automod_json_store.py index 17fa915..7a3018b 100644 --- a/commanderbot/ext/automod/automod_json_store.py +++ b/commanderbot/ext/automod/automod_json_store.py @@ -1,8 +1,9 @@ from dataclasses import dataclass -from typing import AsyncIterable, Optional +from typing import AsyncIterable, Optional, Type, TypeVar from discord import Guild +from commanderbot.ext.automod.automod_bucket import AutomodBucket from commanderbot.ext.automod.automod_data import AutomodData from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.automod_store import AutomodRule @@ -14,6 +15,8 @@ RoleSet, ) +BT = TypeVar("BT", bound=AutomodBucket) + # @implements AutomodStore @dataclass @@ -125,3 +128,17 @@ async def increment_rule_hits(self, guild: Guild, name: str) -> AutomodRule: modified_rule = await cache.increment_rule_hits(guild, name) await self.db.dirty() return modified_rule + + # @implements AutomodStore + async def get_bucket( + self, guild: Guild, name: str, bucket_type: Type[BT] + ) -> Optional[BT]: + cache = await self.db.get_cache() + return await cache.get_bucket(guild, name, bucket_type) + + # @implements AutomodStore + async def require_bucket( + self, guild: Guild, name: str, bucket_type: Type[BT] + ) -> BT: + cache = await self.db.get_cache() + return await cache.require_bucket(guild, name, bucket_type) diff --git a/commanderbot/ext/automod/automod_store.py b/commanderbot/ext/automod/automod_store.py index 26bf331..b0e1292 100644 --- a/commanderbot/ext/automod/automod_store.py +++ b/commanderbot/ext/automod/automod_store.py @@ -1,11 +1,14 @@ -from typing import AsyncIterable, Optional, Protocol +from typing import AsyncIterable, Optional, Protocol, Type, TypeVar from discord import Guild +from commanderbot.ext.automod.automod_bucket import AutomodBucket from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.automod_rule import AutomodRule from commanderbot.lib import JsonObject, LogOptions, RoleSet +BT = TypeVar("BT", bound=AutomodBucket) + class AutomodStore(Protocol): """ @@ -64,3 +67,13 @@ async def disable_rule(self, guild: Guild, name: str) -> AutomodRule: async def increment_rule_hits(self, guild: Guild, name: str) -> AutomodRule: ... + + async def get_bucket( + self, guild: Guild, name: str, bucket_type: Type[BT] + ) -> Optional[BT]: + ... + + async def require_bucket( + self, guild: Guild, name: str, bucket_type: Type[BT] + ) -> BT: + ... diff --git a/commanderbot/ext/automod/buckets/__init__.py b/commanderbot/ext/automod/buckets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/commanderbot/ext/automod/buckets/message_frequency.py b/commanderbot/ext/automod/buckets/message_frequency.py new file mode 100644 index 0000000..e9e24c0 --- /dev/null +++ b/commanderbot/ext/automod/buckets/message_frequency.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any, Iterable, Optional, Type, TypeAlias, TypeVar + +from discord import Member, Message, User + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_bucket import AutomodBucket, AutomodBucketBase +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.lib import ChannelID, JsonObject, UserID +from commanderbot.lib.utils import timedelta_from_field_optional + +ST = TypeVar("ST") + + +class UserTicket: + def __init__(self): + self.message_count: int = 0 + self.unique_channels: set[ChannelID] = set() + + @property + def channel_count(self) -> int: + return len(self.unique_channels) + + def increment(self, message: Message): + """Increment this ticket with the given message.""" + self.message_count += 1 + self.unique_channels.add(message.channel.id) + + def add(self, other: UserTicket): + """Add another ticket to this one.""" + self.message_count += other.message_count + self.unique_channels.update(other.unique_channels) + + +UserBuckets: TypeAlias = defaultdict[UserID, UserTicket] +IntervalBuckets: TypeAlias = defaultdict[int, UserBuckets] + + +def user_buckets_factory() -> UserBuckets: + return defaultdict(default_factory=lambda: UserTicket()) + + +def interval_buckets_factory() -> IntervalBuckets: + return defaultdict(default_factory=user_buckets_factory) + + +@dataclass +class MessageFrequencyState: + interval_buckets: IntervalBuckets = field( + init=False, default_factory=interval_buckets_factory + ) + + +@dataclass +class MessageFrequency(AutomodBucketBase): + """ + Track user activity across channels for potential spam. + + Attributes + ---------- + bucket_lifetime + For how long to record history. Longer durations are able to record more + history for potential queries, but take more memory on average. + bucket_interval + The interval by which to partition buckets. This is used to decide when to + release old, unused buckets and free memory. For example: a value of 3600 will + partition buckets by the hour, and 60 will partition by the minute. + """ + + bucket_lifetime: timedelta + bucket_interval: timedelta + + _state: MessageFrequencyState + + @classmethod + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: + if isinstance(data, dict): + bucket_interval = timedelta_from_field_optional(data, "bucket_interval") + return cls(bucket_interval=bucket_interval) + + @property + def bucket_lifetime_in_seconds(self) -> int: + return int(self.bucket_lifetime.total_seconds()) + + @property + def bucket_interval_in_seconds(self) -> int: + return int(self.bucket_interval.total_seconds()) + + def _message_to_interval(self, message: Message) -> int: + message_seconds = int(message.created_at.timestamp()) + interval = message_seconds // self.bucket_interval_in_seconds + return interval + + def get_user_buckets_since(self, since: datetime) -> Iterable[UserBuckets]: + # Calculate the interval in which the since-time lies. + since_interval = int(since.timestamp()) + # Iterate over each interval... + for interval, user_buckets in self._state.interval_buckets.items(): + # If it's happened since, yield it. + if interval >= since_interval: + yield user_buckets + + def get_user_tickets_since( + self, user: User | Member, since: datetime + ) -> Iterable[UserTicket]: + # Iterate over each interval bucket within the given timeframe... + for user_buckets in self.get_user_buckets_since(since): + # If the user has a ticket in this bucket, yield it. + if user_ticket := user_buckets.get(user.id): + yield user_ticket + + def build_user_record_since( + self, user: User | Member, since: datetime + ) -> UserTicket: + record = UserTicket() + for ticket in self.get_user_tickets_since(user, since): + record.add(ticket) + return record + + def clean_buckets(self): + # IMPL clean buckets to free memory + ... + + async def add(self, event: AutomodEvent): + # Short-circuit if the event does not contain a message. + message = event.message + if not message: + return + + # Release old buckets to free memory. + self.clean_buckets() + + # Calculate the interval based on the most recent message timestamp, and use it + # to get/create the corresponding interval bucket. + interval = self._message_to_interval(message) + interval_bucket = self._state.interval_buckets[interval] + + # Within this interval, get/create and then the user's ticket. + author = message.author + user_ticket = interval_bucket[author.id] + user_ticket.increment(message) + + # Dispatch an event. + await event.state.dispatch_event( + events.MessageFrequencyChanged(event.state, event.bot, message) + ) + + +def create_bucket(data: JsonObject) -> AutomodBucket: + return MessageFrequency.from_data(data) diff --git a/commanderbot/ext/automod/events/__init__.py b/commanderbot/ext/automod/events/__init__.py index b92e08e..03bd448 100644 --- a/commanderbot/ext/automod/events/__init__.py +++ b/commanderbot/ext/automod/events/__init__.py @@ -4,6 +4,7 @@ from .member_updated import * from .message_deleted import * from .message_edited import * +from .message_frequency_changed import * from .message_sent import * from .raw_message_deleted import * from .raw_message_edited import * diff --git a/commanderbot/ext/automod/events/message_frequency_changed.py b/commanderbot/ext/automod/events/message_frequency_changed.py new file mode 100644 index 0000000..bb346ab --- /dev/null +++ b/commanderbot/ext/automod/events/message_frequency_changed.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass + +from discord import Member, TextChannel, Thread + +from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.lib.types import TextMessage + +__all__ = ("MessageFrequencyChanged",) + + +@dataclass +class MessageFrequencyChanged(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 + + @property + def actor(self) -> Member: + return self._message.author + + @property + def member(self) -> Member: + return self._message.author diff --git a/commanderbot/ext/automod/triggers/message_frequency_changed.py b/commanderbot/ext/automod/triggers/message_frequency_changed.py new file mode 100644 index 0000000..74950da --- /dev/null +++ b/commanderbot/ext/automod/triggers/message_frequency_changed.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass +from datetime import timedelta +from typing import Type, TypeVar + +from commanderbot.ext.automod import events +from commanderbot.ext.automod.automod_bucket_ref import BucketRef +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.automod_trigger import AutomodTrigger, AutomodTriggerBase +from commanderbot.ext.automod.buckets.message_frequency import MessageFrequency +from commanderbot.lib import JsonObject +from commanderbot.lib.utils import timedelta_from_field + +ST = TypeVar("ST") + + +@dataclass +class MessageFrequencyChanged(AutomodTriggerBase): + """ + Fires when a message author is suspect of spamming. + + Attributes + ---------- + bucket + The bucket being used to track message frequency. + message_threshold + The minimum number of messages a user must send before being suspected of spam. + Defaults to 3 messages. + channel_threshold + The minimum number of channels in which user must send messages before being + suspected of spam. Defaults to 3 channels. + timeframe + How far back to consider a user's activity for spam. Defaults to 30 seconds. + """ + + event_types = (events.MessageFrequencyChanged,) + + bucket: BucketRef[MessageFrequency] + message_threshold: int + channel_threshold: int + timeframe: timedelta + + @classmethod + def from_data(cls: Type[ST], data: JsonObject) -> ST: + bucket = BucketRef.from_field(data, "bucket") + message_threshold = int(data["message_threshold"]) + channel_threshold = int(data["channel_threshold"]) + timeframe = timedelta_from_field(data, "timeframe") + return cls( + description=data.get("description"), + bucket=bucket, + message_threshold=message_threshold, + channel_threshold=channel_threshold, + timeframe=timeframe, + ) + + async def ignore(self, event: AutomodEvent) -> bool: + # Ignore events without a message. + message = event.message + if not message: + return True + + # Use the bucket to build a record out of our timeframe. + bucket = await self.bucket.resolve(event) + since = message.created_at - self.timeframe + record = bucket.build_user_record_since(message.author, since) + + # Ignore if the record does not meet our thresholds. + enough_messages = record.message_count >= self.message_threshold + enough_channels = record.channel_count >= self.channel_threshold + return enough_messages and enough_channels + + +def create_trigger(data: JsonObject) -> AutomodTrigger: + return MessageFrequencyChanged.from_data(data) From 3b61bdc3b1bb2cb938fa02d0992041d0fe753068 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Fri, 10 Sep 2021 13:20:27 -0400 Subject: [PATCH 05/26] Fix comment --- commanderbot/ext/automod/buckets/message_frequency.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/buckets/message_frequency.py b/commanderbot/ext/automod/buckets/message_frequency.py index e9e24c0..ac10d70 100644 --- a/commanderbot/ext/automod/buckets/message_frequency.py +++ b/commanderbot/ext/automod/buckets/message_frequency.py @@ -139,7 +139,7 @@ async def add(self, event: AutomodEvent): interval = self._message_to_interval(message) interval_bucket = self._state.interval_buckets[interval] - # Within this interval, get/create and then the user's ticket. + # Within this interval, get/create and increment the user's ticket. author = message.author user_ticket = interval_bucket[author.id] user_ticket.increment(message) From 538e9b60b4f900ad0c13c2991f4bd682b9023af9 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Fri, 10 Sep 2021 13:20:41 -0400 Subject: [PATCH 06/26] Fix `AsyncIterable` --- commanderbot/ext/automod/automod_data.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/commanderbot/ext/automod/automod_data.py b/commanderbot/ext/automod/automod_data.py index 2b63918..4458ff4 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -126,7 +126,7 @@ def set_permitted_roles( def all_rules(self) -> Iterable[AutomodRule]: yield from self.rules.values() - async def rules_for_event(self, event: AutomodEvent) -> Iterable[AutomodRule]: + async def rules_for_event(self, event: AutomodEvent) -> AsyncIterable[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]: @@ -292,8 +292,7 @@ async def all_rules(self, guild: Guild) -> AsyncIterable[AutomodRule]: async def rules_for_event( self, guild: Guild, event: AutomodEvent ) -> AsyncIterable[AutomodRule]: - rules = await self.guilds[guild.id].rules_for_event(event) - for rule in rules: + async for rule in self.guilds[guild.id].rules_for_event(event): yield rule # @implements AutomodStore From d7b34c2672a81b39893593c257d658dff3c7794d Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Fri, 10 Sep 2021 13:24:21 -0400 Subject: [PATCH 07/26] Remove unused imports --- commanderbot/ext/automod/automod_guild_state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index 25e8f7a..b9f41ec 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -23,7 +23,6 @@ from yaml import YAMLError from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_bucket import AutomodBucket from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.automod_rule import AutomodRule from commanderbot.ext.automod.automod_store import AutomodStore From 3cde83ea027974a8889731b06ab34991516410ad Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Wed, 22 Sep 2021 09:20:19 -0400 Subject: [PATCH 08/26] WIP --- commanderbot/ext/automod/action/__init__.py | 3 + commanderbot/ext/automod/action/action.py | 13 + .../ext/automod/action/action_base.py | 21 + .../ext/automod/action/action_collection.py | 16 + commanderbot/ext/automod/action/action_ref.py | 13 + .../actions/abc/add_roles_to_target_base.py | 4 +- .../abc/remove_roles_from_target_base.py | 7 +- .../ext/automod/actions/add_reactions.py | 9 +- .../ext/automod/actions/add_roles_to_actor.py | 7 +- .../automod/actions/add_roles_to_author.py | 7 +- .../ext/automod/actions/add_to_bucket.py | 22 +- .../ext/automod/actions/delete_message.py | 8 +- .../ext/automod/actions/log_message.py | 27 +- .../automod/actions/remove_all_reactions.py | 8 +- .../automod/actions/remove_own_reactions.py | 15 +- .../ext/automod/actions/remove_reactions.py | 9 +- .../actions/remove_roles_from_actor.py | 7 +- .../actions/remove_roles_from_author.py | 7 +- .../ext/automod/actions/reply_to_message.py | 19 +- .../ext/automod/actions/send_message.py | 26 +- .../ext/automod/actions/throw_error.py | 8 +- commanderbot/ext/automod/automod_action.py | 48 --- commanderbot/ext/automod/automod_bucket.py | 48 --- .../ext/automod/automod_bucket_ref.py | 33 -- commanderbot/ext/automod/automod_cog.py | 66 ++- commanderbot/ext/automod/automod_condition.py | 49 --- commanderbot/ext/automod/automod_data.py | 391 +++++++----------- commanderbot/ext/automod/automod_entity.py | 120 ------ .../ext/automod/automod_guild_state.py | 195 +++++---- .../ext/automod/automod_json_store.py | 104 ++--- commanderbot/ext/automod/automod_store.py | 54 +-- commanderbot/ext/automod/automod_trigger.py | 83 ---- commanderbot/ext/automod/bucket/__init__.py | 4 + commanderbot/ext/automod/bucket/bucket.py | 13 + .../ext/automod/bucket/bucket_base.py | 21 + .../ext/automod/bucket/bucket_collection.py | 16 + commanderbot/ext/automod/bucket/bucket_ref.py | 13 + .../ext/automod/buckets/message_frequency.py | 6 +- .../ext/automod/component/__init__.py | 3 + .../ext/automod/component/component.py | 23 ++ .../ext/automod/component/component_base.py | 52 +++ .../automod/component/component_collection.py | 29 ++ .../ext/automod/condition/__init__.py | 3 + .../ext/automod/condition/condition.py | 13 + .../ext/automod/condition/condition_base.py | 22 + .../automod/condition/condition_collection.py | 16 + .../ext/automod/condition/condition_ref.py | 13 + .../conditions/abc/target_account_age_base.py | 15 +- .../conditions/abc/target_is_not_bot_base.py | 4 +- .../conditions/abc/target_is_not_self_base.py | 4 +- .../conditions/abc/target_roles_base.py | 15 +- .../automod/conditions/actor_account_age.py | 4 +- .../automod/conditions/actor_is_not_bot.py | 4 +- .../automod/conditions/actor_is_not_self.py | 4 +- .../ext/automod/conditions/actor_roles.py | 4 +- commanderbot/ext/automod/conditions/all_of.py | 29 +- commanderbot/ext/automod/conditions/any_of.py | 30 +- .../automod/conditions/author_account_age.py | 4 +- .../automod/conditions/author_is_not_bot.py | 4 +- .../automod/conditions/author_is_not_self.py | 4 +- .../ext/automod/conditions/author_roles.py | 4 +- .../conditions/message_content_contains.py | 29 +- .../conditions/message_content_matches.py | 26 +- .../conditions/message_has_attachments.py | 22 +- .../automod/conditions/message_has_embeds.py | 22 +- .../automod/conditions/message_has_links.py | 22 +- .../conditions/message_mentions_roles.py | 19 +- .../conditions/message_mentions_users.py | 9 +- .../ext/automod/conditions/throw_error.py | 9 +- commanderbot/ext/automod/node/__init__.py | 4 + commanderbot/ext/automod/node/node.py | 28 ++ commanderbot/ext/automod/node/node_base.py | 89 ++++ .../ext/automod/node/node_collection.py | 164 ++++++++ commanderbot/ext/automod/node/node_kind.py | 65 +++ commanderbot/ext/automod/node/node_ref.py | 46 +++ commanderbot/ext/automod/rule/__init__.py | 2 + .../automod/{automod_rule.py => rule/rule.py} | 67 +-- .../ext/automod/rule/rule_collection.py | 87 ++++ commanderbot/ext/automod/trigger/__init__.py | 3 + commanderbot/ext/automod/trigger/trigger.py | 15 + .../ext/automod/trigger/trigger_base.py | 37 ++ .../ext/automod/trigger/trigger_collection.py | 16 + .../ext/automod/trigger/trigger_ref.py | 13 + .../ext/automod/triggers/member_joined.py | 9 +- .../ext/automod/triggers/member_left.py | 9 +- .../ext/automod/triggers/member_typing.py | 18 +- .../ext/automod/triggers/member_updated.py | 18 +- .../triggers/mentions_removed_from_message.py | 18 +- commanderbot/ext/automod/triggers/message.py | 18 +- .../ext/automod/triggers/message_deleted.py | 4 +- .../ext/automod/triggers/message_edited.py | 4 +- .../triggers/message_frequency_changed.py | 23 +- .../ext/automod/triggers/message_sent.py | 4 +- commanderbot/ext/automod/triggers/reaction.py | 18 +- .../ext/automod/triggers/reaction_added.py | 4 +- .../ext/automod/triggers/reaction_removed.py | 4 +- .../ext/automod/triggers/user_banned.py | 9 +- .../ext/automod/triggers/user_unbanned.py | 9 +- .../ext/automod/triggers/user_updated.py | 9 +- commanderbot/ext/automod/utils.py | 11 +- commanderbot/lib/allowed_mentions.py | 18 +- commanderbot/lib/data.py | 156 ++++++- commanderbot/lib/extended_json_encoder.py | 3 + commanderbot/lib/guards/channels_guard.py | 12 +- commanderbot/lib/guards/reactions_guard.py | 12 +- commanderbot/lib/guards/roles_guard.py | 12 +- commanderbot/lib/integer_range.py | 12 +- commanderbot/lib/intents.py | 18 +- commanderbot/lib/json.py | 5 - commanderbot/lib/log_options.py | 12 +- commanderbot/lib/pattern_wrapper.py | 49 +-- commanderbot/lib/role_set.py | 17 +- commanderbot/lib/utils/json_path.py | 7 +- 113 files changed, 1850 insertions(+), 1289 deletions(-) create mode 100644 commanderbot/ext/automod/action/__init__.py create mode 100644 commanderbot/ext/automod/action/action.py create mode 100644 commanderbot/ext/automod/action/action_base.py create mode 100644 commanderbot/ext/automod/action/action_collection.py create mode 100644 commanderbot/ext/automod/action/action_ref.py delete mode 100644 commanderbot/ext/automod/automod_action.py delete mode 100644 commanderbot/ext/automod/automod_bucket.py delete mode 100644 commanderbot/ext/automod/automod_bucket_ref.py delete mode 100644 commanderbot/ext/automod/automod_condition.py delete mode 100644 commanderbot/ext/automod/automod_entity.py delete mode 100644 commanderbot/ext/automod/automod_trigger.py create mode 100644 commanderbot/ext/automod/bucket/__init__.py create mode 100644 commanderbot/ext/automod/bucket/bucket.py create mode 100644 commanderbot/ext/automod/bucket/bucket_base.py create mode 100644 commanderbot/ext/automod/bucket/bucket_collection.py create mode 100644 commanderbot/ext/automod/bucket/bucket_ref.py create mode 100644 commanderbot/ext/automod/component/__init__.py create mode 100644 commanderbot/ext/automod/component/component.py create mode 100644 commanderbot/ext/automod/component/component_base.py create mode 100644 commanderbot/ext/automod/component/component_collection.py create mode 100644 commanderbot/ext/automod/condition/__init__.py create mode 100644 commanderbot/ext/automod/condition/condition.py create mode 100644 commanderbot/ext/automod/condition/condition_base.py create mode 100644 commanderbot/ext/automod/condition/condition_collection.py create mode 100644 commanderbot/ext/automod/condition/condition_ref.py create mode 100644 commanderbot/ext/automod/node/__init__.py create mode 100644 commanderbot/ext/automod/node/node.py create mode 100644 commanderbot/ext/automod/node/node_base.py create mode 100644 commanderbot/ext/automod/node/node_collection.py create mode 100644 commanderbot/ext/automod/node/node_kind.py create mode 100644 commanderbot/ext/automod/node/node_ref.py create mode 100644 commanderbot/ext/automod/rule/__init__.py rename commanderbot/ext/automod/{automod_rule.py => rule/rule.py} (68%) create mode 100644 commanderbot/ext/automod/rule/rule_collection.py create mode 100644 commanderbot/ext/automod/trigger/__init__.py create mode 100644 commanderbot/ext/automod/trigger/trigger.py create mode 100644 commanderbot/ext/automod/trigger/trigger_base.py create mode 100644 commanderbot/ext/automod/trigger/trigger_collection.py create mode 100644 commanderbot/ext/automod/trigger/trigger_ref.py diff --git a/commanderbot/ext/automod/action/__init__.py b/commanderbot/ext/automod/action/__init__.py new file mode 100644 index 0000000..c25b51c --- /dev/null +++ b/commanderbot/ext/automod/action/__init__.py @@ -0,0 +1,3 @@ +from .action import * +from .action_base import * +from .action_collection import * diff --git a/commanderbot/ext/automod/action/action.py b/commanderbot/ext/automod/action/action.py new file mode 100644 index 0000000..1a58997 --- /dev/null +++ b/commanderbot/ext/automod/action/action.py @@ -0,0 +1,13 @@ +from typing import Protocol + +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.component import Component + +__all__ = ("Action",) + + +class Action(Component, Protocol): + """An action defines a task to perform when conditions pass.""" + + async def apply(self, event: AutomodEvent): + """Apply the action.""" diff --git a/commanderbot/ext/automod/action/action_base.py b/commanderbot/ext/automod/action/action_base.py new file mode 100644 index 0000000..cea594c --- /dev/null +++ b/commanderbot/ext/automod/action/action_base.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import ClassVar + +from commanderbot.ext.automod import actions +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.component import ComponentBase + +__all__ = ("ActionBase",) + + +# @implements Action +@dataclass +class ActionBase(ComponentBase): + # @implements ComponentBase + default_module_prefix: ClassVar[str] = actions.__name__ + + # @implements ComponentBase + module_function_name: ClassVar[str] = "create_action" + + async def apply(self, event: AutomodEvent): + """Override this to apply the action.""" diff --git a/commanderbot/ext/automod/action/action_collection.py b/commanderbot/ext/automod/action/action_collection.py new file mode 100644 index 0000000..6f3a197 --- /dev/null +++ b/commanderbot/ext/automod/action/action_collection.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import ClassVar, Type + +from commanderbot.ext.automod.action.action import Action +from commanderbot.ext.automod.action.action_base import ActionBase +from commanderbot.ext.automod.component import ComponentCollection + +__all__ = ("ActionCollection",) + + +@dataclass(init=False) +class ActionCollection(ComponentCollection[Action]): + """A collection of actions.""" + + # @implements NodeCollection + node_type: ClassVar[Type[Action]] = ActionBase diff --git a/commanderbot/ext/automod/action/action_ref.py b/commanderbot/ext/automod/action/action_ref.py new file mode 100644 index 0000000..a720052 --- /dev/null +++ b/commanderbot/ext/automod/action/action_ref.py @@ -0,0 +1,13 @@ +from typing import TypeVar + +from commanderbot.ext.automod.action.action import Action +from commanderbot.ext.automod.node import NodeRef + +__all__ = ("ActionRef",) + + +NT = TypeVar("NT", bound=Action) + + +class ActionRef(NodeRef[NT]): + """A reference to an action, by name.""" 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 index 828027b..2ac1683 100644 --- a/commanderbot/ext/automod/actions/abc/add_roles_to_target_base.py +++ b/commanderbot/ext/automod/actions/abc/add_roles_to_target_base.py @@ -3,13 +3,13 @@ from discord import Guild, Member -from commanderbot.ext.automod.automod_action import AutomodActionBase +from commanderbot.ext.automod.action import ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.lib import RoleID @dataclass -class AddRolesToTargetBase(AutomodActionBase): +class AddRolesToTargetBase(ActionBase): roles: Tuple[RoleID] reason: Optional[str] = None 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 index af9ec1c..7301345 100644 --- a/commanderbot/ext/automod/actions/abc/remove_roles_from_target_base.py +++ b/commanderbot/ext/automod/actions/abc/remove_roles_from_target_base.py @@ -3,14 +3,15 @@ from discord import Guild, Member -from commanderbot.ext.automod.automod_action import AutomodActionBase +from commanderbot.ext.automod.action import ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.lib import RoleID @dataclass -class RemoveRolesFromTargetBase(AutomodActionBase): +class RemoveRolesFromTargetBase(ActionBase): roles: Tuple[RoleID] + reason: Optional[str] = None def get_target(self, event: AutomodEvent) -> Optional[Member]: raise NotImplementedError() @@ -21,4 +22,4 @@ async def apply(self, event: AutomodEvent): # 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) + await member.remove_roles(*roles, reason=self.reason) diff --git a/commanderbot/ext/automod/actions/add_reactions.py b/commanderbot/ext/automod/actions/add_reactions.py index e594acf..c88f452 100644 --- a/commanderbot/ext/automod/actions/add_reactions.py +++ b/commanderbot/ext/automod/actions/add_reactions.py @@ -1,13 +1,12 @@ from dataclasses import dataclass -from typing import Tuple +from typing import Any, Tuple -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase +from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject @dataclass -class AddReactions(AutomodActionBase): +class AddReactions(ActionBase): """ Add reactions to the message in context. @@ -25,5 +24,5 @@ async def apply(self, event: AutomodEvent): await message.add_reaction(reaction) -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return AddReactions.from_data(data) diff --git a/commanderbot/ext/automod/actions/add_roles_to_actor.py b/commanderbot/ext/automod/actions/add_roles_to_actor.py index 0e2a800..c7608d2 100644 --- a/commanderbot/ext/automod/actions/add_roles_to_actor.py +++ b/commanderbot/ext/automod/actions/add_roles_to_actor.py @@ -1,14 +1,13 @@ from dataclasses import dataclass -from typing import Optional +from typing import Any, Optional from discord import Member +from commanderbot.ext.automod.action import Action 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 @dataclass @@ -28,5 +27,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.actor -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return AddRolesToActor.from_data(data) diff --git a/commanderbot/ext/automod/actions/add_roles_to_author.py b/commanderbot/ext/automod/actions/add_roles_to_author.py index 9a7392c..6ef2c88 100644 --- a/commanderbot/ext/automod/actions/add_roles_to_author.py +++ b/commanderbot/ext/automod/actions/add_roles_to_author.py @@ -1,14 +1,13 @@ from dataclasses import dataclass -from typing import Optional +from typing import Any, Optional from discord import Member +from commanderbot.ext.automod.action import Action 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 @dataclass @@ -28,5 +27,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.author -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return AddRolesToAuthor.from_data(data) diff --git a/commanderbot/ext/automod/actions/add_to_bucket.py b/commanderbot/ext/automod/actions/add_to_bucket.py index cddffde..decd4ce 100644 --- a/commanderbot/ext/automod/actions/add_to_bucket.py +++ b/commanderbot/ext/automod/actions/add_to_bucket.py @@ -1,17 +1,13 @@ from dataclasses import dataclass -from typing import Type, TypeVar +from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase -from commanderbot.ext.automod.automod_bucket import AutomodBucket -from commanderbot.ext.automod.automod_bucket_ref import BucketRef +from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject - -ST = TypeVar("ST") +from commanderbot.ext.automod.bucket import BucketRef @dataclass -class AddToBucket(AutomodActionBase): +class AddToBucket(ActionBase): """ Add the event to a bucket. @@ -21,13 +17,13 @@ class AddToBucket(AutomodActionBase): The bucket to add to. """ - bucket: BucketRef[AutomodBucket] + bucket: BucketRef + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: bucket = BucketRef.from_field(data, "bucket") - return cls( - description=data.get("description"), + return dict( bucket=bucket, ) @@ -37,5 +33,5 @@ async def apply(self, event: AutomodEvent): await bucket.add(event) -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return AddToBucket.from_data(data) diff --git a/commanderbot/ext/automod/actions/delete_message.py b/commanderbot/ext/automod/actions/delete_message.py index 2127b25..3586bd0 100644 --- a/commanderbot/ext/automod/actions/delete_message.py +++ b/commanderbot/ext/automod/actions/delete_message.py @@ -1,12 +1,12 @@ from dataclasses import dataclass +from typing import Any -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase +from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject @dataclass -class DeleteMessage(AutomodActionBase): +class DeleteMessage(ActionBase): """ Delete the message in context. """ @@ -16,5 +16,5 @@ async def apply(self, event: AutomodEvent): await message.delete() -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return DeleteMessage.from_data(data) diff --git a/commanderbot/ext/automod/actions/log_message.py b/commanderbot/ext/automod/actions/log_message.py index 23162b3..ae58c45 100644 --- a/commanderbot/ext/automod/actions/log_message.py +++ b/commanderbot/ext/automod/actions/log_message.py @@ -1,19 +1,17 @@ from dataclasses import dataclass -from typing import Dict, Optional, Type, TypeVar +from typing import Any, Dict, Optional from discord import Color from discord.abc import Messageable -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase +from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import AllowedMentions, ChannelID, JsonObject, ValueFormatter +from commanderbot.lib import AllowedMentions, ChannelID, ValueFormatter from commanderbot.lib.utils import color_from_field_optional, message_to_file -ST = TypeVar("ST") - @dataclass -class LogMessage(AutomodActionBase): +class LogMessage(ActionBase): """ Send a log message, with pings disabled by default. @@ -45,24 +43,21 @@ class LogMessage(AutomodActionBase): fields: Optional[Dict[str, str]] = None allowed_mentions: Optional[AllowedMentions] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: 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"), + return dict( color=color, - fields=data.get("fields"), - attach_message=data.get("attach_message"), allowed_mentions=allowed_mentions, ) async def resolve_channel(self, event: AutomodEvent) -> Optional[Messageable]: if self.channel is not None: - return event.bot.get_channel(self.channel) + channel = event.bot.get_channel(self.channel) + assert isinstance(channel, Messageable) + return channel return event.channel async def apply(self, event: AutomodEvent): @@ -98,5 +93,5 @@ async def apply(self, event: AutomodEvent): await channel.send(content, allowed_mentions=allowed_mentions) -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return LogMessage.from_data(data) diff --git a/commanderbot/ext/automod/actions/remove_all_reactions.py b/commanderbot/ext/automod/actions/remove_all_reactions.py index 4121aef..db0d83e 100644 --- a/commanderbot/ext/automod/actions/remove_all_reactions.py +++ b/commanderbot/ext/automod/actions/remove_all_reactions.py @@ -1,12 +1,12 @@ from dataclasses import dataclass +from typing import Any -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase +from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject @dataclass -class RemoveAllReactions(AutomodActionBase): +class RemoveAllReactions(ActionBase): """ Remove all reactions from the message in context. """ @@ -16,5 +16,5 @@ async def apply(self, event: AutomodEvent): await message.clear_reactions() -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return RemoveAllReactions.from_data(data) diff --git a/commanderbot/ext/automod/actions/remove_own_reactions.py b/commanderbot/ext/automod/actions/remove_own_reactions.py index d1e6f39..71362c1 100644 --- a/commanderbot/ext/automod/actions/remove_own_reactions.py +++ b/commanderbot/ext/automod/actions/remove_own_reactions.py @@ -1,13 +1,14 @@ from dataclasses import dataclass -from typing import Tuple +from typing import Any, Tuple -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase +from discord import User + +from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject @dataclass -class RemoveOwnReactions(AutomodActionBase): +class RemoveOwnReactions(ActionBase): """ Remove the bot's own reactions from the message in context. @@ -21,9 +22,11 @@ class RemoveOwnReactions(AutomodActionBase): async def apply(self, event: AutomodEvent): if message := event.message: + bot_user = event.bot.user + assert isinstance(bot_user, User) for reaction in self.reactions: - await message.remove_reaction(reaction, member=event.bot.user) + await message.remove_reaction(reaction, member=bot_user) -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return RemoveOwnReactions.from_data(data) diff --git a/commanderbot/ext/automod/actions/remove_reactions.py b/commanderbot/ext/automod/actions/remove_reactions.py index 6a645d3..55ae29a 100644 --- a/commanderbot/ext/automod/actions/remove_reactions.py +++ b/commanderbot/ext/automod/actions/remove_reactions.py @@ -1,13 +1,12 @@ from dataclasses import dataclass -from typing import Tuple +from typing import Any, Tuple -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase +from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject @dataclass -class RemoveReactions(AutomodActionBase): +class RemoveReactions(ActionBase): """ Remove certain reactions from the message in context. @@ -25,5 +24,5 @@ async def apply(self, event: AutomodEvent): await message.clear_reaction(reaction) -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return RemoveReactions.from_data(data) diff --git a/commanderbot/ext/automod/actions/remove_roles_from_actor.py b/commanderbot/ext/automod/actions/remove_roles_from_actor.py index b89b6b7..e326c02 100644 --- a/commanderbot/ext/automod/actions/remove_roles_from_actor.py +++ b/commanderbot/ext/automod/actions/remove_roles_from_actor.py @@ -1,14 +1,13 @@ from dataclasses import dataclass -from typing import Optional +from typing import Any, Optional from discord import Member +from commanderbot.ext.automod.action import Action 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 @dataclass @@ -26,5 +25,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.actor -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return RemoveRolesFromActor.from_data(data) diff --git a/commanderbot/ext/automod/actions/remove_roles_from_author.py b/commanderbot/ext/automod/actions/remove_roles_from_author.py index b9b0cb1..ac60034 100644 --- a/commanderbot/ext/automod/actions/remove_roles_from_author.py +++ b/commanderbot/ext/automod/actions/remove_roles_from_author.py @@ -1,14 +1,13 @@ from dataclasses import dataclass -from typing import Optional +from typing import Any, Optional from discord import Member +from commanderbot.ext.automod.action import Action 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 @dataclass @@ -26,5 +25,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.author -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return RemoveRolesFromAuthor.from_data(data) diff --git a/commanderbot/ext/automod/actions/reply_to_message.py b/commanderbot/ext/automod/actions/reply_to_message.py index ce9e2d5..399aabd 100644 --- a/commanderbot/ext/automod/actions/reply_to_message.py +++ b/commanderbot/ext/automod/actions/reply_to_message.py @@ -1,15 +1,13 @@ from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase +from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import AllowedMentions, JsonObject - -ST = TypeVar("ST") +from commanderbot.lib import AllowedMentions @dataclass -class ReplyToMessage(AutomodActionBase): +class ReplyToMessage(ActionBase): """ Reply to the message in context. @@ -25,12 +23,11 @@ class ReplyToMessage(AutomodActionBase): content: str allowed_mentions: Optional[AllowedMentions] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: allowed_mentions = AllowedMentions.from_field_optional(data, "allowed_mentions") - return cls( - description=data.get("description"), - content=data.get("content"), + return dict( allowed_mentions=allowed_mentions, ) @@ -44,5 +41,5 @@ async def apply(self, event: AutomodEvent): ) -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return ReplyToMessage.from_data(data) diff --git a/commanderbot/ext/automod/actions/send_message.py b/commanderbot/ext/automod/actions/send_message.py index 44b12d3..40b0128 100644 --- a/commanderbot/ext/automod/actions/send_message.py +++ b/commanderbot/ext/automod/actions/send_message.py @@ -1,17 +1,15 @@ -from dataclasses import dataclass, field -from typing import Optional, Type, TypeVar +from dataclasses import dataclass +from typing import Any, Dict, Optional from discord.abc import Messageable -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase +from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import AllowedMentions, ChannelID, JsonObject - -ST = TypeVar("ST") +from commanderbot.lib import AllowedMentions, ChannelID @dataclass -class SendMessage(AutomodActionBase): +class SendMessage(ActionBase): """ Send a message. @@ -30,19 +28,19 @@ class SendMessage(AutomodActionBase): channel: Optional[ChannelID] = None allowed_mentions: Optional[AllowedMentions] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: allowed_mentions = AllowedMentions.from_field_optional(data, "allowed_mentions") - return cls( - description=data.get("description"), - content=data.get("content"), - channel=data.get("channel"), + return dict( allowed_mentions=allowed_mentions, ) async def resolve_channel(self, event: AutomodEvent) -> Optional[Messageable]: if self.channel is not None: - return event.bot.get_channel(self.channel) + channel = event.bot.get_channel(self.channel) + assert isinstance(channel, Messageable) + return channel return event.channel async def apply(self, event: AutomodEvent): @@ -55,5 +53,5 @@ async def apply(self, event: AutomodEvent): ) -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return SendMessage.from_data(data) diff --git a/commanderbot/ext/automod/actions/throw_error.py b/commanderbot/ext/automod/actions/throw_error.py index dde86a7..ac4eb64 100644 --- a/commanderbot/ext/automod/actions/throw_error.py +++ b/commanderbot/ext/automod/actions/throw_error.py @@ -1,12 +1,12 @@ from dataclasses import dataclass +from typing import Any -from commanderbot.ext.automod.automod_action import AutomodAction, AutomodActionBase +from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject @dataclass -class ThrowError(AutomodActionBase): +class ThrowError(ActionBase): """ Throw an error when running the action. @@ -24,5 +24,5 @@ async def apply(self, event: AutomodEvent): raise Exception(self.error) -def create_action(data: JsonObject) -> AutomodAction: +def create_action(data: Any) -> Action: return ThrowError.from_data(data) diff --git a/commanderbot/ext/automod/automod_action.py b/commanderbot/ext/automod/automod_action.py deleted file mode 100644 index 08f8dd3..0000000 --- a/commanderbot/ext/automod/automod_action.py +++ /dev/null @@ -1,48 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Iterable, List, 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_bucket.py b/commanderbot/ext/automod/automod_bucket.py deleted file mode 100644 index 5f4f7ff..0000000 --- a/commanderbot/ext/automod/automod_bucket.py +++ /dev/null @@ -1,48 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Iterable, List, Optional, Protocol - -from commanderbot.ext.automod import buckets -from commanderbot.ext.automod.automod_entity import ( - AutomodEntity, - AutomodEntityBase, - deserialize_entities, -) -from commanderbot.ext.automod.automod_event import AutomodEvent - - -class AutomodBucket(AutomodEntity, Protocol): - description: Optional[str] - - async def add(self, event: AutomodEvent): - """Add the event to the bucket.""" - - -# @implements AutomodBucket -@dataclass -class AutomodBucketBase(AutomodEntityBase): - """ - Base bucket for inheriting base fields and functionality. - - Attributes - ---------- - description - A human-readable description of the bucket. - """ - - default_module_prefix = buckets.__name__ - module_function_name = "create_bucket" - - description: Optional[str] - - async def add(self, event: AutomodEvent): - """Override this to modify the bucket according to the event.""" - - -def deserialize_buckets(data: Iterable[Any]) -> List[AutomodBucket]: - return deserialize_entities( - entity_type=AutomodBucketBase, - data=data, - defaults={ - "description": None, - }, - ) diff --git a/commanderbot/ext/automod/automod_bucket_ref.py b/commanderbot/ext/automod/automod_bucket_ref.py deleted file mode 100644 index bc8c58a..0000000 --- a/commanderbot/ext/automod/automod_bucket_ref.py +++ /dev/null @@ -1,33 +0,0 @@ -import typing -from dataclasses import dataclass -from typing import Any, Generic, Optional, Type, TypeVar - -from commanderbot.ext.automod.automod_bucket import AutomodBucket -from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import FromDataMixin, JsonSerializable - -ST = TypeVar("ST") -BT = TypeVar("BT", bound=AutomodBucket) - - -@dataclass -class BucketRef(JsonSerializable, FromDataMixin, Generic[BT]): - name: str - - # @overrides FromDataMixin - @classmethod - def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: - if isinstance(data, str): - return cls(name=data) - - # @implements JsonSerializable - def to_json(self) -> Any: - return self.name - - async def resolve(self, event: AutomodEvent) -> BT: - bucket_generics = typing.get_args(self) - bucket_type = bucket_generics[0] - bucket = await event.state.store.require_bucket( - event.state.guild, self.name, bucket_type - ) - return bucket diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index 540f63e..a1d749e 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -24,6 +24,7 @@ from commanderbot.ext.automod.automod_options import AutomodOptions from commanderbot.ext.automod.automod_state import AutomodState from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.ext.automod.node.node_kind import NodeKind, NodeKindConverter from commanderbot.lib import ( CogGuildStateManager, GuildContext, @@ -367,6 +368,44 @@ async def cmd_automod_options_permit_set(self, ctx: GuildContext, *roles: Role): async def cmd_automod_options_permit_clear(self, ctx: GuildContext): await self.state[ctx.guild].clear_permitted_roles(ctx) + # @@ automod nodes + + @cmd_automod.group( + name="nodes", + brief="Browse and manage automod nodes.", + ) + async def cmd_automod_nodes(self, ctx: GuildContext): + if not ctx.invoked_subcommand: + await ctx.send_help(self.cmd_automod_nodes) + + @cmd_automod_nodes.command( + name="list", + brief="List automod nodes.", + ) + async def cmd_automod_nodes_list( + self, + ctx: GuildContext, + node_type: NodeKindConverter, + query: Optional[str], + ): + node_kind = cast(NodeKind, node_type) + await self.state[ctx.guild].list_nodes(ctx, node_kind, query) + + @cmd_automod_nodes.command( + name="print", + brief="Print the code of an automod node.", + ) + async def cmd_automod_nodes_print( + self, + ctx: GuildContext, + node_type: NodeKindConverter, + query: str, + path: Optional[str], + ): + node_kind = cast(NodeKind, node_type) + parsed_path = parse_json_path(path) if path else None + await self.state[ctx.guild].print_node(ctx, node_kind, query, parsed_path) + # @@ automod rules @cmd_automod.group( @@ -378,14 +417,14 @@ async def cmd_automod_rules(self, ctx: GuildContext): if ctx.subcommand_passed: await ctx.send_help(self.cmd_automod_rules) else: - await self.state[ctx.guild].show_rules(ctx) + await self.state[ctx.guild].list_nodes(ctx, NodeKind.RULE) @cmd_automod_rules.command( - name="show", - brief="List and show automod rules.", + name="list", + brief="List automod rules.", ) - async def cmd_automod_rules_show(self, ctx: GuildContext, query: str): - await self.state[ctx.guild].show_rules(ctx, query) + async def cmd_automod_rules_list(self, ctx: GuildContext, query: Optional[str]): + await self.state[ctx.guild].list_nodes(ctx, NodeKind.RULE, query) @cmd_automod_rules.command( name="print", @@ -398,21 +437,21 @@ async def cmd_automod_rules_print( path: Optional[str], ): parsed_path = parse_json_path(path) if path else None - await self.state[ctx.guild].print_rule(ctx, query, parsed_path) + await self.state[ctx.guild].print_node(ctx, NodeKind.RULE, query, parsed_path) @cmd_automod_rules.command( name="add", brief="Add a new automod rule.", ) async def cmd_automod_rules_add(self, ctx: GuildContext, *, body: str): - await self.state[ctx.guild].add_rule(ctx, body) + await self.state[ctx.guild].add_node(ctx, NodeKind.RULE, body) @cmd_automod_rules.command( name="remove", brief="Remove an automod rule.", ) async def cmd_automod_rules_remove(self, ctx: GuildContext, name: str): - await self.state[ctx.guild].remove_rule(ctx, name) + await self.state[ctx.guild].remove_node(ctx, NodeKind.RULE, name) @cmd_automod_rules.command( name="modify", @@ -429,7 +468,16 @@ async def cmd_automod_rules_modify( ): parsed_path = parse_json_path(path) parsed_op = parse_json_path_op(op) - await self.state[ctx.guild].modify_rule(ctx, name, parsed_path, parsed_op, body) + await self.state[ctx.guild].modify_node( + ctx, NodeKind.RULE, name, parsed_path, parsed_op, body + ) + + @cmd_automod_rules.command( + name="explain", + brief="Explain an automod rule.", + ) + async def cmd_automod_rules_explain(self, ctx: GuildContext, query: str): + await self.state[ctx.guild].explain_rule(ctx, query) @cmd_automod_rules.command( name="enable", diff --git a/commanderbot/ext/automod/automod_condition.py b/commanderbot/ext/automod/automod_condition.py deleted file mode 100644 index bf588e7..0000000 --- a/commanderbot/ext/automod/automod_condition.py +++ /dev/null @@ -1,49 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Iterable, List, 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_data.py b/commanderbot/ext/automod/automod_data.py index 4458ff4..311e418 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -1,113 +1,102 @@ -from __future__ import annotations - +from asyncio.locks import Condition from collections import defaultdict from dataclasses import dataclass, field -from datetime import datetime -from typing import ( - Any, - AsyncIterable, - DefaultDict, - Dict, - Iterable, - Optional, - Set, - Type, - TypeVar, -) +from typing import Any, AsyncIterable, DefaultDict, Dict, Optional, Type, TypeVar, cast from discord import Guild -from commanderbot.ext.automod.automod_bucket import AutomodBucket +from commanderbot.ext.automod.action import Action, ActionCollection 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, -) -from commanderbot.lib.json import to_data -from commanderbot.lib.utils import ( - JsonPath, - JsonPathOp, - dict_without_ellipsis, - update_json_with_path, -) - -BT = TypeVar("BT", bound=AutomodBucket) - - -RulesByEventType = DefaultDict[Type[AutomodEvent], Set[AutomodRule]] - - -class AutomodRuleWithNameAlreadyExists(ResponsiveException): - def __init__(self, name: str): - self.name: str = name - super().__init__(f"A rule with the name `{name}` already exists") - +from commanderbot.ext.automod.bucket import Bucket, BucketCollection +from commanderbot.ext.automod.condition import Condition, ConditionCollection +from commanderbot.ext.automod.node import Node, NodeCollection +from commanderbot.ext.automod.rule import Rule, RuleCollection +from commanderbot.ext.automod.trigger import Trigger, TriggerCollection +from commanderbot.lib import FromData, GuildID, LogOptions, RoleSet, ToData +from commanderbot.lib.responsive_exception import ResponsiveException +from commanderbot.lib.utils import JsonPath, JsonPathOp, dict_without_ellipsis -class AutomodNoRuleWithName(ResponsiveException): - def __init__(self, name: str): - self.name: str = name - super().__init__(f"There is no rule with the name `{name}`") - - -class AutomodRuleNotRegistered(ResponsiveException): - def __init__(self, rule: AutomodRule): - self.rule: AutomodRule = rule - super().__init__(f"Rule `{rule.name}` is not registered") - - -class AutomodInvalidFields(ResponsiveException): - def __init__(self, names: Set[str]): - self.names: Set[str] = names - super().__init__("These fields are invalid: " + "`" + "` `".join(names) + "`") - - -class AutomodUnmodifiableFields(ResponsiveException): - def __init__(self, names: Set[str]): - self.names: Set[str] = names - super().__init__( - "These fields cannot be modified: " + "`" + "` `".join(names) + "`" - ) +ST = TypeVar("ST") +NT = TypeVar("NT", bound=Node) @dataclass -class AutomodGuildData: - # Default logging configuration for this guild. - default_log_options: Optional[LogOptions] = None +class AutomodGuildData(FromData, ToData): + """ + In-memory cache for a particular guild's automod data. + + Attributes + ---------- + default_log_options + Default logging configuration for this guild. + permitted_roles + Roles that are permitted to manage the extension within this guild. + rules + The guild's rules. + buckets + The guild's buckets. + triggers + The guild's triggers. + conditions + The guild's conditions. + actions + The guild's actions. + """ - # Roles that are permitted to manage the extension within this guild. + default_log_options: Optional[LogOptions] = None permitted_roles: Optional[RoleSet] = None - # Index rules by name for faster look-up in commands. - rules: Dict[str, AutomodRule] = field(init=False, default_factory=dict) - - # 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()) + rules: RuleCollection = field(default_factory=lambda: RuleCollection()) + buckets: BucketCollection = field(default_factory=lambda: BucketCollection()) + triggers: TriggerCollection = field(default_factory=lambda: TriggerCollection()) + conditions: ConditionCollection = field( + default_factory=lambda: ConditionCollection() ) + actions: ActionCollection = field(default_factory=lambda: ActionCollection()) + + # @overrides FromData + @classmethod + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: + if isinstance(data, dict): + default_log_options = LogOptions.from_field_optional(data, "log") + permitted_roles = RoleSet.from_field_optional(data, "permitted_roles") + rules = RuleCollection.from_field_default( + data, "rules", lambda: RuleCollection() + ) + buckets = BucketCollection.from_field_default( + data, "buckets", lambda: BucketCollection() + ) + triggers = TriggerCollection.from_field_default( + data, "triggers", lambda: TriggerCollection() + ) + conditions = ConditionCollection.from_field_default( + data, "conditions", lambda: ConditionCollection() + ) + actions = ActionCollection.from_field_default( + data, "actions", lambda: ActionCollection() + ) + return cls( + default_log_options=default_log_options, + permitted_roles=permitted_roles, + rules=rules, + buckets=buckets, + triggers=triggers, + conditions=conditions, + actions=actions, + ) - @staticmethod - def from_data(data: JsonObject) -> AutomodGuildData: - default_log_options = LogOptions.from_field_optional(data, "log") - permitted_roles = RoleSet.from_field_optional(data, "permitted_roles") - guild_data = AutomodGuildData( - 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 dict_without_ellipsis( - log=self.default_log_options or ..., - permitted_roles=self.permitted_roles or ..., - rules=list(self.rules.values()) or ..., - ) + def get_collection(self, node_type: Type[NT]) -> NodeCollection[NT]: + if node_type is Rule: + return cast(Any, self.rules) + if node_type is Bucket: + return cast(Any, self.buckets) + if node_type is Trigger: + return cast(Any, self.triggers) + if node_type is Condition: + return cast(Any, self.conditions) + if node_type is Action: + return cast(Any, self.actions) + raise ResponsiveException(f"Invalid node type: {node_type}") def set_default_log_options( self, log_options: Optional[LogOptions] @@ -123,107 +112,6 @@ def set_permitted_roles( self.permitted_roles = permitted_roles return old_value - def all_rules(self) -> Iterable[AutomodRule]: - yield from self.rules.values() - - async def rules_for_event(self, event: AutomodEvent) -> AsyncIterable[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 await rule.poll_triggers(event): - yield rule - - def query_rules(self, query: str) -> Iterable[AutomodRule]: - # Yield any rules whose name contains the case-insensitive query. - query_lower = query.lower() - for rule_name, rule in self.rules.items(): - if query_lower in rule_name.lower(): - yield rule - - def get_rule(self, name: str) -> Optional[AutomodRule]: - return self.rules.get(name) - - def require_rule(self, name: str) -> AutomodRule: - if rule := self.get_rule(name): - return rule - raise AutomodNoRuleWithName(name) - - def _add_rule_to_cache(self, rule: AutomodRule): - for trigger in rule.triggers: - for event_type in trigger.event_types: - self.rules_by_event_type[event_type].add(rule) - - def add_rule(self, rule: AutomodRule): - if rule.name in self.rules: - raise AutomodRuleWithNameAlreadyExists(rule.name) - self.rules[rule.name] = rule - self._add_rule_to_cache(rule) - - def add_rule_from_data(self, data: JsonObject) -> AutomodRule: - rule = AutomodRule.from_data(data) - self.add_rule(rule) - return rule - - def _remove_rule_from_cache(self, rule: AutomodRule): - for rules in self.rules_by_event_type.values(): - if rule in rules: - rules.remove(rule) - - def remove_rule(self, rule: AutomodRule): - existing_rule = self.rules.get(rule.name) - if not (existing_rule and (existing_rule is rule)): - raise AutomodRuleNotRegistered(rule) - self._remove_rule_from_cache(rule) - del self.rules[rule.name] - - def remove_rule_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - self.remove_rule(rule) - return rule - - def modify_rule_raw( - self, - name: str, - path: JsonPath, - op: 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) - - # Update the modification timestamp. Note that it may still be overidden. - new_data["modified_on"] = datetime.utcnow().isoformat() - - # Update the new rule data using the given changes. - update_json_with_path(new_data, path, op, data) - - # Create a new rule out of the modified data. - new_rule = AutomodRule.from_data(new_data) - - # Remove the old rule, and then add the new one. - self.remove_rule(old_rule) - self.add_rule(new_rule) - - # Return the new rule. - return new_rule - - def enable_rule_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - rule.disabled = False - return rule - - def disable_rule_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - rule.disabled = True - return rule - - def increment_rule_hits_by_name(self, name: str) -> AutomodRule: - rule = self.require_rule(name) - rule.hits += 1 - return rule - def _guilds_defaultdict_factory() -> DefaultDict[GuildID, AutomodGuildData]: return defaultdict(lambda: AutomodGuildData()) @@ -231,17 +119,23 @@ def _guilds_defaultdict_factory() -> DefaultDict[GuildID, AutomodGuildData]: # @implements AutomodStore @dataclass -class AutomodData: +class AutomodData(FromData, ToData): """ Implementation of `AutomodStore` using an in-memory object hierarchy. + + Attributes + ---------- + guilds + The guilds recorded. """ guilds: DefaultDict[GuildID, AutomodGuildData] = field( default_factory=_guilds_defaultdict_factory ) - @staticmethod - def from_data(data: JsonObject) -> AutomodData: + # @overrides FromData + @classmethod + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: guilds = _guilds_defaultdict_factory() guilds.update( { @@ -249,20 +143,22 @@ def from_data(data: JsonObject) -> AutomodData: for guild_id, raw_guild_data in data.get("guilds", {}).items() } ) - return AutomodData(guilds=guilds) + return cls(guilds=guilds) - def to_data(self) -> JsonObject: - # Omit empty guilds, as well as an empty list of guilds. - return dict_without_ellipsis( + # @overrides ToData + def complex_fields_to_data(self) -> Optional[Dict[str, Any]]: + # Map guild IDs to guild data, and omit empty guilds. + return dict( guilds=dict_without_ellipsis( { str(guild_id): (guild_data.to_data() or ...) for guild_id, guild_data in self.guilds.items() } ) - or ... ) + # @@ OPTIONS + # @implements AutomodStore async def get_default_log_options(self, guild: Guild) -> Optional[LogOptions]: return self.guilds[guild.id].default_log_options @@ -283,72 +179,81 @@ async def set_permitted_roles( ) -> Optional[RoleSet]: return self.guilds[guild.id].set_permitted_roles(permitted_roles) + # @@ NODES + # @implements AutomodStore - async def all_rules(self, guild: Guild) -> AsyncIterable[AutomodRule]: - for rule in self.guilds[guild.id].all_rules(): - yield rule + async def all_nodes(self, guild: Guild, node_type: Type[NT]) -> AsyncIterable[NT]: + collection = self.guilds[guild.id].get_collection(node_type) + for node in collection: + yield node # @implements AutomodStore - async def rules_for_event( - self, guild: Guild, event: AutomodEvent - ) -> AsyncIterable[AutomodRule]: - async for rule in self.guilds[guild.id].rules_for_event(event): - yield rule + async def query_nodes( + self, guild: Guild, node_type: Type[NT], query: str + ) -> AsyncIterable[NT]: + collection = self.guilds[guild.id].get_collection(node_type) + for node in collection.query(query): + yield node # @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 + async def get_node( + self, guild: Guild, node_type: Type[NT], name: str + ) -> Optional[NT]: + collection = self.guilds[guild.id].get_collection(node_type) + return collection.get(name) # @implements AutomodStore - async def get_rule(self, guild: Guild, name: str) -> Optional[AutomodRule]: - return self.guilds[guild.id].get_rule(name) + async def require_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: + collection = self.guilds[guild.id].get_collection(node_type) + return collection.require(name) # @implements AutomodStore - async def require_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].require_rule(name) + async def require_node_with_type( + self, guild: Guild, node_type: Type[NT], name: str + ) -> NT: + collection = self.guilds[guild.id].get_collection(node_type) + return collection.require_with_type(name, node_type) # @implements AutomodStore - async def add_rule(self, guild: Guild, data: JsonObject) -> AutomodRule: - return self.guilds[guild.id].add_rule_from_data(data) + async def add_node(self, guild: Guild, node_type: Type[NT], data: Any) -> NT: + collection = self.guilds[guild.id].get_collection(node_type) + return collection.add_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) + async def remove_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: + collection = self.guilds[guild.id].get_collection(node_type) + return collection.remove_by_name(name) # @implements AutomodStore - async def modify_rule( + async def modify_node( self, guild: Guild, + node_type: Type[NT], name: str, path: JsonPath, op: JsonPathOp, data: Any, - ) -> AutomodRule: - return self.guilds[guild.id].modify_rule_raw(name, path, op, data) + ) -> NT: + collection = self.guilds[guild.id].get_collection(node_type) + return collection.modify(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) + # @@ RULES # @implements AutomodStore - async def disable_rule(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].disable_rule_by_name(name) + async def rules_for_event( + self, guild: Guild, event: AutomodEvent + ) -> AsyncIterable[Rule]: + async for rule in self.guilds[guild.id].rules.for_event(event): + yield rule # @implements AutomodStore - async def increment_rule_hits(self, guild: Guild, name: str) -> AutomodRule: - return self.guilds[guild.id].increment_rule_hits_by_name(name) + async def enable_rule(self, guild: Guild, name: str) -> Rule: + return self.guilds[guild.id].rules.enable_by_name(name) # @implements AutomodStore - async def get_bucket( - self, guild: Guild, name: str, bucket_type: Type[BT] - ) -> Optional[BT]: - # IMPL - ... + async def disable_rule(self, guild: Guild, name: str) -> Rule: + return self.guilds[guild.id].rules.disable_by_name(name) # @implements AutomodStore - async def require_bucket( - self, guild: Guild, name: str, bucket_type: Type[BT] - ) -> BT: - # IMPL - ... + async def increment_rule_hits(self, guild: Guild, name: str) -> Rule: + return self.guilds[guild.id].rules.increment_hits_by_name(name) diff --git a/commanderbot/ext/automod/automod_entity.py b/commanderbot/ext/automod/automod_entity.py deleted file mode 100644 index 173f7b4..0000000 --- a/commanderbot/ext/automod/automod_entity.py +++ /dev/null @@ -1,120 +0,0 @@ -from dataclasses import dataclass -from typing import ( - Any, - ClassVar, - Dict, - Iterable, - List, - 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_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index b9f41ec..8e7a455 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -24,8 +24,9 @@ from commanderbot.ext.automod import events from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_rule import AutomodRule from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.ext.automod.node.node_kind import NodeKind +from commanderbot.ext.automod.rule import Rule from commanderbot.lib import ( CogGuildState, GuildContext, @@ -36,7 +37,6 @@ TextReaction, ) from commanderbot.lib.dialogs import ConfirmationResult, confirm_with_reaction -from commanderbot.lib.json import to_data from commanderbot.lib.utils import ( JsonPath, JsonPathOp, @@ -59,9 +59,7 @@ class AutomodGuildState(CogGuildState): store: AutomodStore - async def _get_log_options_for_rule( - self, rule: AutomodRule - ) -> Optional[LogOptions]: + async def _get_log_options_for_rule(self, rule: Rule) -> Optional[LogOptions]: # First try to grab the rule's specific logging configuration, if any. if rule.log: return rule.log @@ -69,7 +67,7 @@ async def _get_log_options_for_rule( # also not exist, hence why it's optional. return await self.store.get_default_log_options(self.guild) - async def _handle_rule_error(self, rule: AutomodRule, error: Exception): + async def _handle_rule_error(self, rule: Rule, error: Exception): error_message = f"Rule `{rule.name}` caused an error:" # Re-raise the error so that it can be printed to the console. @@ -94,7 +92,7 @@ async def _handle_rule_error(self, rule: AutomodRule, error: Exception): # If something went wrong here, print another exception to the console. self.log.exception("Failed to log message to error channel") - async def _do_event_for_rule(self, event: AutomodEvent, rule: AutomodRule): + async def _do_event_for_rule(self, event: AutomodEvent, rule: Rule): try: if await rule.run(event): await self.store.increment_rule_hits(self.guild, rule.name) @@ -123,6 +121,13 @@ async def reply(self, ctx: GuildContext, content: str): allowed_mentions=AllowedMentions.none(), ) + 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 show_default_log_options(self, ctx: GuildContext): log_options = await self.store.get_default_log_options(self.guild) if log_options: @@ -209,73 +214,60 @@ async def clear_permitted_roles(self, ctx: GuildContext): else: await self.reply(ctx, f"No roles are permitted to manage automod") - async def show_rules(self, ctx: GuildContext, query: str = ""): + # @@ NODES + + async def list_nodes( + self, + ctx: GuildContext, + node_kind: NodeKind, + query: Optional[str] = None, + ): if query: - rules = await async_expand(self.store.query_rules(self.guild, query)) + nodes = await async_expand( + self.store.query_nodes(self.guild, node_kind.node_type, query) + ) else: - rules = await 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 = datetime.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() + nodes = await async_expand( + self.store.all_nodes(self.guild, node_kind.node_type) + ) + if nodes: + # Build each node's title. + titles = [node.build_title() for node in nodes] + + # Sort the node titles alphabetically. + sorted_titles = sorted(titles) + + # Print out a code block with the node titles. lines = [ "```", - name_line, - f" Hits: {rule.hits}", - f" Added on: {added_on_str}", - f" Modified on: {modified_on_str}", - " Triggers:", + *sorted_titles, + "```", ] - for i, trigger in enumerate(rule.triggers): - description = trigger.description or "(No description)" - lines.append(f" {i+1}. {description}") - lines.append(" Conditions:") - for i, condition in enumerate(rule.conditions): - description = condition.description or "(No description)" - lines.append(f" {i+1}. {description}") - lines.append(" Actions:") - for i, action in enumerate(rule.actions): - description = action.description or "(No description)" - lines.append(f" {i+1}. {description}") - lines.append("```") content = "\n".join(lines) await self.reply(ctx, content) - elif query: - await self.reply(ctx, f"No rules matching `{query}`") + else: - await self.reply(ctx, f"No rules available") + await self.reply(ctx, f"No automod {node_kind} found matching `{query}`") - async def print_rule( + async def print_node( self, ctx: GuildContext, + node_kind: NodeKind, query: str, path: Optional[JsonPath] = None, ): - rules = await async_expand(self.store.query_rules(self.guild, query)) - if rules: - # If multiple rules were found, just use the first. - rule = rules[0] + nodes = await async_expand( + self.store.query_nodes(self.guild, node_kind.node_type, query) + ) + if nodes: + # If multiple nodes were found, just use the first. + node = nodes[0] - # Turn the rule into raw data. - rule_data = to_data(rule) + # Turn the node into raw data. + node_data = node.to_data() # Take a sub-section of the data, if necessary. - output_data = rule_data + output_data = node_data if path: output_data = query_json_path(output_data, path) @@ -290,7 +282,7 @@ async def print_rule( # Otherwise, stuff it into a file and send it as an attachment. else: - filename = f"{rule.name}.yaml" + filename = f"{node.name}.yaml" fp = cast(Any, io.StringIO(output_yaml)) file = File(fp=fp, filename=filename) await ctx.message.reply( @@ -299,41 +291,91 @@ async def print_rule( ) else: - await self.reply(ctx, f"No rule found matching `{query}`") + await self.reply(ctx, f"No automod {node_kind} found matching `{query}`") - async def add_rule(self, ctx: GuildContext, body: str): + async def add_node(self, ctx: GuildContext, node_kind: NodeKind, 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}`") + node = await self.store.add_node(self.guild, node_kind.node_type, data) + await self.reply(ctx, f"Added automod {node_kind} `{node.name}`") + + async def remove_node(self, ctx: GuildContext, node_kind: NodeKind, name: str): + # Get the corresponding node. + node = await self.store.require_node(self.guild, node_kind.node_type, name) - async def remove_rule(self, ctx: GuildContext, name: str): - # Get the corresponding rule. - rule = await self.store.require_rule(self.guild, name) # Then ask for confirmation to actually remove it. conf = await confirm_with_reaction( self.bot, ctx, - f"Are you sure you want to remove automod rule `{rule.name}`?", + f"Are you sure you want to remove automod {node_kind} `{node.name}`?", ) - # If the answer was yes, attempt to remove the rule and send a response. + + # If the answer was yes, attempt to remove the node 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}`") + removed_node = await self.store.remove_node( + self.guild, node_kind.node_type, node.name + ) + await self.reply(ctx, f"Removed automod {node_kind} `{removed_node.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}`") + await self.reply(ctx, f"Did not remove automod {node_kind} `{node.name}`") - async def modify_rule( + async def modify_node( self, ctx: GuildContext, + node_kind: NodeKind, name: str, path: JsonPath, op: 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}`") + node = await self.store.modify_node( + self.guild, node_kind.node_type, name, path, op, data + ) + await self.reply(ctx, f"Modified automod {node_kind} `{node.name}`") + + # @@ RULES + + async def explain_rule(self, ctx: GuildContext, query: str): + rules = await async_expand(self.store.query_nodes(self.guild, Rule, query)) + if rules: + # If multiple nodes were found, just use the first. + rule = rules[0] + + now = datetime.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) + + else: + await self.reply(ctx, f"No automod rules matching `{query}`") async def enable_rule(self, ctx: GuildContext, name: str): rule = await self.store.enable_rule(self.guild, name) @@ -343,13 +385,6 @@ async def disable_rule(self, ctx: GuildContext, name: str): rule = await self.store.disable_rule(self.guild, name) await self.reply(ctx, f"Disabled automod rule `{rule.name}`") - 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 - # @@ EVENT HANDLERS async def dispatch_event(self, event: AutomodEvent): diff --git a/commanderbot/ext/automod/automod_json_store.py b/commanderbot/ext/automod/automod_json_store.py index 0fee595..10401f1 100644 --- a/commanderbot/ext/automod/automod_json_store.py +++ b/commanderbot/ext/automod/automod_json_store.py @@ -3,20 +3,16 @@ from discord import Guild -from commanderbot.ext.automod.automod_bucket import AutomodBucket 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 ( - CogStore, - JsonFileDatabaseAdapter, - JsonObject, - LogOptions, - RoleSet, -) +from commanderbot.ext.automod.bucket import Bucket +from commanderbot.ext.automod.node import Node +from commanderbot.ext.automod.rule import Rule +from commanderbot.lib import CogStore, JsonFileDatabaseAdapter, LogOptions, RoleSet from commanderbot.lib.utils import JsonPath, JsonPathOp -BT = TypeVar("BT", bound=AutomodBucket) +BT = TypeVar("BT", bound=Bucket) +NT = TypeVar("NT", bound=Node) # @implements AutomodStore @@ -28,6 +24,8 @@ class AutomodJsonStore(CogStore): db: JsonFileDatabaseAdapter[AutomodData] + # @@ OPTIONS + # @implements AutomodStore async def get_default_log_options(self, guild: Guild) -> Optional[LogOptions]: cache = await self.db.get_cache() @@ -56,95 +54,97 @@ async def set_permitted_roles( await self.db.dirty() return old_value + # @@ NODES + # @implements AutomodStore - async def all_rules(self, guild: Guild) -> AsyncIterable[AutomodRule]: + async def all_nodes(self, guild: Guild, node_type: Type[NT]) -> AsyncIterable[NT]: cache = await self.db.get_cache() - async for rule in cache.all_rules(guild): - yield rule + async for node in cache.all_nodes(guild, node_type): + yield node # @implements AutomodStore - async def rules_for_event( - self, guild: Guild, event: AutomodEvent - ) -> AsyncIterable[AutomodRule]: + async def query_nodes( + self, guild: Guild, node_type: Type[NT], query: str + ) -> AsyncIterable[NT]: cache = await self.db.get_cache() - async for rule in cache.rules_for_event(guild, event): - yield rule + async for node in cache.query_nodes(guild, node_type, query): + yield node # @implements AutomodStore - async def query_rules(self, guild: Guild, query: str) -> AsyncIterable[AutomodRule]: + async def get_node( + self, guild: Guild, node_type: Type[NT], name: str + ) -> Optional[NT]: cache = await self.db.get_cache() - async for rule in cache.query_rules(guild, query): - yield rule + return await cache.get_node(guild, node_type, name) # @implements AutomodStore - async def get_rule(self, guild: Guild, name: str) -> Optional[AutomodRule]: + async def require_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: cache = await self.db.get_cache() - return await cache.get_rule(guild, name) + return await cache.require_node(guild, node_type, name) # @implements AutomodStore - async def require_rule(self, guild: Guild, name: str) -> AutomodRule: + async def require_node_with_type( + self, guild: Guild, node_type: Type[NT], name: str + ) -> NT: cache = await self.db.get_cache() - return await cache.require_rule(guild, name) + return await cache.require_node_with_type(guild, node_type, name) # @implements AutomodStore - async def add_rule(self, guild: Guild, data: JsonObject) -> AutomodRule: + async def add_node(self, guild: Guild, node_type: Type[NT], data: Any) -> NT: cache = await self.db.get_cache() - added_rule = await cache.add_rule(guild, data) + added_node = await cache.add_node(guild, node_type, data) await self.db.dirty() - return added_rule + return added_node # @implements AutomodStore - async def remove_rule(self, guild: Guild, name: str) -> AutomodRule: + async def remove_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: cache = await self.db.get_cache() - removed_rule = await cache.remove_rule(guild, name) + removed_node = await cache.remove_node(guild, node_type, name) await self.db.dirty() - return removed_rule + return removed_node # @implements AutomodStore - async def modify_rule( + async def modify_node( self, guild: Guild, + node_type: Type[NT], name: str, path: JsonPath, op: JsonPathOp, data: Any, - ) -> AutomodRule: + ) -> NT: cache = await self.db.get_cache() - modified_rule = await cache.modify_rule(guild, name, path, op, data) + modified_node = await cache.modify_node(guild, node_type, name, path, op, data) await self.db.dirty() - return modified_rule + return modified_node + + # @@ RULES # @implements AutomodStore - async def enable_rule(self, guild: Guild, name: str) -> AutomodRule: + async def rules_for_event( + self, guild: Guild, event: AutomodEvent + ) -> AsyncIterable[Rule]: + cache = await self.db.get_cache() + async for rule in cache.rules_for_event(guild, event): + yield rule + + # @implements AutomodStore + async def enable_rule(self, guild: Guild, name: str) -> Rule: 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: + async def disable_rule(self, guild: Guild, name: str) -> Rule: 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: + async def increment_rule_hits(self, guild: Guild, name: str) -> Rule: cache = await self.db.get_cache() modified_rule = await cache.increment_rule_hits(guild, name) await self.db.dirty() return modified_rule - - # @implements AutomodStore - async def get_bucket( - self, guild: Guild, name: str, bucket_type: Type[BT] - ) -> Optional[BT]: - cache = await self.db.get_cache() - return await cache.get_bucket(guild, name, bucket_type) - - # @implements AutomodStore - async def require_bucket( - self, guild: Guild, name: str, bucket_type: Type[BT] - ) -> BT: - cache = await self.db.get_cache() - return await cache.require_bucket(guild, name, bucket_type) diff --git a/commanderbot/ext/automod/automod_store.py b/commanderbot/ext/automod/automod_store.py index e4c59b4..30bd43d 100644 --- a/commanderbot/ext/automod/automod_store.py +++ b/commanderbot/ext/automod/automod_store.py @@ -2,13 +2,13 @@ from discord import Guild -from commanderbot.ext.automod.automod_bucket import AutomodBucket from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_rule import AutomodRule -from commanderbot.lib import JsonObject, LogOptions, RoleSet +from commanderbot.ext.automod.node import Node +from commanderbot.ext.automod.rule import Rule +from commanderbot.lib import LogOptions, RoleSet from commanderbot.lib.utils import JsonPath, JsonPathOp -BT = TypeVar("BT", bound=AutomodBucket) +NT = TypeVar("NT", bound=Node) class AutomodStore(Protocol): @@ -16,6 +16,8 @@ class AutomodStore(Protocol): Abstracts the data storage and persistence of the automod cog. """ + # @@ OPTIONS + async def get_default_log_options(self, guild: Guild) -> Optional[LogOptions]: ... @@ -32,54 +34,56 @@ async def set_permitted_roles( ) -> Optional[RoleSet]: ... - def all_rules(self, guild: Guild) -> AsyncIterable[AutomodRule]: + # @@ NODES + + def all_nodes(self, guild: Guild, node_type: Type[NT]) -> AsyncIterable[NT]: ... - def rules_for_event( - self, guild: Guild, event: AutomodEvent - ) -> AsyncIterable[AutomodRule]: + def query_nodes( + self, guild: Guild, node_type: Type[NT], query: str + ) -> AsyncIterable[NT]: ... - def query_rules(self, guild: Guild, query: str) -> AsyncIterable[AutomodRule]: + async def get_node( + self, guild: Guild, node_type: Type[NT], name: str + ) -> Optional[NT]: ... - async def get_rule(self, guild: Guild, name: str) -> Optional[AutomodRule]: + async def require_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: ... - async def require_rule(self, guild: Guild, name: str) -> AutomodRule: + async def require_node_with_type( + self, guild: Guild, node_type: Type[NT], name: str + ) -> NT: ... - async def add_rule(self, guild: Guild, data: JsonObject) -> AutomodRule: + async def add_node(self, guild: Guild, node_type: Type[NT], data: Any) -> NT: ... - async def remove_rule(self, guild: Guild, name: str) -> AutomodRule: + async def remove_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: ... - async def modify_rule( + async def modify_node( self, guild: Guild, + node_type: Type[NT], name: str, path: JsonPath, op: JsonPathOp, data: Any, - ) -> AutomodRule: + ) -> NT: ... - async def enable_rule(self, guild: Guild, name: str) -> AutomodRule: - ... + # @@ RULES - async def disable_rule(self, guild: Guild, name: str) -> AutomodRule: + def rules_for_event(self, guild: Guild, event: AutomodEvent) -> AsyncIterable[Rule]: ... - async def increment_rule_hits(self, guild: Guild, name: str) -> AutomodRule: + async def enable_rule(self, guild: Guild, name: str) -> Rule: ... - async def get_bucket( - self, guild: Guild, name: str, bucket_type: Type[BT] - ) -> Optional[BT]: + async def disable_rule(self, guild: Guild, name: str) -> Rule: ... - async def require_bucket( - self, guild: Guild, name: str, bucket_type: Type[BT] - ) -> BT: + async def increment_rule_hits(self, guild: Guild, name: str) -> Rule: ... diff --git a/commanderbot/ext/automod/automod_trigger.py b/commanderbot/ext/automod/automod_trigger.py deleted file mode 100644 index 35d4776..0000000 --- a/commanderbot/ext/automod/automod_trigger.py +++ /dev/null @@ -1,83 +0,0 @@ -from dataclasses import dataclass -from typing import ( - Any, - ClassVar, - Iterable, - List, - Optional, - Protocol, - Tuple, - 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.types import JsonObject - -ST = TypeVar("ST") - - -class AutomodTrigger(AutomodEntity, Protocol): - event_types: ClassVar[Tuple[Type[AutomodEvent], ...]] - - description: Optional[str] - - async 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"), - ) - - async 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 await self.ignore(event): - return False - return True - - async 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 0000000..678abed --- /dev/null +++ b/commanderbot/ext/automod/bucket/__init__.py @@ -0,0 +1,4 @@ +from .bucket import * +from .bucket_base import * +from .bucket_collection import * +from .bucket_ref import * diff --git a/commanderbot/ext/automod/bucket/bucket.py b/commanderbot/ext/automod/bucket/bucket.py new file mode 100644 index 0000000..7117a93 --- /dev/null +++ b/commanderbot/ext/automod/bucket/bucket.py @@ -0,0 +1,13 @@ +from typing import Protocol + +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.component import Component + +__all__ = ("Bucket",) + + +class Bucket(Component, Protocol): + """A bucket can be used to carry state through multiple events.""" + + async def add(self, event: AutomodEvent): + """Add the event to the bucket.""" diff --git a/commanderbot/ext/automod/bucket/bucket_base.py b/commanderbot/ext/automod/bucket/bucket_base.py new file mode 100644 index 0000000..0bf9c09 --- /dev/null +++ b/commanderbot/ext/automod/bucket/bucket_base.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import ClassVar + +from commanderbot.ext.automod import buckets +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.component import ComponentBase + +__all__ = ("BucketBase",) + + +# @implements Bucket +@dataclass +class BucketBase(ComponentBase): + # @implements ComponentBase + default_module_prefix: ClassVar[str] = buckets.__name__ + + # @implements ComponentBase + module_function_name: ClassVar[str] = "create_bucket" + + async def add(self, event: AutomodEvent): + """Override this to modify the bucket according to the event.""" diff --git a/commanderbot/ext/automod/bucket/bucket_collection.py b/commanderbot/ext/automod/bucket/bucket_collection.py new file mode 100644 index 0000000..9770819 --- /dev/null +++ b/commanderbot/ext/automod/bucket/bucket_collection.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import ClassVar, Type + +from commanderbot.ext.automod.bucket.bucket import Bucket +from commanderbot.ext.automod.bucket.bucket_base import BucketBase +from commanderbot.ext.automod.component import ComponentCollection + +__all__ = ("BucketCollection",) + + +@dataclass(init=False) +class BucketCollection(ComponentCollection[Bucket]): + """A collection of buckets.""" + + # @implements NodeCollection + node_type: ClassVar[Type[Bucket]] = BucketBase diff --git a/commanderbot/ext/automod/bucket/bucket_ref.py b/commanderbot/ext/automod/bucket/bucket_ref.py new file mode 100644 index 0000000..f505d22 --- /dev/null +++ b/commanderbot/ext/automod/bucket/bucket_ref.py @@ -0,0 +1,13 @@ +from typing import TypeVar + +from commanderbot.ext.automod.bucket.bucket import Bucket +from commanderbot.ext.automod.node import NodeRef + +__all__ = ("BucketRef",) + + +NT = TypeVar("NT", bound=Bucket) + + +class BucketRef(NodeRef[NT]): + """A reference to a bucket, by name.""" diff --git a/commanderbot/ext/automod/buckets/message_frequency.py b/commanderbot/ext/automod/buckets/message_frequency.py index ac10d70..68ca8fa 100644 --- a/commanderbot/ext/automod/buckets/message_frequency.py +++ b/commanderbot/ext/automod/buckets/message_frequency.py @@ -8,8 +8,8 @@ from discord import Member, Message, User from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_bucket import AutomodBucket, AutomodBucketBase from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.bucket import Bucket, BucketBase from commanderbot.lib import ChannelID, JsonObject, UserID from commanderbot.lib.utils import timedelta_from_field_optional @@ -56,7 +56,7 @@ class MessageFrequencyState: @dataclass -class MessageFrequency(AutomodBucketBase): +class MessageFrequency(BucketBase): """ Track user activity across channels for potential spam. @@ -150,5 +150,5 @@ async def add(self, event: AutomodEvent): ) -def create_bucket(data: JsonObject) -> AutomodBucket: +def create_bucket(data: JsonObject) -> Bucket: return MessageFrequency.from_data(data) diff --git a/commanderbot/ext/automod/component/__init__.py b/commanderbot/ext/automod/component/__init__.py new file mode 100644 index 0000000..92d7c75 --- /dev/null +++ b/commanderbot/ext/automod/component/__init__.py @@ -0,0 +1,3 @@ +from .component import * +from .component_base import * +from .component_collection import * diff --git a/commanderbot/ext/automod/component/component.py b/commanderbot/ext/automod/component/component.py new file mode 100644 index 0000000..7aefc08 --- /dev/null +++ b/commanderbot/ext/automod/component/component.py @@ -0,0 +1,23 @@ +from typing import ClassVar, Protocol + +from commanderbot.ext.automod.node.node import Node + +__all__ = ("Component",) + + +class Component(Node, Protocol): + """ + Base interface for automod components. + + Components are nodes that inherit functionality based on their type. For example: + buckets, triggers, conditions, and actions are all components and all require a + pre-determined `type` to function. + + A componenet's `type` generally corresponds to a module that is used in the + de/serialization process to construct the underlying object dynamically at runtime. + + Note that while rules are nodes, they are not components. + """ + + default_module_prefix: ClassVar[str] + module_function_name: ClassVar[str] diff --git a/commanderbot/ext/automod/component/component_base.py b/commanderbot/ext/automod/component/component_base.py new file mode 100644 index 0000000..f993ba3 --- /dev/null +++ b/commanderbot/ext/automod/component/component_base.py @@ -0,0 +1,52 @@ +from abc import abstractmethod +from dataclasses import dataclass +from typing import Any, Dict, Optional, TypeVar + +from commanderbot.ext.automod.node.node_base import NodeBase + +__all__ = ("ComponentBase",) + + +ST = TypeVar("ST", bound="ComponentBase") + + +# @implements Component +@dataclass +class ComponentBase(NodeBase): + """ + Base implementation for automod components. + + See `Component` for a description of what components are. + + 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. + """ + + @classmethod + @property + @abstractmethod + def default_module_prefix(cls) -> str: + ... + + @classmethod + @property + @abstractmethod + def module_function_name(cls) -> str: + ... + + @classmethod + def get_type_string(cls) -> str: + """Override this if the external type field requires special handling.""" + 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 + + # @overrides ToData + def base_fields_to_data(self) -> Optional[Dict[str, Any]]: + # Include `type` as a base field at serialization-time only. + type_str = self.get_type_string() + base_fields = dict(type=type_str) + return base_fields diff --git a/commanderbot/ext/automod/component/component_collection.py b/commanderbot/ext/automod/component/component_collection.py new file mode 100644 index 0000000..e84fb7b --- /dev/null +++ b/commanderbot/ext/automod/component/component_collection.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from typing import Any, Generic, Type, TypeVar + +from commanderbot.ext.automod.component.component import Component +from commanderbot.ext.automod.node import NodeCollection +from commanderbot.ext.automod.utils import deserialize_module_object + +ST = TypeVar("ST", bound="ComponentCollection") +CT = TypeVar("CT", bound=Component) + + +# @abstract +@dataclass(init=False) +class ComponentCollection(NodeCollection[CT], Generic[CT]): + """A collection of components with the same base type.""" + + # @overrides NodeCollection + @classmethod + def build_node_from_data(cls: Type[ST], data: Any) -> CT: + # Instead of just constructing objects out of the base type, actually look + # at the `type` field and build them dynamically from modules. + component_type: Type[CT] = cls.node_type + component = deserialize_module_object( + data=data, + default_module_prefix=component_type.default_module_prefix, + function_name=component_type.module_function_name, + ) + assert isinstance(component, component_type) + return component diff --git a/commanderbot/ext/automod/condition/__init__.py b/commanderbot/ext/automod/condition/__init__.py new file mode 100644 index 0000000..ab7642f --- /dev/null +++ b/commanderbot/ext/automod/condition/__init__.py @@ -0,0 +1,3 @@ +from .condition import * +from .condition_base import * +from .condition_collection import * diff --git a/commanderbot/ext/automod/condition/condition.py b/commanderbot/ext/automod/condition/condition.py new file mode 100644 index 0000000..acb70a9 --- /dev/null +++ b/commanderbot/ext/automod/condition/condition.py @@ -0,0 +1,13 @@ +from typing import Protocol + +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.component import Component + +__all__ = ("Condition",) + + +class Condition(Component, Protocol): + """A condition is a predicate that must pass in order to run actions.""" + + async def check(self, event: AutomodEvent) -> bool: + """Check whether the condition passes.""" diff --git a/commanderbot/ext/automod/condition/condition_base.py b/commanderbot/ext/automod/condition/condition_base.py new file mode 100644 index 0000000..6eda7d5 --- /dev/null +++ b/commanderbot/ext/automod/condition/condition_base.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from typing import ClassVar + +from commanderbot.ext.automod import conditions +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.component import ComponentBase + +__all__ = ("ConditionBase",) + + +# @implements Condition +@dataclass +class ConditionBase(ComponentBase): + # @implements ComponentBase + default_module_prefix: ClassVar[str] = conditions.__name__ + + # @implements ComponentBase + module_function_name: ClassVar[str] = "create_condition" + + async def check(self, event: AutomodEvent) -> bool: + """Override this to check whether the condition passes.""" + return False diff --git a/commanderbot/ext/automod/condition/condition_collection.py b/commanderbot/ext/automod/condition/condition_collection.py new file mode 100644 index 0000000..7290e5c --- /dev/null +++ b/commanderbot/ext/automod/condition/condition_collection.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import ClassVar, Type + +from commanderbot.ext.automod.component import ComponentCollection +from commanderbot.ext.automod.condition.condition import Condition +from commanderbot.ext.automod.condition.condition_base import ConditionBase + +__all__ = ("ConditionCollection",) + + +@dataclass(init=False) +class ConditionCollection(ComponentCollection[Condition]): + """A collection of conditions.""" + + # @implements NodeCollection + node_type: ClassVar[Type[Condition]] = ConditionBase diff --git a/commanderbot/ext/automod/condition/condition_ref.py b/commanderbot/ext/automod/condition/condition_ref.py new file mode 100644 index 0000000..39841e1 --- /dev/null +++ b/commanderbot/ext/automod/condition/condition_ref.py @@ -0,0 +1,13 @@ +from typing import TypeVar + +from commanderbot.ext.automod.condition.condition import Condition +from commanderbot.ext.automod.node import NodeRef + +__all__ = ("ConditionRef",) + + +NT = TypeVar("NT", bound=Condition) + + +class ConditionRef(NodeRef[NT]): + """A reference to a condition, by name.""" diff --git a/commanderbot/ext/automod/conditions/abc/target_account_age_base.py b/commanderbot/ext/automod/conditions/abc/target_account_age_base.py index e5eb2f4..7c2765e 100644 --- a/commanderbot/ext/automod/conditions/abc/target_account_age_base.py +++ b/commanderbot/ext/automod/conditions/abc/target_account_age_base.py @@ -1,28 +1,25 @@ from dataclasses import dataclass from datetime import timedelta -from typing import Optional, Type, TypeVar +from typing import Any, Dict, Optional 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.ext.automod.condition import ConditionBase from commanderbot.lib.utils import timedelta_from_field_optional, utcnow_aware -ST = TypeVar("ST") - @dataclass -class TargetAccountAgeBase(AutomodConditionBase): +class TargetAccountAgeBase(ConditionBase): more_than: Optional[timedelta] = None less_than: Optional[timedelta] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: more_than = timedelta_from_field_optional(data, "more_than") less_than = timedelta_from_field_optional(data, "less_than") - return cls( - description=data.get("description"), + return dict( more_than=more_than, less_than=less_than, ) 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 index 05990ec..ece3c4b 100644 --- a/commanderbot/ext/automod/conditions/abc/target_is_not_bot_base.py +++ b/commanderbot/ext/automod/conditions/abc/target_is_not_bot_base.py @@ -3,14 +3,14 @@ from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodConditionBase from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import ConditionBase ST = TypeVar("ST") @dataclass -class TargetIsNotBotBase(AutomodConditionBase): +class TargetIsNotBotBase(ConditionBase): def get_target(self, event: AutomodEvent) -> Optional[Member]: raise NotImplementedError() 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 index bd220eb..66fde3b 100644 --- a/commanderbot/ext/automod/conditions/abc/target_is_not_self_base.py +++ b/commanderbot/ext/automod/conditions/abc/target_is_not_self_base.py @@ -3,14 +3,14 @@ from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodConditionBase from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import ConditionBase ST = TypeVar("ST") @dataclass -class TargetIsNotSelfBase(AutomodConditionBase): +class TargetIsNotSelfBase(ConditionBase): def get_target(self, event: AutomodEvent) -> Optional[Member]: raise NotImplementedError() diff --git a/commanderbot/ext/automod/conditions/abc/target_roles_base.py b/commanderbot/ext/automod/conditions/abc/target_roles_base.py index 89b1c3e..7f242e5 100644 --- a/commanderbot/ext/automod/conditions/abc/target_roles_base.py +++ b/commanderbot/ext/automod/conditions/abc/target_roles_base.py @@ -1,25 +1,22 @@ from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Any, Dict, Optional 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.ext.automod.condition import ConditionBase from commanderbot.lib.guards.roles_guard import RolesGuard -ST = TypeVar("ST") - @dataclass -class TargetRolesBase(AutomodConditionBase): +class TargetRolesBase(ConditionBase): roles: RolesGuard + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: roles = RolesGuard.from_field(data, "roles") - return cls( - description=data.get("description"), + return dict( roles=roles, ) diff --git a/commanderbot/ext/automod/conditions/actor_account_age.py b/commanderbot/ext/automod/conditions/actor_account_age.py index c2a065a..d504e93 100644 --- a/commanderbot/ext/automod/conditions/actor_account_age.py +++ b/commanderbot/ext/automod/conditions/actor_account_age.py @@ -3,8 +3,8 @@ from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_account_age_base import ( TargetAccountAgeBase, ) @@ -30,5 +30,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.actor -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return ActorAccountAge.from_data(data) diff --git a/commanderbot/ext/automod/conditions/actor_is_not_bot.py b/commanderbot/ext/automod/conditions/actor_is_not_bot.py index 2aac012..8f806b4 100644 --- a/commanderbot/ext/automod/conditions/actor_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/actor_is_not_bot.py @@ -3,7 +3,7 @@ from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.conditions.abc.target_is_not_bot_base import ( TargetIsNotBotBase, @@ -23,5 +23,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.actor -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return ActorIsNotBot.from_data(data) diff --git a/commanderbot/ext/automod/conditions/actor_is_not_self.py b/commanderbot/ext/automod/conditions/actor_is_not_self.py index 116bc64..685e022 100644 --- a/commanderbot/ext/automod/conditions/actor_is_not_self.py +++ b/commanderbot/ext/automod/conditions/actor_is_not_self.py @@ -3,7 +3,7 @@ from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.conditions.abc.target_is_not_self_base import ( TargetIsNotSelfBase, @@ -23,5 +23,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.actor -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return ActorIsNotSelf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/actor_roles.py b/commanderbot/ext/automod/conditions/actor_roles.py index b501e64..b5059e1 100644 --- a/commanderbot/ext/automod/conditions/actor_roles.py +++ b/commanderbot/ext/automod/conditions/actor_roles.py @@ -3,7 +3,7 @@ from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.conditions.abc.target_roles_base import ( TargetRolesBase, @@ -28,5 +28,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.actor -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return ActorRoles.from_data(data) diff --git a/commanderbot/ext/automod/conditions/all_of.py b/commanderbot/ext/automod/conditions/all_of.py index 16c7e99..943ecdb 100644 --- a/commanderbot/ext/automod/conditions/all_of.py +++ b/commanderbot/ext/automod/conditions/all_of.py @@ -1,19 +1,17 @@ from dataclasses import dataclass -from typing import Tuple, Type, TypeVar +from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, - deserialize_conditions, -) from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import ( + Condition, + ConditionBase, + ConditionCollection, +) from commanderbot.lib import JsonObject -ST = TypeVar("ST") - @dataclass -class AllOf(AutomodConditionBase): +class AllOf(ConditionBase): """ Check if all sub-conditions pass (logical AND). @@ -26,14 +24,13 @@ class AllOf(AutomodConditionBase): The sub-conditions to check. """ - conditions: Tuple[AutomodCondition] + conditions: ConditionCollection + # @overrides NodeBase @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"), + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + conditions = ConditionCollection.from_data(data["conditions"]) + return dict( conditions=conditions, ) @@ -44,5 +41,5 @@ async def check(self, event: AutomodEvent) -> bool: return True -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return AllOf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/any_of.py b/commanderbot/ext/automod/conditions/any_of.py index f0bb4b6..02bec67 100644 --- a/commanderbot/ext/automod/conditions/any_of.py +++ b/commanderbot/ext/automod/conditions/any_of.py @@ -1,19 +1,17 @@ from dataclasses import dataclass -from typing import Optional, Tuple, Type, TypeVar +from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, - deserialize_conditions, -) from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import ( + Condition, + ConditionBase, + ConditionCollection, +) from commanderbot.lib import JsonObject -ST = TypeVar("ST") - @dataclass -class AnyOf(AutomodConditionBase): +class AnyOf(ConditionBase): """ Check if a number of sub-conditions pass (logical OR). @@ -26,17 +24,15 @@ class AnyOf(AutomodConditionBase): sub-condition is required to pass. """ - conditions: Tuple[AutomodCondition] + conditions: ConditionCollection count: Optional[int] = None + # @overrides NodeBase @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"), + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + conditions = ConditionCollection.from_data(data["conditions"]) + return dict( conditions=conditions, - count=data.get("count"), ) async def check(self, event: AutomodEvent) -> bool: @@ -49,5 +45,5 @@ async def check(self, event: AutomodEvent) -> bool: return False -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return AnyOf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/author_account_age.py b/commanderbot/ext/automod/conditions/author_account_age.py index 9a6012a..5b00b70 100644 --- a/commanderbot/ext/automod/conditions/author_account_age.py +++ b/commanderbot/ext/automod/conditions/author_account_age.py @@ -3,7 +3,7 @@ from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.conditions.abc.target_account_age_base import ( TargetAccountAgeBase, @@ -30,5 +30,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.author -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return AuthorAccountAge.from_data(data) diff --git a/commanderbot/ext/automod/conditions/author_is_not_bot.py b/commanderbot/ext/automod/conditions/author_is_not_bot.py index b7eea68..eefc353 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/author_is_not_bot.py @@ -3,7 +3,7 @@ from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.conditions.abc.target_is_not_bot_base import ( TargetIsNotBotBase, @@ -23,5 +23,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.author -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return AuthorIsNotBot.from_data(data) diff --git a/commanderbot/ext/automod/conditions/author_is_not_self.py b/commanderbot/ext/automod/conditions/author_is_not_self.py index 52a177c..63bde0b 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_self.py +++ b/commanderbot/ext/automod/conditions/author_is_not_self.py @@ -3,7 +3,7 @@ from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.conditions.abc.target_is_not_self_base import ( TargetIsNotSelfBase, @@ -23,5 +23,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.author -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return AuthorIsNotSelf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/author_roles.py b/commanderbot/ext/automod/conditions/author_roles.py index b3f4368..2d3bd2f 100644 --- a/commanderbot/ext/automod/conditions/author_roles.py +++ b/commanderbot/ext/automod/conditions/author_roles.py @@ -3,7 +3,7 @@ from discord import Member -from commanderbot.ext.automod.automod_condition import AutomodCondition +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.conditions.abc.target_roles_base import ( TargetRolesBase, @@ -28,5 +28,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.author -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return AuthorRoles.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_content_contains.py b/commanderbot/ext/automod/conditions/message_content_contains.py index 84d64d6..a1f429d 100644 --- a/commanderbot/ext/automod/conditions/message_content_contains.py +++ b/commanderbot/ext/automod/conditions/message_content_contains.py @@ -1,22 +1,16 @@ import unicodedata from dataclasses import dataclass -from typing import Optional, Tuple, Type, TypeVar +from typing import Any, Dict, Optional, Tuple -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition, ConditionBase from commanderbot.lib import JsonObject -ST = TypeVar("ST") - - DEFAULT_NORMALIZATION_FORM = "NFKD" @dataclass -class MessageContentContains(AutomodConditionBase): +class MessageContentContains(ConditionBase): """ Check if message content contains a number of substrings. @@ -43,23 +37,20 @@ class MessageContentContains(AutomodConditionBase): use_normalization: Optional[bool] = None normalization_form: Optional[str] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: 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: + + if data.get("ignore_case"): contains = [item.lower() for item in contains] - return cls( - description=data.get("description"), + + return dict( 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: @@ -91,5 +82,5 @@ async def check(self, event: AutomodEvent) -> bool: return False -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return MessageContentContains.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_content_matches.py b/commanderbot/ext/automod/conditions/message_content_matches.py index 0b251d6..e8a986f 100644 --- a/commanderbot/ext/automod/conditions/message_content_matches.py +++ b/commanderbot/ext/automod/conditions/message_content_matches.py @@ -1,22 +1,16 @@ import unicodedata from dataclasses import dataclass -from typing import Optional, Tuple, Type, TypeVar +from typing import Any, Dict, Optional, Tuple -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition, ConditionBase from commanderbot.lib import JsonObject, PatternWrapper -ST = TypeVar("ST") - - DEFAULT_NORMALIZATION_FORM = "NFKD" @dataclass -class MessageContentMatches(AutomodConditionBase): +class MessageContentMatches(ConditionBase): """ Check if message content matches a number of regular expressions. @@ -43,20 +37,18 @@ class MessageContentMatches(AutomodConditionBase): use_normalization: Optional[bool] = None normalization_form: Optional[str] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: raw_matches = data["matches"] if isinstance(raw_matches, (str, dict)): matches = [PatternWrapper.from_data(raw_matches)] else: + assert isinstance(raw_matches, list) matches = [PatternWrapper.from_data(item) for item in raw_matches] - return cls( - description=data.get("description"), + + return dict( 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: @@ -89,5 +81,5 @@ async def check(self, event: AutomodEvent) -> bool: return False -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return MessageContentMatches.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_has_attachments.py b/commanderbot/ext/automod/conditions/message_has_attachments.py index 6216c84..4afa89e 100644 --- a/commanderbot/ext/automod/conditions/message_has_attachments.py +++ b/commanderbot/ext/automod/conditions/message_has_attachments.py @@ -1,19 +1,13 @@ from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Any, Dict, Optional -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.integer_range import IntegerRange - -ST = TypeVar("ST") +from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.lib import IntegerRange, JsonObject @dataclass -class MessageHasAttachments(AutomodConditionBase): +class MessageHasAttachments(ConditionBase): """ Check if the message has attachments. @@ -25,11 +19,11 @@ class MessageHasAttachments(AutomodConditionBase): count: Optional[IntegerRange] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: count = IntegerRange.from_field_optional(data, "count") - return cls( - description=data.get("description"), + return dict( count=count, ) @@ -43,5 +37,5 @@ async def check(self, event: AutomodEvent) -> bool: return count_attachments > 0 -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return MessageHasAttachments.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_has_embeds.py b/commanderbot/ext/automod/conditions/message_has_embeds.py index 3d6d572..b300cec 100644 --- a/commanderbot/ext/automod/conditions/message_has_embeds.py +++ b/commanderbot/ext/automod/conditions/message_has_embeds.py @@ -1,19 +1,13 @@ from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Any, Dict, Optional -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.integer_range import IntegerRange - -ST = TypeVar("ST") +from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.lib import IntegerRange, JsonObject @dataclass -class MessageHasEmbeds(AutomodConditionBase): +class MessageHasEmbeds(ConditionBase): """ Check if the message has embeds. @@ -25,11 +19,11 @@ class MessageHasEmbeds(AutomodConditionBase): count: Optional[IntegerRange] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: count = IntegerRange.from_field_optional(data, "count") - return cls( - description=data.get("description"), + return dict( count=count, ) @@ -43,5 +37,5 @@ async def check(self, event: AutomodEvent) -> bool: return count_embeds > 0 -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return MessageHasEmbeds.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_has_links.py b/commanderbot/ext/automod/conditions/message_has_links.py index b5f463e..97f1c30 100644 --- a/commanderbot/ext/automod/conditions/message_has_links.py +++ b/commanderbot/ext/automod/conditions/message_has_links.py @@ -1,19 +1,13 @@ from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Any, Dict, Optional -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.integer_range import IntegerRange - -ST = TypeVar("ST") +from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.lib import IntegerRange, JsonObject @dataclass -class MessageHasLinks(AutomodConditionBase): +class MessageHasLinks(ConditionBase): """ Check if the message has links. @@ -28,11 +22,11 @@ class MessageHasLinks(AutomodConditionBase): count: Optional[IntegerRange] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: count = IntegerRange.from_field_optional(data, "count") - return cls( - description=data.get("description"), + return dict( count=count, ) @@ -49,5 +43,5 @@ async def check(self, event: AutomodEvent) -> bool: return count_links > 0 -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return MessageHasLinks.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_mentions_roles.py b/commanderbot/ext/automod/conditions/message_mentions_roles.py index 1e795fb..6532b45 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_roles.py +++ b/commanderbot/ext/automod/conditions/message_mentions_roles.py @@ -1,18 +1,13 @@ from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition, ConditionBase from commanderbot.lib import JsonObject, RolesGuard -ST = TypeVar("ST") - @dataclass -class MessageMentionsRoles(AutomodConditionBase): +class MessageMentionsRoles(ConditionBase): """ Check if the message contains role mentions. @@ -24,11 +19,11 @@ class MessageMentionsRoles(AutomodConditionBase): roles: Optional[RolesGuard] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: roles = RolesGuard.from_data(data.get("roles", {})) - return cls( - description=data.get("description"), + return dict( roles=roles, ) @@ -58,5 +53,5 @@ async def check(self, event: AutomodEvent) -> bool: return True -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return MessageMentionsRoles.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_mentions_users.py b/commanderbot/ext/automod/conditions/message_mentions_users.py index f168ece..7a9e58d 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_users.py +++ b/commanderbot/ext/automod/conditions/message_mentions_users.py @@ -1,15 +1,12 @@ from dataclasses import dataclass -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition, ConditionBase from commanderbot.lib import JsonObject @dataclass -class MessageMentionsUsers(AutomodConditionBase): +class MessageMentionsUsers(ConditionBase): """Check if the message contains user mentions.""" async def check(self, event: AutomodEvent) -> bool: @@ -31,5 +28,5 @@ async def check(self, event: AutomodEvent) -> bool: return True -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return MessageMentionsUsers.from_data(data) diff --git a/commanderbot/ext/automod/conditions/throw_error.py b/commanderbot/ext/automod/conditions/throw_error.py index 2d52a3c..f52d34e 100644 --- a/commanderbot/ext/automod/conditions/throw_error.py +++ b/commanderbot/ext/automod/conditions/throw_error.py @@ -1,15 +1,12 @@ from dataclasses import dataclass -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, -) from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition, ConditionBase from commanderbot.lib import JsonObject @dataclass -class ThrowError(AutomodConditionBase): +class ThrowError(ConditionBase): """ Throw an error when checking the condition. @@ -27,5 +24,5 @@ async def check(self, event: AutomodEvent) -> bool: raise Exception(self.error) -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return ThrowError.from_data(data) diff --git a/commanderbot/ext/automod/node/__init__.py b/commanderbot/ext/automod/node/__init__.py new file mode 100644 index 0000000..b7e10fe --- /dev/null +++ b/commanderbot/ext/automod/node/__init__.py @@ -0,0 +1,4 @@ +from .node import * +from .node_base import * +from .node_collection import * +from .node_ref import * diff --git a/commanderbot/ext/automod/node/node.py b/commanderbot/ext/automod/node/node.py new file mode 100644 index 0000000..e4bdd4d --- /dev/null +++ b/commanderbot/ext/automod/node/node.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Protocol, Type, TypeVar + +__all__ = ("Node",) + + +ST = TypeVar("ST") + + +class Node(Protocol): + """ + Base interface for automod nodes. + + Nodes are generally anything that can be saved to data, loaded from data, and + referenced by name. + """ + + name: str + description: Optional[str] + + @classmethod + def from_data(cls: Type[ST], data: Any) -> ST: + """Create a node from raw data.""" + + def to_data(self) -> Any: + """Turn the node into raw data.""" + + def build_title(self) -> str: + """Return a human-readable title for the node.""" diff --git a/commanderbot/ext/automod/node/node_base.py b/commanderbot/ext/automod/node/node_base.py new file mode 100644 index 0000000..bffe9c0 --- /dev/null +++ b/commanderbot/ext/automod/node/node_base.py @@ -0,0 +1,89 @@ +import uuid +from dataclasses import dataclass +from typing import Any, Dict, Optional, Type, TypeVar + +from commanderbot.lib import FromData, ToData + +__all__ = ("NodeBase",) + + +ST = TypeVar("ST", bound="NodeBase") + + +# @implements Node +@dataclass +class NodeBase(FromData, ToData): + """ + Base implementation for automod nodes. + + See `Node` for a description of what nodes are. + + There isn't much here, other than base logic for the de/serialization methods. + + Attributes + ---------- + name + The name of the node. Can be any arbitrary string, but snake_case tends to be + easier to type into chat. + description + A human-readable description of the node. + """ + + # @implements Node + name: str + description: Optional[str] + + # @overrides FromData + @classmethod + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: + if isinstance(data, dict): + processed_data = cls.build_processed_data(data) + obj = cls(**processed_data) + return obj + + @classmethod + def new_rule_name(cls) -> str: + """Create a new, unique rule name.""" + return str(uuid.uuid4()) + + @classmethod + def build_base_data(cls, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Auto-fill required dataclass fields that aren't necessarily required in data. + + Currently this includes the `name` and `description` fields, which are required + dataclass fields not necessarily required in data. + """ + name = data.get("name") + if not name: + name = cls.new_rule_name() + base_data: Dict[str, Any] = { + "name": name, + "description": None, + } + base_data.update(data) + return base_data + + @classmethod + def build_processed_data(cls, data: Dict[str, Any]) -> Dict[str, Any]: + """Auto-fill required dataclass fields and include extracted fields.""" + # Start with the base data. + processed_data = cls.build_base_data(data) + + # If complex fields are present, include them. + if complex_fields := cls.build_complex_fields(data): + processed_data.update(complex_fields) + + # Return the final, processed data. + return processed_data + + @classmethod + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return nested objects that must themselves be constructed from raw data.""" + + # @implements Node + def build_title(self) -> str: + parts = [f"{self.name}:"] + if self.description: + parts.append(self.description) + return " ".join(parts) diff --git a/commanderbot/ext/automod/node/node_collection.py b/commanderbot/ext/automod/node/node_collection.py new file mode 100644 index 0000000..a225c05 --- /dev/null +++ b/commanderbot/ext/automod/node/node_collection.py @@ -0,0 +1,164 @@ +from abc import abstractmethod +from dataclasses import dataclass +from typing import Any, Dict, Generic, Iterable, Iterator, List, Optional, Type, TypeVar + +from commanderbot.ext.automod.node.node import Node +from commanderbot.lib import FromData, ResponsiveException, ToData +from commanderbot.lib.utils import JsonPath, JsonPathOp, update_json_with_path + +__all__ = ( + "NodeWithNameAlreadyExists", + "NoNodeWithName", + "NodeNotRegistered", + "UnexpectedNodeType", + "NodeCollection", +) + + +ST = TypeVar("ST", bound="NodeCollection") +NT = TypeVar("NT", bound=Node) +SNT = TypeVar("SNT", bound=Node) + + +class NodeWithNameAlreadyExists(ResponsiveException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"A node with the name `{name}` already exists") + + +class NoNodeWithName(ResponsiveException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"There is no node with the name `{name}`") + + +class NodeNotRegistered(ResponsiveException): + def __init__(self, node: Node): + self.node: Node = node + super().__init__(f"Node `{node.name}` is not registered") + + +class UnexpectedNodeType(ResponsiveException): + def __init__(self, node: Node): + self.node: Node = node + super().__init__(f"Unexpected node type `{type(node).__name__}`") + + +# @abstract +@dataclass(init=False) +class NodeCollection(FromData, ToData, Generic[NT]): + """A collection of nodes with the same base type.""" + + _nodes: List[NT] + _nodes_by_name: Dict[str, NT] + + @classmethod + @property + @abstractmethod + def node_type(cls) -> Type[NT]: + ... + + @classmethod + def build_node_from_data(cls: Type[ST], data: Any) -> NT: + return cls.node_type.from_data(data) + + # @overrides FromData + @classmethod + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: + if isinstance(data, list): + nodes = [cls.build_node_from_data(node_data) for node_data in data] + return cls(nodes) + + def __init__(self, nodes: Optional[Iterable[NT]] = None): + self._nodes = [] + self._nodes_by_name = {} + if nodes: + for node in nodes: + self.add(node) + + def __iter__(self) -> Iterator[NT]: + return iter(self._nodes) + + # @overrides ToData + def to_data(self) -> Any: + return list(node.to_data() for node in self._nodes) + + def query(self, query: str) -> Iterable[NT]: + # Yield any nodes whose name contains the case-insensitive query. + query_lower = query.lower() + for node in self: + if query_lower in node.name.lower(): + yield node + + def get(self, name: str) -> Optional[NT]: + return self._nodes_by_name.get(name) + + def require(self, name: str) -> NT: + if node := self.get(name): + return node + raise NoNodeWithName(name) + + def require_with_type(self, name: str, node_type: Type[SNT]) -> SNT: + node = self.require(name) + if not isinstance(node, node_type): + raise UnexpectedNodeType(node) + return node + + def _add_to_cache(self, node: NT): + self._nodes_by_name[node.name] = node + + def add(self, node: NT): + if node.name in self._nodes_by_name: + raise NodeWithNameAlreadyExists(node.name) + self._nodes.append(node) + self._add_to_cache(node) + + def add_from_data(self, data: Any) -> NT: + node = self.build_node_from_data(data) + self.add(node) + return node + + def _remove_from_cache(self, node: NT): + del self._nodes_by_name[node.name] + + def remove(self, node: NT): + existing_node = self.require(node.name) + if not (existing_node and (existing_node is node)): + raise NodeNotRegistered(node) + self._nodes.remove(node) + self._remove_from_cache(node) + + def remove_by_name(self, name: str) -> NT: + node = self.require(name) + self.remove(node) + return node + + def get_index(self, node: NT) -> int: + for i, n in enumerate(self._nodes): + if n is node: + return i + raise NodeNotRegistered(node) + + def replace(self, old_node: NT, new_node: NT): + # Determine the index of the old node and replace it with the new one. + index = self.get_index(old_node) + self._nodes[index] = new_node + self._remove_from_cache(old_node) + self._add_to_cache(new_node) + + def modify(self, name: str, path: JsonPath, op: JsonPathOp, data: Any) -> NT: + # Start with the serialized form of the original node. + old_node = self.require(name) + new_data = old_node.to_data() + + # Update the new node data using the given changes. + new_data = update_json_with_path(new_data, path, op, data) + + # Create a new node out of the modified data. + new_node = self.build_node_from_data(new_data) + + # Replace the old node with the new one. + self.replace(old_node, new_node) + + # Return the new node. + return new_node diff --git a/commanderbot/ext/automod/node/node_kind.py b/commanderbot/ext/automod/node/node_kind.py new file mode 100644 index 0000000..3e0753b --- /dev/null +++ b/commanderbot/ext/automod/node/node_kind.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Generic, Type, TypeVar + +from discord.ext.commands import Context + +from commanderbot.ext.automod.action.action import Action +from commanderbot.ext.automod.bucket.bucket import Bucket +from commanderbot.ext.automod.condition.condition import Condition +from commanderbot.ext.automod.node.node import Node +from commanderbot.ext.automod.rule.rule import Rule +from commanderbot.ext.automod.trigger.trigger import Trigger +from commanderbot.lib.responsive_exception import ResponsiveException + +__all__ = ( + "NodeKind", + "NodeKindConverter", +) + + +NT = TypeVar("NT", bound=Node) + + +class NodeKind(Enum): + @dataclass + class _Value(Generic[NT]): + plural: str + singular: str + node_type: Type[NT] + + RULE = _Value("rule", "rules", Rule) + BUCKET = _Value("bucket", "buckets", Bucket) + TRIGGER = _Value("trigger", "triggers", Trigger) + CONDITION = _Value("condition", "conditions", Condition) + ACTION = _Value("action", "actions", Action) + + def __str__(self) -> str: + return self.value.singular + + @property + def plural(self) -> str: + return self.value.plural + + @property + def singular(self) -> str: + return self.value.singular + + @property + def node_type(self) -> Type[NT]: + return self.value.node_type + + +class NodeKindConverter: + async def convert(self, ctx: Context, argument: str) -> NodeKind: + try: + return NodeKind[argument] + except: + pass + + try: + return NodeKind[argument.upper()] + except: + pass + + raise ResponsiveException(f"No such `{NodeKind.__name__}`: `{argument}`") diff --git a/commanderbot/ext/automod/node/node_ref.py b/commanderbot/ext/automod/node/node_ref.py new file mode 100644 index 0000000..4f9cd6a --- /dev/null +++ b/commanderbot/ext/automod/node/node_ref.py @@ -0,0 +1,46 @@ +import typing +from dataclasses import dataclass +from typing import Any, Generic, Optional, Type, TypeVar, cast + +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.node.node import Node +from commanderbot.lib import FromData, ToData + +__all__ = ("NodeRef",) + + +ST = TypeVar("ST") +NT = TypeVar("NT", bound=Node) + + +@dataclass +class NodeRef(FromData, ToData, Generic[NT]): + """A reference to a node, by name.""" + + name: str + + # @overrides FromData + @classmethod + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: + if isinstance(data, str): + return cls(name=data) + + # @overrides ToData + def to_data(self) -> Any: + return self.name + + @property + def node_type(self) -> Type[NT]: + # IMPL + t_args = typing.get_args(self) + t_origin = typing.get_origin(self) + t_hints = typing.get_type_hints(self) + node_type = t_args[0] + assert issubclass(node_type, Node) + return cast(Type[NT], node_type) + + async def resolve(self, event: AutomodEvent) -> NT: + node = await event.state.store.require_node_with_type( + event.state.guild, self.node_type, self.name + ) + return node diff --git a/commanderbot/ext/automod/rule/__init__.py b/commanderbot/ext/automod/rule/__init__.py new file mode 100644 index 0000000..90f5e0a --- /dev/null +++ b/commanderbot/ext/automod/rule/__init__.py @@ -0,0 +1,2 @@ +from .rule import * +from .rule_collection import * diff --git a/commanderbot/ext/automod/automod_rule.py b/commanderbot/ext/automod/rule/rule.py similarity index 68% rename from commanderbot/ext/automod/automod_rule.py rename to commanderbot/ext/automod/rule/rule.py index b11aa92..086a0f8 100644 --- a/commanderbot/ext/automod/automod_rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -1,33 +1,25 @@ -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime -from typing import List, Optional +from typing import Any, Optional, Type, TypeVar -from commanderbot.ext.automod.automod_action import AutomodAction, deserialize_actions -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - deserialize_conditions, -) +from commanderbot.ext.automod.action import ActionCollection from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - deserialize_triggers, -) -from commanderbot.lib import JsonObject, LogOptions +from commanderbot.ext.automod.condition import ConditionCollection +from commanderbot.ext.automod.node.node_base import NodeBase +from commanderbot.ext.automod.trigger import TriggerCollection +from commanderbot.lib import LogOptions from commanderbot.lib.utils import datetime_from_field_optional +ST = TypeVar("ST", bound="Rule") + @dataclass -class AutomodRule: +class Rule(NodeBase): """ 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 @@ -36,8 +28,6 @@ class AutomodRule: 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 @@ -48,8 +38,6 @@ class AutomodRule: 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 @@ -57,16 +45,30 @@ class AutomodRule: log: Optional[LogOptions] - triggers: List[AutomodTrigger] - conditions: List[AutomodCondition] - actions: List[AutomodAction] + triggers: TriggerCollection + conditions: ConditionCollection + actions: ActionCollection - @staticmethod - def from_data(data: JsonObject) -> AutomodRule: + # @overrides FromData + @classmethod + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: + if not isinstance(data, dict): + return now = datetime.utcnow() added_on = datetime_from_field_optional(data, "added_on") or now modified_on = datetime_from_field_optional(data, "modified_on") or now - return AutomodRule( + triggers = ( + TriggerCollection.from_field_optional(data, "triggers") + or TriggerCollection() + ) + conditions = ( + ConditionCollection.from_field_optional(data, "conditions") + or ConditionCollection() + ) + actions = ( + ActionCollection.from_field_optional(data, "actions") or ActionCollection() + ) + return cls( name=data["name"], added_on=added_on, modified_on=modified_on, @@ -74,21 +76,20 @@ def from_data(data: JsonObject) -> AutomodRule: 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", [])), + triggers=triggers, + conditions=conditions, + actions=actions, ) def __hash__(self) -> int: return hash(self.name) + # @overrides NodeBase def build_title(self) -> str: parts = [] if self.disabled: parts.append("(Disabled)") - parts.append(f"{self.name}:") - if self.description: - parts.append(self.description) + parts.append(super().build_title()) return " ".join(parts) async def poll_triggers(self, event: AutomodEvent) -> bool: diff --git a/commanderbot/ext/automod/rule/rule_collection.py b/commanderbot/ext/automod/rule/rule_collection.py new file mode 100644 index 0000000..e0fc886 --- /dev/null +++ b/commanderbot/ext/automod/rule/rule_collection.py @@ -0,0 +1,87 @@ +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime +from typing import ( + Any, + AsyncIterable, + ClassVar, + DefaultDict, + Iterable, + Optional, + Set, + Type, +) + +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.node import NodeCollection +from commanderbot.ext.automod.rule.rule import Rule +from commanderbot.lib.utils import JsonPath, JsonPathOp + +__all__ = ("RuleCollection",) + + +RuleBase = Rule + + +@dataclass(init=False) +class RuleCollection(NodeCollection[Rule]): + """A collection of rules.""" + + # @implements NodeCollection + node_type: ClassVar[Type[Rule]] = RuleBase + + # Index rules by event type for faster look-up during event dispatch. + _rules_by_event_type: DefaultDict[Type[AutomodEvent], Set[Rule]] + + # @overrides NodeCollection + def __init__(self, nodes: Optional[Iterable[Rule]] = None): + self._rules_by_event_type = defaultdict(lambda: set()) + super().__init__(nodes) + + # @overrides NodeCollection + def _add_to_cache(self, node: Rule): + super()._add_to_cache(node) + rule = node + # Add the rule to the event index. + for trigger in rule.triggers: + for event_type in trigger.event_types: + self._rules_by_event_type[event_type].add(rule) + + # @overrides NodeCollection + def _remove_from_cache(self, node: Rule): + super()._remove_from_cache(node) + rule = node + # Remove the rule from the event index. + for rules_for_event_type in self._rules_by_event_type.values(): + if rule in rules_for_event_type: + rules_for_event_type.remove(rule) + + # @overrides NodeCollection + def modify(self, name: str, path: JsonPath, op: JsonPathOp, data: Any) -> Rule: + # Update the rule's "modified on" timestamp. + rule = super().modify(name, path, op, data) + rule.modified_on = datetime.utcnow() + return rule + + async def for_event(self, event: AutomodEvent) -> AsyncIterable[Rule]: + 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 await rule.poll_triggers(event): + yield rule + + def enable_by_name(self, name: str) -> Rule: + rule = self.require(name) + rule.disabled = False + return rule + + def disable_by_name(self, name: str) -> Rule: + rule = self.require(name) + rule.disabled = True + return rule + + def increment_hits_by_name(self, name: str) -> Rule: + rule = self.require(name) + rule.hits += 1 + return rule diff --git a/commanderbot/ext/automod/trigger/__init__.py b/commanderbot/ext/automod/trigger/__init__.py new file mode 100644 index 0000000..2f9e34b --- /dev/null +++ b/commanderbot/ext/automod/trigger/__init__.py @@ -0,0 +1,3 @@ +from .trigger import * +from .trigger_base import * +from .trigger_collection import * diff --git a/commanderbot/ext/automod/trigger/trigger.py b/commanderbot/ext/automod/trigger/trigger.py new file mode 100644 index 0000000..4457840 --- /dev/null +++ b/commanderbot/ext/automod/trigger/trigger.py @@ -0,0 +1,15 @@ +from typing import ClassVar, Optional, Protocol, Tuple, Type + +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.component import Component + +__all__ = ("Trigger",) + + +class Trigger(Component, Protocol): + """A trigger details precisely which events to listen for.""" + + event_types: ClassVar[Tuple[Type[AutomodEvent], ...]] + + async def poll(self, event: AutomodEvent) -> Optional[bool]: + """Check whether an event activates the trigger.""" diff --git a/commanderbot/ext/automod/trigger/trigger_base.py b/commanderbot/ext/automod/trigger/trigger_base.py new file mode 100644 index 0000000..23ad4ed --- /dev/null +++ b/commanderbot/ext/automod/trigger/trigger_base.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from typing import ClassVar, Tuple, Type + +from commanderbot.ext.automod import triggers +from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.component import ComponentBase + +__all__ = ("TriggerBase",) + + +# @implements Trigger +@dataclass +class TriggerBase(ComponentBase): + # @implements ComponentBase + default_module_prefix: ClassVar[str] = triggers.__name__ + + # @implements ComponentBase + module_function_name: ClassVar[str] = "create_trigger" + + event_types: ClassVar[Tuple[Type[AutomodEvent], ...]] = tuple() + + async 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 await self.ignore(event): + return False + + # If we get here, we probably care about the event. + return True + + async def ignore(self, event: AutomodEvent) -> bool: + """Override this if more than just the event type needs to be checked.""" + return False diff --git a/commanderbot/ext/automod/trigger/trigger_collection.py b/commanderbot/ext/automod/trigger/trigger_collection.py new file mode 100644 index 0000000..644aeee --- /dev/null +++ b/commanderbot/ext/automod/trigger/trigger_collection.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import ClassVar, Type + +from commanderbot.ext.automod.component import ComponentCollection +from commanderbot.ext.automod.trigger.trigger import Trigger +from commanderbot.ext.automod.trigger.trigger_base import TriggerBase + +__all__ = ("TriggerCollection",) + + +@dataclass(init=False) +class TriggerCollection(ComponentCollection[Trigger]): + """A collection of triggers.""" + + # @implements NodeCollection + node_type: ClassVar[Type[Trigger]] = TriggerBase diff --git a/commanderbot/ext/automod/trigger/trigger_ref.py b/commanderbot/ext/automod/trigger/trigger_ref.py new file mode 100644 index 0000000..66e94e1 --- /dev/null +++ b/commanderbot/ext/automod/trigger/trigger_ref.py @@ -0,0 +1,13 @@ +from typing import TypeVar + +from commanderbot.ext.automod.node import NodeRef +from commanderbot.ext.automod.trigger.trigger import Trigger + +__all__ = ("TriggerRef",) + + +NT = TypeVar("NT", bound=Trigger) + + +class TriggerRef(NodeRef[NT]): + """A reference to a trigger, by name.""" diff --git a/commanderbot/ext/automod/triggers/member_joined.py b/commanderbot/ext/automod/triggers/member_joined.py index dc85065..bb77255 100644 --- a/commanderbot/ext/automod/triggers/member_joined.py +++ b/commanderbot/ext/automod/triggers/member_joined.py @@ -1,15 +1,12 @@ from dataclasses import dataclass from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) +from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib import JsonObject @dataclass -class MemberJoined(AutomodTriggerBase): +class MemberJoined(TriggerBase): """ Fires when an `on_member_join` event is received. @@ -19,5 +16,5 @@ class MemberJoined(AutomodTriggerBase): event_types = (events.MemberJoined,) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: JsonObject) -> Trigger: return MemberJoined.from_data(data) diff --git a/commanderbot/ext/automod/triggers/member_left.py b/commanderbot/ext/automod/triggers/member_left.py index 6bd56ba..fb8e4d1 100644 --- a/commanderbot/ext/automod/triggers/member_left.py +++ b/commanderbot/ext/automod/triggers/member_left.py @@ -1,15 +1,12 @@ from dataclasses import dataclass from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) +from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib import JsonObject @dataclass -class MemberLeft(AutomodTriggerBase): +class MemberLeft(TriggerBase): """ Fires when an `on_member_remove` event is received. @@ -19,5 +16,5 @@ class MemberLeft(AutomodTriggerBase): event_types = (events.MemberLeft,) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: JsonObject) -> Trigger: return MemberLeft.from_data(data) diff --git a/commanderbot/ext/automod/triggers/member_typing.py b/commanderbot/ext/automod/triggers/member_typing.py index c68f4cf..4288331 100644 --- a/commanderbot/ext/automod/triggers/member_typing.py +++ b/commanderbot/ext/automod/triggers/member_typing.py @@ -1,16 +1,14 @@ from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Any, Dict, Optional 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 ChannelsGuard, JsonObject, RolesGuard - -ST = TypeVar("ST") +from commanderbot.ext.automod.trigger import Trigger, TriggerBase +from commanderbot.lib import ChannelsGuard, RolesGuard @dataclass -class MemberTyping(AutomodTriggerBase): +class MemberTyping(TriggerBase): """ Fires when an `on_typing` event is received. @@ -29,12 +27,12 @@ class MemberTyping(AutomodTriggerBase): channels: Optional[ChannelsGuard] = None roles: Optional[RolesGuard] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: channels = ChannelsGuard.from_field_optional(data, "channels") roles = RolesGuard.from_field_optional(data, "roles") - return cls( - description=data.get("description"), + return dict( channels=channels, roles=roles, ) @@ -53,5 +51,5 @@ async def ignore(self, event: AutomodEvent) -> bool: return self.ignore_by_channel(event) or self.ignore_by_role(event) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: Any) -> Trigger: return MemberTyping.from_data(data) diff --git a/commanderbot/ext/automod/triggers/member_updated.py b/commanderbot/ext/automod/triggers/member_updated.py index 4b76395..9ec42e4 100644 --- a/commanderbot/ext/automod/triggers/member_updated.py +++ b/commanderbot/ext/automod/triggers/member_updated.py @@ -1,16 +1,14 @@ from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Any, Dict, Optional 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, RolesGuard - -ST = TypeVar("ST") +from commanderbot.ext.automod.trigger import Trigger, TriggerBase +from commanderbot.lib import RolesGuard @dataclass -class MemberUpdated(AutomodTriggerBase): +class MemberUpdated(TriggerBase): """ Fires when an `on_typing` event is received. @@ -33,11 +31,11 @@ class MemberUpdated(AutomodTriggerBase): roles: Optional[RolesGuard] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: roles = RolesGuard.from_field_optional(data, "roles") - return cls( - description=data.get("description"), + return dict( roles=roles, ) @@ -50,5 +48,5 @@ async def ignore(self, event: AutomodEvent) -> bool: return self.ignore_by_role(event) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: Any) -> Trigger: 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 664a04a..5b3521e 100644 --- a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py +++ b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py @@ -1,18 +1,16 @@ from dataclasses import dataclass -from typing import List, Optional, Type, TypeVar +from typing import Any, Dict, List, Optional from discord import Member, Message, 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 ChannelsGuard, JsonObject, RolesGuard - -ST = TypeVar("ST") +from commanderbot.ext.automod.trigger import Trigger, TriggerBase +from commanderbot.lib import ChannelsGuard, RolesGuard @dataclass -class MentionsRemovedFromMessage(AutomodTriggerBase): +class MentionsRemovedFromMessage(TriggerBase): """ Fires when mentions are removed from a message. @@ -37,13 +35,13 @@ class MentionsRemovedFromMessage(AutomodTriggerBase): author_roles: Optional[RolesGuard] = None victim_roles: Optional[RolesGuard] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: 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"), + return dict( channels=channels, author_roles=author_roles, victim_roles=victim_roles, @@ -140,5 +138,5 @@ async def ignore(self, event: AutomodEvent) -> bool: return True -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: Any) -> Trigger: return MentionsRemovedFromMessage.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message.py b/commanderbot/ext/automod/triggers/message.py index 1dbb1d6..d7e4fce 100644 --- a/commanderbot/ext/automod/triggers/message.py +++ b/commanderbot/ext/automod/triggers/message.py @@ -1,16 +1,14 @@ from dataclasses import dataclass -from typing import List, Optional, Type, TypeVar +from typing import Any, Dict, List, Optional 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 ChannelsGuard, JsonObject, RolesGuard - -ST = TypeVar("ST") +from commanderbot.ext.automod.trigger import Trigger, TriggerBase +from commanderbot.lib import ChannelsGuard, RolesGuard @dataclass -class Message(AutomodTriggerBase): +class Message(TriggerBase): """ Fires when an `on_message` or `on_message_edit` event is received. @@ -36,15 +34,15 @@ class Message(AutomodTriggerBase): channels: Optional[ChannelsGuard] = None author_roles: Optional[RolesGuard] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: content = data.get("content") if isinstance(content, str): content = [content] channels = ChannelsGuard.from_field_optional(data, "channels") author_roles = RolesGuard.from_field_optional(data, "author_roles") - return cls( - description=data.get("description"), + return dict( content=content, channels=channels, author_roles=author_roles, @@ -73,5 +71,5 @@ async def ignore(self, event: AutomodEvent) -> bool: ) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: Any) -> Trigger: return Message.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_deleted.py b/commanderbot/ext/automod/triggers/message_deleted.py index 540979f..35417fe 100644 --- a/commanderbot/ext/automod/triggers/message_deleted.py +++ b/commanderbot/ext/automod/triggers/message_deleted.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger +from commanderbot.ext.automod.trigger import Trigger from commanderbot.ext.automod.triggers.message import Message from commanderbot.lib import JsonObject @@ -17,5 +17,5 @@ class MessageDeleted(Message): event_types = (events.MessageDeleted,) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: JsonObject) -> Trigger: return MessageDeleted.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_edited.py b/commanderbot/ext/automod/triggers/message_edited.py index 36a176c..6ccc681 100644 --- a/commanderbot/ext/automod/triggers/message_edited.py +++ b/commanderbot/ext/automod/triggers/message_edited.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger +from commanderbot.ext.automod.trigger import Trigger from commanderbot.ext.automod.triggers.message import Message from commanderbot.lib import JsonObject @@ -17,5 +17,5 @@ class MessageEdited(Message): event_types = (events.MessageEdited,) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: JsonObject) -> Trigger: return MessageEdited.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_frequency_changed.py b/commanderbot/ext/automod/triggers/message_frequency_changed.py index 74950da..c5ed79d 100644 --- a/commanderbot/ext/automod/triggers/message_frequency_changed.py +++ b/commanderbot/ext/automod/triggers/message_frequency_changed.py @@ -1,20 +1,17 @@ from dataclasses import dataclass from datetime import timedelta -from typing import Type, TypeVar +from typing import Any, Dict, Optional from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_bucket_ref import BucketRef from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.ext.automod.automod_trigger import AutomodTrigger, AutomodTriggerBase +from commanderbot.ext.automod.bucket import BucketRef from commanderbot.ext.automod.buckets.message_frequency import MessageFrequency -from commanderbot.lib import JsonObject +from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib.utils import timedelta_from_field -ST = TypeVar("ST") - @dataclass -class MessageFrequencyChanged(AutomodTriggerBase): +class MessageFrequencyChanged(TriggerBase): """ Fires when a message author is suspect of spamming. @@ -39,17 +36,13 @@ class MessageFrequencyChanged(AutomodTriggerBase): channel_threshold: int timeframe: timedelta + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: bucket = BucketRef.from_field(data, "bucket") - message_threshold = int(data["message_threshold"]) - channel_threshold = int(data["channel_threshold"]) timeframe = timedelta_from_field(data, "timeframe") - return cls( - description=data.get("description"), + return dict( bucket=bucket, - message_threshold=message_threshold, - channel_threshold=channel_threshold, timeframe=timeframe, ) @@ -70,5 +63,5 @@ async def ignore(self, event: AutomodEvent) -> bool: return enough_messages and enough_channels -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: Any) -> Trigger: return MessageFrequencyChanged.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_sent.py b/commanderbot/ext/automod/triggers/message_sent.py index d216786..a9b1fdc 100644 --- a/commanderbot/ext/automod/triggers/message_sent.py +++ b/commanderbot/ext/automod/triggers/message_sent.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger +from commanderbot.ext.automod.trigger import Trigger from commanderbot.ext.automod.triggers.message import Message from commanderbot.lib import JsonObject @@ -17,5 +17,5 @@ class MessageSent(Message): event_types = (events.MessageSent,) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: JsonObject) -> Trigger: return MessageSent.from_data(data) diff --git a/commanderbot/ext/automod/triggers/reaction.py b/commanderbot/ext/automod/triggers/reaction.py index a7903ff..dee7b53 100644 --- a/commanderbot/ext/automod/triggers/reaction.py +++ b/commanderbot/ext/automod/triggers/reaction.py @@ -1,16 +1,14 @@ from dataclasses import dataclass -from typing import Optional, Type, TypeVar +from typing import Any, Dict, Optional 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 ChannelsGuard, JsonObject, ReactionsGuard, RolesGuard - -ST = TypeVar("ST") +from commanderbot.ext.automod.trigger import Trigger, TriggerBase +from commanderbot.lib import ChannelsGuard, ReactionsGuard, RolesGuard @dataclass -class Reaction(AutomodTriggerBase): +class Reaction(TriggerBase): """ Fires when an `on_reaction_add` or `on_reaction_remove` event is received. @@ -37,15 +35,15 @@ class Reaction(AutomodTriggerBase): author_roles: Optional[RolesGuard] = None actor_roles: Optional[RolesGuard] = None + # @overrides NodeBase @classmethod - def from_data(cls: Type[ST], data: JsonObject) -> ST: + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: reactions = ReactionsGuard.from_field_optional(data, "reactions") 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( + return dict( reactions=reactions, - description=data.get("description"), channels=channels, author_roles=author_roles, actor_roles=actor_roles, @@ -80,5 +78,5 @@ async def ignore(self, event: AutomodEvent) -> bool: ) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: Any) -> Trigger: return Reaction.from_data(data) diff --git a/commanderbot/ext/automod/triggers/reaction_added.py b/commanderbot/ext/automod/triggers/reaction_added.py index 6a7a514..99bbc14 100644 --- a/commanderbot/ext/automod/triggers/reaction_added.py +++ b/commanderbot/ext/automod/triggers/reaction_added.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger +from commanderbot.ext.automod.trigger import Trigger from commanderbot.ext.automod.triggers.reaction import Reaction from commanderbot.lib import JsonObject @@ -28,5 +28,5 @@ class ReactionAdded(Reaction): event_types = (events.ReactionAdded,) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: JsonObject) -> Trigger: return ReactionAdded.from_data(data) diff --git a/commanderbot/ext/automod/triggers/reaction_removed.py b/commanderbot/ext/automod/triggers/reaction_removed.py index dbf92d3..5c63303 100644 --- a/commanderbot/ext/automod/triggers/reaction_removed.py +++ b/commanderbot/ext/automod/triggers/reaction_removed.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import AutomodTrigger +from commanderbot.ext.automod.trigger import Trigger from commanderbot.ext.automod.triggers.reaction import Reaction from commanderbot.lib import JsonObject @@ -28,5 +28,5 @@ class ReactionRemoved(Reaction): event_types = (events.ReactionRemoved,) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: JsonObject) -> Trigger: return ReactionRemoved.from_data(data) diff --git a/commanderbot/ext/automod/triggers/user_banned.py b/commanderbot/ext/automod/triggers/user_banned.py index edfe12c..4edf614 100644 --- a/commanderbot/ext/automod/triggers/user_banned.py +++ b/commanderbot/ext/automod/triggers/user_banned.py @@ -1,15 +1,12 @@ from dataclasses import dataclass from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) +from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib import JsonObject @dataclass -class UserBanned(AutomodTriggerBase): +class UserBanned(TriggerBase): """ Fires when an `on_member_ban` event is received. @@ -19,5 +16,5 @@ class UserBanned(AutomodTriggerBase): event_types = (events.UserBanned,) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: JsonObject) -> Trigger: return UserBanned.from_data(data) diff --git a/commanderbot/ext/automod/triggers/user_unbanned.py b/commanderbot/ext/automod/triggers/user_unbanned.py index 6dbe94f..124d432 100644 --- a/commanderbot/ext/automod/triggers/user_unbanned.py +++ b/commanderbot/ext/automod/triggers/user_unbanned.py @@ -1,15 +1,12 @@ from dataclasses import dataclass from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) +from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib import JsonObject @dataclass -class UserUnbanned(AutomodTriggerBase): +class UserUnbanned(TriggerBase): """ Fires when an `on_member_unban` event is received. @@ -19,5 +16,5 @@ class UserUnbanned(AutomodTriggerBase): event_types = (events.UserUnbanned,) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: JsonObject) -> Trigger: return UserUnbanned.from_data(data) diff --git a/commanderbot/ext/automod/triggers/user_updated.py b/commanderbot/ext/automod/triggers/user_updated.py index 555f733..6300905 100644 --- a/commanderbot/ext/automod/triggers/user_updated.py +++ b/commanderbot/ext/automod/triggers/user_updated.py @@ -1,15 +1,12 @@ from dataclasses import dataclass from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_trigger import ( - AutomodTrigger, - AutomodTriggerBase, -) +from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib import JsonObject @dataclass -class UserUpdated(AutomodTriggerBase): +class UserUpdated(TriggerBase): """ Fires when an `on_user_update` event is received. @@ -24,5 +21,5 @@ class UserUpdated(AutomodTriggerBase): event_types = (events.UserUpdated,) -def create_trigger(data: JsonObject) -> AutomodTrigger: +def create_trigger(data: JsonObject) -> Trigger: return UserUpdated.from_data(data) diff --git a/commanderbot/ext/automod/utils.py b/commanderbot/ext/automod/utils.py index d26db53..86bf7ed 100644 --- a/commanderbot/ext/automod/utils.py +++ b/commanderbot/ext/automod/utils.py @@ -2,7 +2,7 @@ from importlib import import_module from typing import Any, DefaultDict -from commanderbot.lib import JsonObject, ResponsiveException +from commanderbot.lib import ResponsiveException module_function_cache: DefaultDict[str, DefaultDict[str, Any]] = defaultdict( lambda: defaultdict(lambda: None) @@ -41,23 +41,28 @@ def __init__(self, module_name: str, function_name: str): def deserialize_module_object( - data: JsonObject, + data: Any, default_module_prefix: str, function_name: str, ) -> Any: + """Use a `type` field to dynamically construct an object from another module.""" + # get the type name - type_name = str(data.pop("type")) + type_name = data.pop("type", None) 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) diff --git a/commanderbot/lib/allowed_mentions.py b/commanderbot/lib/allowed_mentions.py index dbb170a..6943830 100644 --- a/commanderbot/lib/allowed_mentions.py +++ b/commanderbot/lib/allowed_mentions.py @@ -1,14 +1,16 @@ -from typing import Any +from typing import Any, Optional, Type, TypeVar import discord -from commanderbot.lib.from_data_mixin import FromDataMixin -from commanderbot.lib.json_serializable import JsonSerializable +from commanderbot.lib.data import FromData, ToData __all__ = ("AllowedMentions",) -class AllowedMentions(JsonSerializable, discord.AllowedMentions, FromDataMixin): +ST = TypeVar("ST", bound="AllowedMentions") + + +class AllowedMentions(discord.AllowedMentions, FromData, ToData): """Extends `discord.AllowedMentions` to simplify de/serialization.""" @classmethod @@ -19,17 +21,17 @@ def not_everyone(cls): def only_replies(cls): return cls(everyone=False, users=False, roles=False, replied_user=True) - # @overrides FromDataMixin + # @overrides FromData @classmethod - def try_from_data(cls, data): + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: if isinstance(data, str): if factory := getattr(cls, data, None): return factory() if isinstance(data, dict): return cls(**data) - # @implements JsonSerializable - def to_json(self) -> Any: + # @overrides ToData + def to_data(self) -> Any: return dict( everyone=self.everyone, users=self.users, diff --git a/commanderbot/lib/data.py b/commanderbot/lib/data.py index 2085eef..3d4892c 100644 --- a/commanderbot/lib/data.py +++ b/commanderbot/lib/data.py @@ -1,8 +1,160 @@ -from typing import Any, Type +import dataclasses +from datetime import datetime, timedelta +from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, cast -__all__ = ("MalformedData",) +from discord import Color + +__all__ = ( + "MalformedData", + "FromData", + "ToData", +) + + +MISSING = object() class MalformedData(Exception): def __init__(self, cls: Type, data: Any): super().__init__(f"Cannot create {cls.__name__} from {type(data).__name__}") + + +ST = TypeVar("ST", bound="FromData") + + +class FromData: + """Something that can be deserialized from raw data into an object.""" + + @classmethod + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: + """ + Attempt to construct an instance of `cls` from raw `data`. + + An implementation of this method only needs to account for valid cases. + + It is recommended to use `from_data` as a wrapper around this method. In this + case if `None` (or nothing at all) is returned, or an error is thrown, a new + error will be thrown that wraps the original with additional context. + """ + if isinstance(data, dict): + # We're only doing this to satisfy type-checking. It may or may not work + # during runtime, but it should be wrapped in an error handler regardless. + maybe_constructor = cast(Callable[[Any], ST], cls) + return maybe_constructor(**data) + + @classmethod + def from_data(cls: Type[ST], data: Any) -> ST: + """Construct an instance of `cls` from raw `data`.""" + try: + if (maybe_from_data := cls.try_from_data(data)) is not None: + return maybe_from_data + except Exception as ex: + raise MalformedData(cls, data) from ex + raise MalformedData(cls, data) + + @classmethod + def from_field(cls: Type[ST], data: Dict[str, Any], key: str) -> ST: + """Construct an instance of `cls` from a field of `data`.""" + return cls.from_data(data[key]) + + @classmethod + def from_field_optional( + cls: Type[ST], data: Dict[str, Any], key: str + ) -> Optional[ST]: + """Optionally construct an instance of `cls` from a field of `data`.""" + if raw_value := data.get(key): + return cls.from_data(raw_value) + + @classmethod + def from_field_default( + cls: Type[ST], + data: Dict[str, Any], + key: str, + default_factory: Callable[[], ST], + ) -> ST: + """Construct an instance of `cls` from a field of `data` or return a default.""" + if raw_value := data.get(key): + return cls.from_data(raw_value) + return default_factory() + + +class ToData: + """An object that can be serialized into raw data.""" + + @classmethod + def attributes_to_data(cls, attrs: Dict[str, Any]) -> Dict[str, Any]: + """Convert object attributes to data.""" + return {k: cls.attribute_to_data(v) for k, v in attrs.items()} + + @classmethod + def set_to_data(cls, value: set) -> List[Any]: + return list(value) + + @classmethod + def datetime_to_data(cls, value: datetime) -> str: + return value.isoformat() + + @classmethod + def timedelta_to_data(cls, value: timedelta) -> Dict[str, int]: + return dict( + days=value.days, + seconds=value.seconds, + microseconds=value.microseconds, + ) + + @classmethod + def color_to_data(cls, value: Color) -> str: + return str(value) + + @classmethod + def attribute_to_data(cls, value: Any) -> Any: + """Convert an attribute to data, if possible.""" + if isinstance(value, ToData): + return value.to_data() + if dataclasses.is_dataclass(value): + return cls.attributes_to_data(value.__dict__) + if isinstance(value, set): + return cls.set_to_data(value) + if isinstance(value, datetime): + return cls.datetime_to_data(value) + if isinstance(value, timedelta): + return cls.timedelta_to_data(value) + if isinstance(value, Color): + return cls.color_to_data(value) + return value + + def to_data(self) -> Any: + """Turn the object into raw data.""" + # Start with a new, empty copy of data. + data: Dict[str, Any] = {} + + # Update with base fields, if any. + if base_fields := self.base_fields_to_data(): + data.update(base_fields) + + # Update with converted fields from `__dict__`. + converted_attributes = self.attributes_to_data(self.__dict__) + data.update(converted_attributes) + + # Update with additional complex fields, if any. + if complex_fields := self.complex_fields_to_data(): + data.update(complex_fields) + + # Return the final, converted data. + return data + + def base_fields_to_data(self) -> Optional[Dict[str, Any]]: + """ + Convert base fields into raw data, if any. + + Override this if the inheriting class has base fields that are not present on + instances of the class, but should be included in data. + """ + + def complex_fields_to_data(self) -> Optional[Dict[str, Any]]: + """ + Convert complex fields into raw data, if any. + + Override this if the inheriting class has additional attributes that require + special handling to be converted into data. + """ diff --git a/commanderbot/lib/extended_json_encoder.py b/commanderbot/lib/extended_json_encoder.py index cad9816..0796d8e 100644 --- a/commanderbot/lib/extended_json_encoder.py +++ b/commanderbot/lib/extended_json_encoder.py @@ -5,6 +5,7 @@ from discord import Color +from commanderbot.lib.data import ToData from commanderbot.lib.json_serializable import JsonSerializable from commanderbot.lib.utils import color_to_hex, datetime_to_str, timedelta_to_dict @@ -22,6 +23,8 @@ class ExtendedJsonEncoder(json.JSONEncoder): """ def default(self, obj: Any) -> Any: + if isinstance(obj, ToData): + return obj.to_data() if isinstance(obj, JsonSerializable): return obj.to_json() if isinstance(obj, set): diff --git a/commanderbot/lib/guards/channels_guard.py b/commanderbot/lib/guards/channels_guard.py index 033d22d..9c8ff81 100644 --- a/commanderbot/lib/guards/channels_guard.py +++ b/commanderbot/lib/guards/channels_guard.py @@ -1,16 +1,19 @@ from dataclasses import dataclass, field -from typing import Optional, Set +from typing import Any, Optional, Set, Type, TypeVar from discord import TextChannel, Thread -from commanderbot.lib.from_data_mixin import FromDataMixin +from commanderbot.lib.data import FromData, ToData from commanderbot.lib.types import ChannelID __all__ = ("ChannelsGuard",) +ST = TypeVar("ST") + + @dataclass -class ChannelsGuard(FromDataMixin): +class ChannelsGuard(FromData, ToData): """ Check whether a channel matches a set of channels. @@ -25,8 +28,9 @@ class ChannelsGuard(FromDataMixin): include: Set[ChannelID] = field(default_factory=set) exclude: Set[ChannelID] = field(default_factory=set) + # @overrides FromData @classmethod - def try_from_data(cls, data): + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: if isinstance(data, dict): return cls( include=set(data.get("include", [])), diff --git a/commanderbot/lib/guards/reactions_guard.py b/commanderbot/lib/guards/reactions_guard.py index 0ae65ac..bbef40b 100644 --- a/commanderbot/lib/guards/reactions_guard.py +++ b/commanderbot/lib/guards/reactions_guard.py @@ -1,16 +1,19 @@ from dataclasses import dataclass, field -from typing import Optional, Set +from typing import Any, Optional, Set, Type, TypeVar from discord import Reaction -from commanderbot.lib.from_data_mixin import FromDataMixin +from commanderbot.lib.data import FromData, ToData from commanderbot.lib.integer_range import IntegerRange __all__ = ("ReactionsGuard",) +ST = TypeVar("ST") + + @dataclass -class ReactionsGuard(FromDataMixin): +class ReactionsGuard(FromData, ToData): """ Check whether a reaction matches a set of reactions. @@ -29,8 +32,9 @@ class ReactionsGuard(FromDataMixin): count: Optional[IntegerRange] = None + # @overrides FromData @classmethod - def try_from_data(cls, data): + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: if isinstance(data, dict): return cls( include=set(data.get("include", [])), diff --git a/commanderbot/lib/guards/roles_guard.py b/commanderbot/lib/guards/roles_guard.py index f1137b6..08af428 100644 --- a/commanderbot/lib/guards/roles_guard.py +++ b/commanderbot/lib/guards/roles_guard.py @@ -1,17 +1,20 @@ from dataclasses import dataclass, field -from typing import Iterable, List, Optional, Set +from typing import Any, Iterable, List, Optional, Set, Type, TypeVar from discord import Member, Role, User -from commanderbot.lib.from_data_mixin import FromDataMixin +from commanderbot.lib.data import FromData, ToData from commanderbot.lib.types import RoleID from commanderbot.lib.utils import member_roles_from __all__ = ("RolesGuard",) +ST = TypeVar("ST") + + @dataclass -class RolesGuard(FromDataMixin): +class RolesGuard(FromData, ToData): """ Check whether a member matches a set of roles. @@ -26,8 +29,9 @@ class RolesGuard(FromDataMixin): include: Set[RoleID] = field(default_factory=set) exclude: Set[RoleID] = field(default_factory=set) + # @overrides FromData @classmethod - def try_from_data(cls, data): + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: if isinstance(data, dict): return cls( include=set(data.get("include", [])), diff --git a/commanderbot/lib/integer_range.py b/commanderbot/lib/integer_range.py index 3cffd20..7cafe86 100644 --- a/commanderbot/lib/integer_range.py +++ b/commanderbot/lib/integer_range.py @@ -1,13 +1,16 @@ from dataclasses import dataclass -from typing import Optional +from typing import Any, Optional, Type, TypeVar -from commanderbot.lib.from_data_mixin import FromDataMixin +from commanderbot.lib.data import FromData, ToData __all__ = ("IntegerRange",) +ST = TypeVar("ST") + + @dataclass -class IntegerRange(FromDataMixin): +class IntegerRange(FromData, ToData): """ An integer range with optional upper and lower bounds. @@ -22,8 +25,9 @@ class IntegerRange(FromDataMixin): min: Optional[int] max: Optional[int] + # @overrides FromData @classmethod - def try_from_data(cls, data): + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: if isinstance(data, int): return cls(min=data, max=data) elif isinstance(data, list): diff --git a/commanderbot/lib/intents.py b/commanderbot/lib/intents.py index 07b9de5..6601b6d 100644 --- a/commanderbot/lib/intents.py +++ b/commanderbot/lib/intents.py @@ -1,19 +1,21 @@ -from typing import Any +from typing import Any, Optional, Type, TypeVar import discord -from commanderbot.lib.from_data_mixin import FromDataMixin -from commanderbot.lib.json_serializable import JsonSerializable +from commanderbot.lib.data import FromData, ToData __all__ = ("Intents",) -class Intents(JsonSerializable, discord.Intents, FromDataMixin): +ST = TypeVar("ST", bound="Intents") + + +class Intents(discord.Intents, FromData, ToData): """Extends `discord.Intents` to simplify de/serialization.""" - # @overrides FromDataMixin + # @overrides FromData @classmethod - def try_from_data(cls, data): + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: if isinstance(data, int): return cls._from_value(data) elif isinstance(data, str): @@ -22,6 +24,6 @@ def try_from_data(cls, data): elif isinstance(data, dict): return cls(**data) - # @implements JsonSerializable - def to_json(self) -> Any: + # @overrides ToData + def to_data(self) -> Any: return self.__dict__ diff --git a/commanderbot/lib/json.py b/commanderbot/lib/json.py index 927c637..2653a4b 100644 --- a/commanderbot/lib/json.py +++ b/commanderbot/lib/json.py @@ -7,11 +7,6 @@ from commanderbot.lib.types import JsonObject -def to_data(obj: Any) -> Any: - # TODO There's got to be a direct way to do this... #optimize - return json.loads(json.dumps(obj, cls=ExtendedJsonEncoder)) - - def json_load(path: Path) -> JsonObject: with open(path) as fp: data = json.load(fp) diff --git a/commanderbot/lib/log_options.py b/commanderbot/lib/log_options.py index a1504b6..0d4ee8e 100644 --- a/commanderbot/lib/log_options.py +++ b/commanderbot/lib/log_options.py @@ -1,9 +1,9 @@ from dataclasses import dataclass -from typing import Optional +from typing import Any, Optional, Type, TypeVar from discord import Client, Color, Message, TextChannel, Thread -from commanderbot.lib.from_data_mixin import FromDataMixin +from commanderbot.lib.data import FromData, ToData from commanderbot.lib.responsive_exception import ResponsiveException from commanderbot.lib.types import ChannelID from commanderbot.lib.utils import color_from_field_optional, sanitize_stacktrace @@ -11,8 +11,11 @@ __all__ = ("LogOptions",) +ST = TypeVar("ST") + + @dataclass -class LogOptions(FromDataMixin): +class LogOptions(FromData, ToData): """ Data container for various log options. @@ -34,8 +37,9 @@ class LogOptions(FromDataMixin): emoji: Optional[str] = None color: Optional[Color] = None + # @overrides FromData @classmethod - def try_from_data(cls, data): + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: if isinstance(data, int): return cls(channel=data) elif isinstance(data, dict): diff --git a/commanderbot/lib/pattern_wrapper.py b/commanderbot/lib/pattern_wrapper.py index 9c5c89a..856c596 100644 --- a/commanderbot/lib/pattern_wrapper.py +++ b/commanderbot/lib/pattern_wrapper.py @@ -1,15 +1,17 @@ import re from dataclasses import dataclass -from typing import Any, AnyStr, Match, Optional +from typing import Any, AnyStr, Match, Optional, Type, TypeVar -from commanderbot.lib.from_data_mixin import FromDataMixin -from commanderbot.lib.json_serializable import JsonSerializable +from commanderbot.lib.data import FromData, ToData __all__ = ("PatternWrapper",) +ST = TypeVar("ST") + + @dataclass -class PatternWrapper(JsonSerializable, FromDataMixin): +class PatternWrapper(FromData, ToData): """Wraps `re.Pattern` to simplify de/serialization.""" pattern: re.Pattern @@ -17,33 +19,26 @@ class PatternWrapper(JsonSerializable, FromDataMixin): # TODO Other regex flags? #enhance ignore_case: Optional[bool] = None + # @overrides FromData @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): + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: if isinstance(data, str): - return cls.try_from_str(data) + pattern = re.compile(data) + return cls(pattern) elif isinstance(data, dict): - return cls.try_from_dict(data) + 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, + ) - # @implements JsonSerializable - def to_json(self) -> Any: + # @overrides ToData + def to_data(self) -> Any: if self.ignore_case: return dict( pattern=self.pattern.pattern, diff --git a/commanderbot/lib/role_set.py b/commanderbot/lib/role_set.py index 5142d6b..eae1261 100644 --- a/commanderbot/lib/role_set.py +++ b/commanderbot/lib/role_set.py @@ -1,17 +1,19 @@ from dataclasses import dataclass, field -from typing import Any, Iterable, Iterator, Optional, Set, Tuple, Union +from typing import Any, Iterable, Iterator, Optional, Set, Tuple, Type, TypeVar, Union from discord import Guild, Member, Role -from commanderbot.lib.from_data_mixin import FromDataMixin -from commanderbot.lib.json_serializable import JsonSerializable +from commanderbot.lib.data import FromData, ToData from commanderbot.lib.types import RoleID __all__ = ("RoleSet",) +ST = TypeVar("ST") + + @dataclass(frozen=True) -class RoleSet(JsonSerializable, FromDataMixin): +class RoleSet(FromData, ToData): """ Wrapper around a set of role IDs, useful for common set-based operations. @@ -23,8 +25,9 @@ class RoleSet(JsonSerializable, FromDataMixin): _values: Set[RoleID] = field(default_factory=set) + # @overrides FromData @classmethod - def try_from_data(cls, data): + def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: if isinstance(data, dict): return cls( _values=set(data.get("values", [])), @@ -38,8 +41,8 @@ def __iter__(self) -> Iterator[RoleID]: def __len__(self) -> int: return len(self._values) - # @implements JsonSerializable - def to_json(self) -> Any: + # @overrides ToData + def to_data(self) -> Any: return list(self._values) def _get_role_id(self, role: Union[Role, RoleID]) -> RoleID: diff --git a/commanderbot/lib/utils/json_path.py b/commanderbot/lib/utils/json_path.py index 5b4af3b..caeb16f 100644 --- a/commanderbot/lib/utils/json_path.py +++ b/commanderbot/lib/utils/json_path.py @@ -49,9 +49,11 @@ def query_json_path(target: Any, path: JsonPath) -> Any: return values -def update_json_with_path(target: Any, path: JsonPath, op: JsonPathOp, value: Any): +def update_json_with_path( + target: Any, path: JsonPath, op: JsonPathOp, value: Any +) -> Any: if op == JsonPathOp.set: - path.update_or_create(target, value) + return path.update_or_create(target, value) elif op == JsonPathOp.merge: if not isinstance(value, dict): raise ValueError(f"Expected `dict`, got `{type(target).__name__}`") @@ -68,3 +70,4 @@ def update_json_with_path(target: Any, path: JsonPath, op: JsonPathOp, value: An node.value.insert(0, value) else: raise ResponsiveException(f"Unsupported operation: `{op.value}`") + return target From 5c331e57f2204a471db43afe71c927ebfcc498ec Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Wed, 22 Sep 2021 13:27:02 -0400 Subject: [PATCH 09/26] Sort uppercase and lowercase together --- commanderbot/ext/automod/automod_guild_state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index 8e7a455..b883689 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -235,7 +235,7 @@ async def list_nodes( titles = [node.build_title() for node in nodes] # Sort the node titles alphabetically. - sorted_titles = sorted(titles) + sorted_titles = sorted(titles, key=lambda title: title.lower()) # Print out a code block with the node titles. lines = [ From 4a5c71075cbc1ac7372d42de4b0ca1a48deb866b Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Sat, 25 Sep 2021 17:20:07 -0400 Subject: [PATCH 10/26] Update `none_of` conditions --- .../ext/automod/conditions/none_of.py | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/commanderbot/ext/automod/conditions/none_of.py b/commanderbot/ext/automod/conditions/none_of.py index 21c2370..3608a4e 100644 --- a/commanderbot/ext/automod/conditions/none_of.py +++ b/commanderbot/ext/automod/conditions/none_of.py @@ -1,19 +1,17 @@ from dataclasses import dataclass -from typing import Tuple, Type, TypeVar +from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_condition import ( - AutomodCondition, - AutomodConditionBase, - deserialize_conditions, -) from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import ( + Condition, + ConditionBase, + ConditionCollection, +) from commanderbot.lib import JsonObject -ST = TypeVar("ST") - @dataclass -class NoneOf(AutomodConditionBase): +class NoneOf(ConditionBase): """ Passes if and only if none of the sub-conditions pass. @@ -23,14 +21,13 @@ class NoneOf(AutomodConditionBase): The sub-conditions to check. """ - conditions: Tuple[AutomodCondition] + conditions: ConditionCollection + # @overrides NodeBase @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"), + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + conditions = ConditionCollection.from_data(data["conditions"]) + return dict( conditions=conditions, ) @@ -41,5 +38,5 @@ async def check(self, event: AutomodEvent) -> bool: return True -def create_condition(data: JsonObject) -> AutomodCondition: +def create_condition(data: JsonObject) -> Condition: return NoneOf.from_data(data) From 0d24c64ba624fb28dc10238a07d7786ba4a64da8 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Tue, 5 Oct 2021 11:40:35 -0400 Subject: [PATCH 11/26] Remove last usages of JsonObject --- .../ext/automod/actions/add_users_to_thread.py | 6 +++--- commanderbot/ext/automod/actions/join_thread.py | 4 ++-- commanderbot/ext/automod/actions/wait.py | 3 +-- commanderbot/ext/automod/buckets/message_frequency.py | 4 ++-- .../ext/automod/conditions/actor_account_age.py | 5 ++--- .../ext/automod/conditions/actor_is_not_bot.py | 7 +++---- .../ext/automod/conditions/actor_is_not_self.py | 7 +++---- commanderbot/ext/automod/conditions/actor_roles.py | 11 ++++------- commanderbot/ext/automod/conditions/all_of.py | 3 +-- commanderbot/ext/automod/conditions/any_of.py | 3 +-- .../ext/automod/conditions/author_account_age.py | 7 +++---- .../ext/automod/conditions/author_is_not_bot.py | 7 +++---- .../ext/automod/conditions/author_is_not_self.py | 7 +++---- commanderbot/ext/automod/conditions/author_roles.py | 11 ++++------- .../automod/conditions/message_content_contains.py | 3 +-- .../ext/automod/conditions/message_content_matches.py | 4 ++-- .../ext/automod/conditions/message_has_attachments.py | 4 ++-- .../ext/automod/conditions/message_has_embeds.py | 4 ++-- .../ext/automod/conditions/message_has_links.py | 4 ++-- .../ext/automod/conditions/message_mentions_roles.py | 4 ++-- .../ext/automod/conditions/message_mentions_users.py | 4 ++-- commanderbot/ext/automod/conditions/none_of.py | 3 +-- commanderbot/ext/automod/conditions/not.py | 3 +-- commanderbot/ext/automod/conditions/throw_error.py | 4 ++-- commanderbot/ext/automod/conditions/wait.py | 3 +-- commanderbot/ext/automod/triggers/member_joined.py | 4 ++-- commanderbot/ext/automod/triggers/member_left.py | 4 ++-- commanderbot/ext/automod/triggers/message_deleted.py | 4 ++-- commanderbot/ext/automod/triggers/message_edited.py | 4 ++-- commanderbot/ext/automod/triggers/message_sent.py | 4 ++-- commanderbot/ext/automod/triggers/reaction_added.py | 4 ++-- commanderbot/ext/automod/triggers/reaction_removed.py | 4 ++-- commanderbot/ext/automod/triggers/thread_created.py | 4 ++-- commanderbot/ext/automod/triggers/user_banned.py | 4 ++-- commanderbot/ext/automod/triggers/user_unbanned.py | 4 ++-- commanderbot/ext/automod/triggers/user_updated.py | 4 ++-- 36 files changed, 75 insertions(+), 94 deletions(-) diff --git a/commanderbot/ext/automod/actions/add_users_to_thread.py b/commanderbot/ext/automod/actions/add_users_to_thread.py index 5b34964..f21e26b 100644 --- a/commanderbot/ext/automod/actions/add_users_to_thread.py +++ b/commanderbot/ext/automod/actions/add_users_to_thread.py @@ -1,11 +1,11 @@ from dataclasses import dataclass, field -from typing import Tuple +from typing import Any, Tuple from discord import Thread from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject, RoleID, UserID +from commanderbot.lib import RoleID, UserID @dataclass @@ -53,5 +53,5 @@ async def apply(self, event: AutomodEvent): await self.try_add_role(event, thread, role_id) -def create_action(data: JsonObject) -> Action: +def create_action(data: Any) -> Action: return AddUsersToThread.from_data(data) diff --git a/commanderbot/ext/automod/actions/join_thread.py b/commanderbot/ext/automod/actions/join_thread.py index e5f2925..10b8b98 100644 --- a/commanderbot/ext/automod/actions/join_thread.py +++ b/commanderbot/ext/automod/actions/join_thread.py @@ -1,8 +1,8 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject @dataclass @@ -20,5 +20,5 @@ async def apply(self, event: AutomodEvent): await thread.join() -def create_action(data: JsonObject) -> Action: +def create_action(data: Any) -> Action: return JoinThread.from_data(data) diff --git a/commanderbot/ext/automod/actions/wait.py b/commanderbot/ext/automod/actions/wait.py index fbe3e5f..d166dd5 100644 --- a/commanderbot/ext/automod/actions/wait.py +++ b/commanderbot/ext/automod/actions/wait.py @@ -5,7 +5,6 @@ from commanderbot.ext.automod.action import Action, ActionBase from commanderbot.ext.automod.automod_event import AutomodEvent -from commanderbot.lib import JsonObject from commanderbot.lib.utils import timedelta_from_field_optional @@ -32,5 +31,5 @@ async def apply(self, event: AutomodEvent): await asyncio.sleep(self.delay.total_seconds()) -def create_action(data: JsonObject) -> Action: +def create_action(data: Any) -> Action: return Wait.from_data(data) diff --git a/commanderbot/ext/automod/buckets/message_frequency.py b/commanderbot/ext/automod/buckets/message_frequency.py index 2cf9505..a9e3d74 100644 --- a/commanderbot/ext/automod/buckets/message_frequency.py +++ b/commanderbot/ext/automod/buckets/message_frequency.py @@ -10,7 +10,7 @@ from commanderbot.ext.automod import events from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.bucket import Bucket, BucketBase -from commanderbot.lib import ChannelID, JsonObject, UserID +from commanderbot.lib import ChannelID, UserID from commanderbot.lib.utils import timedelta_from_field_optional ST = TypeVar("ST") @@ -150,5 +150,5 @@ async def add(self, event: AutomodEvent): ) -def create_bucket(data: JsonObject) -> Bucket: +def create_bucket(data: Any) -> Bucket: return MessageFrequency.from_data(data) diff --git a/commanderbot/ext/automod/conditions/actor_account_age.py b/commanderbot/ext/automod/conditions/actor_account_age.py index d504e93..2d472f5 100644 --- a/commanderbot/ext/automod/conditions/actor_account_age.py +++ b/commanderbot/ext/automod/conditions/actor_account_age.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Any, Optional, TypeVar from discord import Member @@ -8,7 +8,6 @@ from commanderbot.ext.automod.conditions.abc.target_account_age_base import ( TargetAccountAgeBase, ) -from commanderbot.lib import JsonObject ST = TypeVar("ST") @@ -30,5 +29,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.actor -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return ActorAccountAge.from_data(data) diff --git a/commanderbot/ext/automod/conditions/actor_is_not_bot.py b/commanderbot/ext/automod/conditions/actor_is_not_bot.py index 8f806b4..cab90cb 100644 --- a/commanderbot/ext/automod/conditions/actor_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/actor_is_not_bot.py @@ -1,14 +1,13 @@ from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Any, Optional, TypeVar from discord import Member -from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_is_not_bot_base import ( TargetIsNotBotBase, ) -from commanderbot.lib import JsonObject ST = TypeVar("ST") @@ -23,5 +22,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.actor -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return ActorIsNotBot.from_data(data) diff --git a/commanderbot/ext/automod/conditions/actor_is_not_self.py b/commanderbot/ext/automod/conditions/actor_is_not_self.py index 685e022..c1decaa 100644 --- a/commanderbot/ext/automod/conditions/actor_is_not_self.py +++ b/commanderbot/ext/automod/conditions/actor_is_not_self.py @@ -1,14 +1,13 @@ from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Any, Optional, TypeVar from discord import Member -from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_is_not_self_base import ( TargetIsNotSelfBase, ) -from commanderbot.lib import JsonObject ST = TypeVar("ST") @@ -23,5 +22,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.actor -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return ActorIsNotSelf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/actor_roles.py b/commanderbot/ext/automod/conditions/actor_roles.py index b5059e1..e1f3f3f 100644 --- a/commanderbot/ext/automod/conditions/actor_roles.py +++ b/commanderbot/ext/automod/conditions/actor_roles.py @@ -1,14 +1,11 @@ from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Any, Optional, TypeVar from discord import Member -from commanderbot.ext.automod.condition import Condition 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.condition import Condition +from commanderbot.ext.automod.conditions.abc.target_roles_base import TargetRolesBase ST = TypeVar("ST") @@ -28,5 +25,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.actor -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return ActorRoles.from_data(data) diff --git a/commanderbot/ext/automod/conditions/all_of.py b/commanderbot/ext/automod/conditions/all_of.py index 943ecdb..0f56d4c 100644 --- a/commanderbot/ext/automod/conditions/all_of.py +++ b/commanderbot/ext/automod/conditions/all_of.py @@ -7,7 +7,6 @@ ConditionBase, ConditionCollection, ) -from commanderbot.lib import JsonObject @dataclass @@ -41,5 +40,5 @@ async def check(self, event: AutomodEvent) -> bool: return True -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return AllOf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/any_of.py b/commanderbot/ext/automod/conditions/any_of.py index 02bec67..c169d74 100644 --- a/commanderbot/ext/automod/conditions/any_of.py +++ b/commanderbot/ext/automod/conditions/any_of.py @@ -7,7 +7,6 @@ ConditionBase, ConditionCollection, ) -from commanderbot.lib import JsonObject @dataclass @@ -45,5 +44,5 @@ async def check(self, event: AutomodEvent) -> bool: return False -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: 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 5b00b70..a16b69b 100644 --- a/commanderbot/ext/automod/conditions/author_account_age.py +++ b/commanderbot/ext/automod/conditions/author_account_age.py @@ -1,14 +1,13 @@ from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Any, Optional, TypeVar from discord import Member -from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_account_age_base import ( TargetAccountAgeBase, ) -from commanderbot.lib import JsonObject ST = TypeVar("ST") @@ -30,5 +29,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.author -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return AuthorAccountAge.from_data(data) diff --git a/commanderbot/ext/automod/conditions/author_is_not_bot.py b/commanderbot/ext/automod/conditions/author_is_not_bot.py index eefc353..866972e 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/author_is_not_bot.py @@ -1,14 +1,13 @@ from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Any, Optional, TypeVar from discord import Member -from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_is_not_bot_base import ( TargetIsNotBotBase, ) -from commanderbot.lib import JsonObject ST = TypeVar("ST") @@ -23,5 +22,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.author -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return AuthorIsNotBot.from_data(data) diff --git a/commanderbot/ext/automod/conditions/author_is_not_self.py b/commanderbot/ext/automod/conditions/author_is_not_self.py index 63bde0b..181c8cb 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_self.py +++ b/commanderbot/ext/automod/conditions/author_is_not_self.py @@ -1,14 +1,13 @@ from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Any, Optional, TypeVar from discord import Member -from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_is_not_self_base import ( TargetIsNotSelfBase, ) -from commanderbot.lib import JsonObject ST = TypeVar("ST") @@ -23,5 +22,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.author -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return AuthorIsNotSelf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/author_roles.py b/commanderbot/ext/automod/conditions/author_roles.py index 2d3bd2f..49cc9e2 100644 --- a/commanderbot/ext/automod/conditions/author_roles.py +++ b/commanderbot/ext/automod/conditions/author_roles.py @@ -1,14 +1,11 @@ from dataclasses import dataclass -from typing import Optional, TypeVar +from typing import Any, Optional, TypeVar from discord import Member -from commanderbot.ext.automod.condition import Condition 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.condition import Condition +from commanderbot.ext.automod.conditions.abc.target_roles_base import TargetRolesBase ST = TypeVar("ST") @@ -28,5 +25,5 @@ def get_target(self, event: AutomodEvent) -> Optional[Member]: return event.author -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return AuthorRoles.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_content_contains.py b/commanderbot/ext/automod/conditions/message_content_contains.py index a1f429d..c19a035 100644 --- a/commanderbot/ext/automod/conditions/message_content_contains.py +++ b/commanderbot/ext/automod/conditions/message_content_contains.py @@ -4,7 +4,6 @@ from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase -from commanderbot.lib import JsonObject DEFAULT_NORMALIZATION_FORM = "NFKD" @@ -82,5 +81,5 @@ async def check(self, event: AutomodEvent) -> bool: return False -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return MessageContentContains.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_content_matches.py b/commanderbot/ext/automod/conditions/message_content_matches.py index e8a986f..2e3aadb 100644 --- a/commanderbot/ext/automod/conditions/message_content_matches.py +++ b/commanderbot/ext/automod/conditions/message_content_matches.py @@ -4,7 +4,7 @@ from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase -from commanderbot.lib import JsonObject, PatternWrapper +from commanderbot.lib import PatternWrapper DEFAULT_NORMALIZATION_FORM = "NFKD" @@ -81,5 +81,5 @@ async def check(self, event: AutomodEvent) -> bool: return False -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return MessageContentMatches.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_has_attachments.py b/commanderbot/ext/automod/conditions/message_has_attachments.py index 4afa89e..4ae63ad 100644 --- a/commanderbot/ext/automod/conditions/message_has_attachments.py +++ b/commanderbot/ext/automod/conditions/message_has_attachments.py @@ -3,7 +3,7 @@ from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase -from commanderbot.lib import IntegerRange, JsonObject +from commanderbot.lib import IntegerRange @dataclass @@ -37,5 +37,5 @@ async def check(self, event: AutomodEvent) -> bool: return count_attachments > 0 -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return MessageHasAttachments.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_has_embeds.py b/commanderbot/ext/automod/conditions/message_has_embeds.py index b300cec..7bc75cd 100644 --- a/commanderbot/ext/automod/conditions/message_has_embeds.py +++ b/commanderbot/ext/automod/conditions/message_has_embeds.py @@ -3,7 +3,7 @@ from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase -from commanderbot.lib import IntegerRange, JsonObject +from commanderbot.lib import IntegerRange @dataclass @@ -37,5 +37,5 @@ async def check(self, event: AutomodEvent) -> bool: return count_embeds > 0 -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return MessageHasEmbeds.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_has_links.py b/commanderbot/ext/automod/conditions/message_has_links.py index 97f1c30..0e76c72 100644 --- a/commanderbot/ext/automod/conditions/message_has_links.py +++ b/commanderbot/ext/automod/conditions/message_has_links.py @@ -3,7 +3,7 @@ from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase -from commanderbot.lib import IntegerRange, JsonObject +from commanderbot.lib import IntegerRange @dataclass @@ -43,5 +43,5 @@ async def check(self, event: AutomodEvent) -> bool: return count_links > 0 -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return MessageHasLinks.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_mentions_roles.py b/commanderbot/ext/automod/conditions/message_mentions_roles.py index 6532b45..594923d 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_roles.py +++ b/commanderbot/ext/automod/conditions/message_mentions_roles.py @@ -3,7 +3,7 @@ from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase -from commanderbot.lib import JsonObject, RolesGuard +from commanderbot.lib import RolesGuard @dataclass @@ -53,5 +53,5 @@ async def check(self, event: AutomodEvent) -> bool: return True -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return MessageMentionsRoles.from_data(data) diff --git a/commanderbot/ext/automod/conditions/message_mentions_users.py b/commanderbot/ext/automod/conditions/message_mentions_users.py index 7a9e58d..300c1ae 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_users.py +++ b/commanderbot/ext/automod/conditions/message_mentions_users.py @@ -1,8 +1,8 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase -from commanderbot.lib import JsonObject @dataclass @@ -28,5 +28,5 @@ async def check(self, event: AutomodEvent) -> bool: return True -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return MessageMentionsUsers.from_data(data) diff --git a/commanderbot/ext/automod/conditions/none_of.py b/commanderbot/ext/automod/conditions/none_of.py index 3608a4e..344aa93 100644 --- a/commanderbot/ext/automod/conditions/none_of.py +++ b/commanderbot/ext/automod/conditions/none_of.py @@ -7,7 +7,6 @@ ConditionBase, ConditionCollection, ) -from commanderbot.lib import JsonObject @dataclass @@ -38,5 +37,5 @@ async def check(self, event: AutomodEvent) -> bool: return True -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return NoneOf.from_data(data) diff --git a/commanderbot/ext/automod/conditions/not.py b/commanderbot/ext/automod/conditions/not.py index d43c096..4eda564 100644 --- a/commanderbot/ext/automod/conditions/not.py +++ b/commanderbot/ext/automod/conditions/not.py @@ -7,7 +7,6 @@ ConditionBase, ConditionCollection, ) -from commanderbot.lib import JsonObject @dataclass @@ -38,5 +37,5 @@ async def check(self, event: AutomodEvent) -> bool: return False -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return Not.from_data(data) diff --git a/commanderbot/ext/automod/conditions/throw_error.py b/commanderbot/ext/automod/conditions/throw_error.py index f52d34e..6dcaa7d 100644 --- a/commanderbot/ext/automod/conditions/throw_error.py +++ b/commanderbot/ext/automod/conditions/throw_error.py @@ -1,8 +1,8 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase -from commanderbot.lib import JsonObject @dataclass @@ -24,5 +24,5 @@ async def check(self, event: AutomodEvent) -> bool: raise Exception(self.error) -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return ThrowError.from_data(data) diff --git a/commanderbot/ext/automod/conditions/wait.py b/commanderbot/ext/automod/conditions/wait.py index 48106e4..9b038f0 100644 --- a/commanderbot/ext/automod/conditions/wait.py +++ b/commanderbot/ext/automod/conditions/wait.py @@ -5,7 +5,6 @@ from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase -from commanderbot.lib import JsonObject from commanderbot.lib.utils import timedelta_from_field_optional @@ -33,5 +32,5 @@ async def check(self, event: AutomodEvent) -> bool: return True -def create_condition(data: JsonObject) -> Condition: +def create_condition(data: Any) -> Condition: return Wait.from_data(data) diff --git a/commanderbot/ext/automod/triggers/member_joined.py b/commanderbot/ext/automod/triggers/member_joined.py index bb77255..43a2197 100644 --- a/commanderbot/ext/automod/triggers/member_joined.py +++ b/commanderbot/ext/automod/triggers/member_joined.py @@ -1,8 +1,8 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger, TriggerBase -from commanderbot.lib import JsonObject @dataclass @@ -16,5 +16,5 @@ class MemberJoined(TriggerBase): event_types = (events.MemberJoined,) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return MemberJoined.from_data(data) diff --git a/commanderbot/ext/automod/triggers/member_left.py b/commanderbot/ext/automod/triggers/member_left.py index fb8e4d1..5e4b164 100644 --- a/commanderbot/ext/automod/triggers/member_left.py +++ b/commanderbot/ext/automod/triggers/member_left.py @@ -1,8 +1,8 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger, TriggerBase -from commanderbot.lib import JsonObject @dataclass @@ -16,5 +16,5 @@ class MemberLeft(TriggerBase): event_types = (events.MemberLeft,) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return MemberLeft.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_deleted.py b/commanderbot/ext/automod/triggers/message_deleted.py index 35417fe..b31f7fa 100644 --- a/commanderbot/ext/automod/triggers/message_deleted.py +++ b/commanderbot/ext/automod/triggers/message_deleted.py @@ -1,9 +1,9 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger from commanderbot.ext.automod.triggers.message import Message -from commanderbot.lib import JsonObject @dataclass @@ -17,5 +17,5 @@ class MessageDeleted(Message): event_types = (events.MessageDeleted,) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return MessageDeleted.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_edited.py b/commanderbot/ext/automod/triggers/message_edited.py index 6ccc681..9d2ab48 100644 --- a/commanderbot/ext/automod/triggers/message_edited.py +++ b/commanderbot/ext/automod/triggers/message_edited.py @@ -1,9 +1,9 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger from commanderbot.ext.automod.triggers.message import Message -from commanderbot.lib import JsonObject @dataclass @@ -17,5 +17,5 @@ class MessageEdited(Message): event_types = (events.MessageEdited,) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return MessageEdited.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_sent.py b/commanderbot/ext/automod/triggers/message_sent.py index a9b1fdc..2d5e09b 100644 --- a/commanderbot/ext/automod/triggers/message_sent.py +++ b/commanderbot/ext/automod/triggers/message_sent.py @@ -1,9 +1,9 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger from commanderbot.ext.automod.triggers.message import Message -from commanderbot.lib import JsonObject @dataclass @@ -17,5 +17,5 @@ class MessageSent(Message): event_types = (events.MessageSent,) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return MessageSent.from_data(data) diff --git a/commanderbot/ext/automod/triggers/reaction_added.py b/commanderbot/ext/automod/triggers/reaction_added.py index 99bbc14..15a65c8 100644 --- a/commanderbot/ext/automod/triggers/reaction_added.py +++ b/commanderbot/ext/automod/triggers/reaction_added.py @@ -1,9 +1,9 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger from commanderbot.ext.automod.triggers.reaction import Reaction -from commanderbot.lib import JsonObject @dataclass @@ -28,5 +28,5 @@ class ReactionAdded(Reaction): event_types = (events.ReactionAdded,) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return ReactionAdded.from_data(data) diff --git a/commanderbot/ext/automod/triggers/reaction_removed.py b/commanderbot/ext/automod/triggers/reaction_removed.py index 5c63303..2a658f1 100644 --- a/commanderbot/ext/automod/triggers/reaction_removed.py +++ b/commanderbot/ext/automod/triggers/reaction_removed.py @@ -1,9 +1,9 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger from commanderbot.ext.automod.triggers.reaction import Reaction -from commanderbot.lib import JsonObject @dataclass @@ -28,5 +28,5 @@ class ReactionRemoved(Reaction): event_types = (events.ReactionRemoved,) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return ReactionRemoved.from_data(data) diff --git a/commanderbot/ext/automod/triggers/thread_created.py b/commanderbot/ext/automod/triggers/thread_created.py index 09ca927..b03dd0e 100644 --- a/commanderbot/ext/automod/triggers/thread_created.py +++ b/commanderbot/ext/automod/triggers/thread_created.py @@ -3,7 +3,7 @@ from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger, TriggerBase -from commanderbot.lib import ChannelsGuard, JsonObject +from commanderbot.lib import ChannelsGuard @dataclass @@ -44,5 +44,5 @@ def ignore(self, event: events.ThreadCreated) -> bool: return self.parent_channels.ignore(event.thread.parent) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return ThreadCreated.from_data(data) diff --git a/commanderbot/ext/automod/triggers/user_banned.py b/commanderbot/ext/automod/triggers/user_banned.py index 4edf614..027a555 100644 --- a/commanderbot/ext/automod/triggers/user_banned.py +++ b/commanderbot/ext/automod/triggers/user_banned.py @@ -1,8 +1,8 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger, TriggerBase -from commanderbot.lib import JsonObject @dataclass @@ -16,5 +16,5 @@ class UserBanned(TriggerBase): event_types = (events.UserBanned,) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return UserBanned.from_data(data) diff --git a/commanderbot/ext/automod/triggers/user_unbanned.py b/commanderbot/ext/automod/triggers/user_unbanned.py index 124d432..4b88bce 100644 --- a/commanderbot/ext/automod/triggers/user_unbanned.py +++ b/commanderbot/ext/automod/triggers/user_unbanned.py @@ -1,8 +1,8 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger, TriggerBase -from commanderbot.lib import JsonObject @dataclass @@ -16,5 +16,5 @@ class UserUnbanned(TriggerBase): event_types = (events.UserUnbanned,) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return UserUnbanned.from_data(data) diff --git a/commanderbot/ext/automod/triggers/user_updated.py b/commanderbot/ext/automod/triggers/user_updated.py index 6300905..7551c9b 100644 --- a/commanderbot/ext/automod/triggers/user_updated.py +++ b/commanderbot/ext/automod/triggers/user_updated.py @@ -1,8 +1,8 @@ from dataclasses import dataclass +from typing import Any from commanderbot.ext.automod import events from commanderbot.ext.automod.trigger import Trigger, TriggerBase -from commanderbot.lib import JsonObject @dataclass @@ -21,5 +21,5 @@ class UserUpdated(TriggerBase): event_types = (events.UserUpdated,) -def create_trigger(data: JsonObject) -> Trigger: +def create_trigger(data: Any) -> Trigger: return UserUpdated.from_data(data) From 6ea98dc12a69db670f7e90ff7de0d6761b654c7a Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Tue, 5 Oct 2021 13:02:02 -0400 Subject: [PATCH 12/26] Unwrap `ConversionError`s --- commanderbot/core/error_handling.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/commanderbot/core/error_handling.py b/commanderbot/core/error_handling.py index aa7a0e5..e45ccaa 100644 --- a/commanderbot/core/error_handling.py +++ b/commanderbot/core/error_handling.py @@ -9,6 +9,7 @@ CheckFailure, CommandInvokeError, CommandNotFound, + ConversionError, MissingPermissions, NoPrivateMessage, UserInputError, @@ -26,7 +27,7 @@ class ErrorHandling: command_error_handlers: List[CommandErrorHandler] = field(default_factory=list) def _get_root_error(self, error: Exception) -> Exception: - if isinstance(error, CommandInvokeError): + if isinstance(error, CommandInvokeError | ConversionError): return error.original else: return error From 47ff820a192814dffe6b69743259d613e1b510c6 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Tue, 5 Oct 2021 13:31:58 -0400 Subject: [PATCH 13/26] Promote `disabled` property to `NodeBase` --- commanderbot/ext/automod/automod_cog.py | 108 +++++++++--------- commanderbot/ext/automod/automod_data.py | 18 +-- .../ext/automod/automod_guild_state.py | 104 +++++++++-------- .../ext/automod/automod_json_store.py | 28 ++--- commanderbot/ext/automod/automod_store.py | 12 +- commanderbot/ext/automod/node/node.py | 1 + commanderbot/ext/automod/node/node_base.py | 8 +- .../ext/automod/node/node_collection.py | 10 ++ commanderbot/ext/automod/rule/rule.py | 7 +- .../ext/automod/rule/rule_collection.py | 10 -- 10 files changed, 160 insertions(+), 146 deletions(-) diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index ee05c63..f0efd58 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -495,89 +495,89 @@ async def cmd_automod_nodes_print( parsed_path = parse_json_path(path) if path else None await self.state[ctx.guild].print_node(ctx, node_kind, query, parsed_path) - # @@ automod rules - - @cmd_automod.group( - name="rules", - brief="Browse and manage automod rules.", - ) - async def cmd_automod_rules(self, ctx: GuildContext): - if not ctx.invoked_subcommand: - if ctx.subcommand_passed: - await ctx.send_help(self.cmd_automod_rules) - else: - await self.state[ctx.guild].list_nodes(ctx, NodeKind.RULE) - - @cmd_automod_rules.command( - name="list", - brief="List automod rules.", - ) - async def cmd_automod_rules_list(self, ctx: GuildContext, query: Optional[str]): - await self.state[ctx.guild].list_nodes(ctx, NodeKind.RULE, query) - - @cmd_automod_rules.command( - name="print", - brief="Print the code of an automod rule.", + @cmd_automod_nodes.command( + name="add", + brief="Add a new automod rule.", ) - async def cmd_automod_rules_print( + async def cmd_automod_nodes_add( self, ctx: GuildContext, - query: str, - path: Optional[str], + node_type: NodeKindConverter, + *, + body: str, ): - parsed_path = parse_json_path(path) if path else None - await self.state[ctx.guild].print_node(ctx, NodeKind.RULE, query, parsed_path) - - @cmd_automod_rules.command( - name="add", - brief="Add a new automod rule.", - ) - async def cmd_automod_rules_add(self, ctx: GuildContext, *, body: str): - await self.state[ctx.guild].add_node(ctx, NodeKind.RULE, body) + node_kind = cast(NodeKind, node_type) + await self.state[ctx.guild].add_node(ctx, node_kind, body) - @cmd_automod_rules.command( + @cmd_automod_nodes.command( name="remove", brief="Remove an automod rule.", ) - async def cmd_automod_rules_remove(self, ctx: GuildContext, name: str): - await self.state[ctx.guild].remove_node(ctx, NodeKind.RULE, name) + async def cmd_automod_nodes_remove( + self, + ctx: GuildContext, + node_type: NodeKindConverter, + name: str, + ): + node_kind = cast(NodeKind, node_type) + await self.state[ctx.guild].remove_node(ctx, node_kind, name) - @cmd_automod_rules.command( + @cmd_automod_nodes.command( name="modify", brief="Modify an automod rule", ) - async def cmd_automod_rules_modify( + async def cmd_automod_nodes_modify( self, ctx: GuildContext, + node_type: NodeKindConverter, name: str, path: str, op: str, *, body: str, ): + node_kind = cast(NodeKind, node_type) parsed_path = parse_json_path(path) parsed_op = parse_json_path_op(op) await self.state[ctx.guild].modify_node( - ctx, NodeKind.RULE, name, parsed_path, parsed_op, body + ctx, node_kind, name, parsed_path, parsed_op, body ) - @cmd_automod_rules.command( - name="explain", - brief="Explain an automod rule.", - ) - async def cmd_automod_rules_explain(self, ctx: GuildContext, query: str): - await self.state[ctx.guild].explain_rule(ctx, query) - - @cmd_automod_rules.command( + @cmd_automod_nodes.command( name="enable", brief="Enable an automod rule", ) - async def cmd_automod_rules_enable(self, ctx: GuildContext, name: str): - await self.state[ctx.guild].enable_rule(ctx, name) + async def cmd_automod_nodes_enable( + self, + ctx: GuildContext, + node_type: NodeKindConverter, + name: str, + ): + node_kind = cast(NodeKind, node_type) + await self.state[ctx.guild].enable_node(ctx, node_kind, name) - @cmd_automod_rules.command( + @cmd_automod_nodes.command( name="disable", brief="Disable an automod rule", ) - async def cmd_automod_rules_disable(self, ctx: GuildContext, name: str): - await self.state[ctx.guild].disable_rule(ctx, name) + async def cmd_automod_nodes_disable( + self, + ctx: GuildContext, + node_type: NodeKindConverter, + name: str, + ): + node_kind = cast(NodeKind, node_type) + await self.state[ctx.guild].disable_node(ctx, node_kind, name) + + @cmd_automod_nodes.command( + name="explain", + brief="Explain an automod node.", + ) + async def cmd_automod_nodes_explain( + self, + ctx: GuildContext, + node_type: NodeKindConverter, + query: str, + ): + node_kind = cast(NodeKind, node_type) + await self.state[ctx.guild].explain_node(ctx, node_kind, query) diff --git a/commanderbot/ext/automod/automod_data.py b/commanderbot/ext/automod/automod_data.py index 2f8a6f6..5153338 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -230,6 +230,16 @@ async def remove_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: collection = self.guilds[guild.id].get_collection(node_type) return collection.remove_by_name(name) + # @implements AutomodStore + async def enable_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: + collection = self.guilds[guild.id].get_collection(node_type) + return collection.enable_by_name(name) + + # @implements AutomodStore + async def disable_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: + collection = self.guilds[guild.id].get_collection(node_type) + return collection.disable_by_name(name) + # @implements AutomodStore async def modify_node( self, @@ -252,14 +262,6 @@ async def rules_for_event( async for rule in self.guilds[guild.id].rules.for_event(event): yield rule - # @implements AutomodStore - async def enable_rule(self, guild: Guild, name: str) -> Rule: - return self.guilds[guild.id].rules.enable_by_name(name) - - # @implements AutomodStore - async def disable_rule(self, guild: Guild, name: str) -> Rule: - return self.guilds[guild.id].rules.disable_by_name(name) - # @implements AutomodStore async def increment_rule_hits(self, guild: Guild, name: str) -> Rule: return self.guilds[guild.id].rules.increment_hits_by_name(name) diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index a649c64..f3293a5 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -197,6 +197,7 @@ async def list_nodes( nodes = await async_expand( self.store.all_nodes(self.guild, node_kind.node_type) ) + if nodes: # Build each node's title. titles = [node.build_title() for node in nodes] @@ -213,9 +214,12 @@ async def list_nodes( content = "\n".join(lines) await self.reply(ctx, content) - else: + elif query: await self.reply(ctx, f"No automod {node_kind} found matching `{query}`") + else: + await self.reply(ctx, f"No automod {node_kind} found") + async def print_node( self, ctx: GuildContext, @@ -282,6 +286,14 @@ async def remove_node(self, ctx: GuildContext, node_kind: NodeKind, name: str): elif conf == ConfirmationResult.NO: await self.reply(ctx, f"Did not remove automod {node_kind} `{node.name}`") + async def enable_node(self, ctx: GuildContext, node_kind: NodeKind, name: str): + node = await self.store.enable_node(self.guild, node_kind.node_type, name) + await self.reply(ctx, f"Enabled automod {node_kind} `{node.name}`") + + async def disable_node(self, ctx: GuildContext, node_kind: NodeKind, name: str): + node = await self.store.disable_node(self.guild, node_kind.node_type, name) + await self.reply(ctx, f"Disabled automod {node_kind} `{node.name}`") + async def modify_node( self, ctx: GuildContext, @@ -297,55 +309,53 @@ async def modify_node( ) await self.reply(ctx, f"Modified automod {node_kind} `{node.name}`") - # @@ RULES - - async def explain_rule(self, ctx: GuildContext, query: str): - rules = await async_expand(self.store.query_nodes(self.guild, Rule, query)) - if rules: + async def explain_node(self, ctx: GuildContext, node_kind: NodeKind, query: str): + nodes = await async_expand( + self.store.query_nodes(self.guild, node_kind.node_type, query) + ) + if nodes: # If multiple nodes were found, just use the first. - rule = rules[0] - - now = datetime.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) - - else: - await self.reply(ctx, f"No automod rules matching `{query}`") + node = nodes[0] - async def enable_rule(self, ctx: GuildContext, name: str): - rule = await self.store.enable_rule(self.guild, name) - await self.reply(ctx, f"Enabled automod rule `{rule.name}`") + # IMPL print node descriptions recursively + await self.reply(ctx, node.description or "(No description)") + + # # If multiple nodes were found, just use the first. + # rule = rules[0] + + # now = datetime.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) - async def disable_rule(self, ctx: GuildContext, name: str): - rule = await self.store.disable_rule(self.guild, name) - await self.reply(ctx, f"Disabled automod rule `{rule.name}`") + else: + await self.reply(ctx, f"No automod {node_kind} found matching `{query}`") # @@ EVENT HANDLERS diff --git a/commanderbot/ext/automod/automod_json_store.py b/commanderbot/ext/automod/automod_json_store.py index 10401f1..e0120b9 100644 --- a/commanderbot/ext/automod/automod_json_store.py +++ b/commanderbot/ext/automod/automod_json_store.py @@ -103,6 +103,20 @@ async def remove_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: await self.db.dirty() return removed_node + # @implements AutomodStore + async def enable_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: + cache = await self.db.get_cache() + modified_node = await cache.enable_node(guild, node_type, name) + await self.db.dirty() + return modified_node + + # @implements AutomodStore + async def disable_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: + cache = await self.db.get_cache() + modified_node = await cache.disable_node(guild, node_type, name) + await self.db.dirty() + return modified_node + # @implements AutomodStore async def modify_node( self, @@ -128,20 +142,6 @@ async def rules_for_event( async for rule in cache.rules_for_event(guild, event): yield rule - # @implements AutomodStore - async def enable_rule(self, guild: Guild, name: str) -> Rule: - 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) -> Rule: - 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) -> Rule: cache = await self.db.get_cache() diff --git a/commanderbot/ext/automod/automod_store.py b/commanderbot/ext/automod/automod_store.py index 30bd43d..208c0e5 100644 --- a/commanderbot/ext/automod/automod_store.py +++ b/commanderbot/ext/automod/automod_store.py @@ -63,6 +63,12 @@ async def add_node(self, guild: Guild, node_type: Type[NT], data: Any) -> NT: async def remove_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: ... + async def enable_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: + ... + + async def disable_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: + ... + async def modify_node( self, guild: Guild, @@ -79,11 +85,5 @@ async def modify_node( def rules_for_event(self, guild: Guild, event: AutomodEvent) -> AsyncIterable[Rule]: ... - async def enable_rule(self, guild: Guild, name: str) -> Rule: - ... - - async def disable_rule(self, guild: Guild, name: str) -> Rule: - ... - async def increment_rule_hits(self, guild: Guild, name: str) -> Rule: ... diff --git a/commanderbot/ext/automod/node/node.py b/commanderbot/ext/automod/node/node.py index e4bdd4d..24af534 100644 --- a/commanderbot/ext/automod/node/node.py +++ b/commanderbot/ext/automod/node/node.py @@ -16,6 +16,7 @@ class Node(Protocol): name: str description: Optional[str] + disabled: Optional[bool] @classmethod def from_data(cls: Type[ST], data: Any) -> ST: diff --git a/commanderbot/ext/automod/node/node_base.py b/commanderbot/ext/automod/node/node_base.py index bffe9c0..2afb3ee 100644 --- a/commanderbot/ext/automod/node/node_base.py +++ b/commanderbot/ext/automod/node/node_base.py @@ -27,11 +27,14 @@ class NodeBase(FromData, ToData): easier to type into chat. description A human-readable description of the node. + disabled + Whether the node is currently disabled. """ # @implements Node name: str description: Optional[str] + disabled: Optional[bool] # @overrides FromData @classmethod @@ -51,8 +54,8 @@ def build_base_data(cls, data: Dict[str, Any]) -> Dict[str, Any]: """ Auto-fill required dataclass fields that aren't necessarily required in data. - Currently this includes the `name` and `description` fields, which are required - dataclass fields not necessarily required in data. + Currently this includes the `name`, `description`, and `disabled` fields, which + are required dataclass fields not necessarily required in data. """ name = data.get("name") if not name: @@ -60,6 +63,7 @@ def build_base_data(cls, data: Dict[str, Any]) -> Dict[str, Any]: base_data: Dict[str, Any] = { "name": name, "description": None, + "disabled": None, } base_data.update(data) return base_data diff --git a/commanderbot/ext/automod/node/node_collection.py b/commanderbot/ext/automod/node/node_collection.py index a225c05..4b1f1ec 100644 --- a/commanderbot/ext/automod/node/node_collection.py +++ b/commanderbot/ext/automod/node/node_collection.py @@ -133,6 +133,16 @@ def remove_by_name(self, name: str) -> NT: self.remove(node) return node + def enable_by_name(self, name: str) -> NT: + node = self.require(name) + node.disabled = None + return node + + def disable_by_name(self, name: str) -> NT: + node = self.require(name) + node.disabled = True + return node + def get_index(self, node: NT) -> int: for i, n in enumerate(self._nodes): if n is node: diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index 086a0f8..01cba33 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -24,8 +24,6 @@ class Rule(NodeBase): 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. log @@ -40,7 +38,6 @@ class Rule(NodeBase): added_on: datetime modified_on: datetime - disabled: bool hits: int log: Optional[LogOptions] @@ -70,11 +67,11 @@ def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: ) return cls( name=data["name"], + description=data.get("description"), + disabled=data.get("disabled"), 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=triggers, conditions=conditions, diff --git a/commanderbot/ext/automod/rule/rule_collection.py b/commanderbot/ext/automod/rule/rule_collection.py index e0fc886..c138e7d 100644 --- a/commanderbot/ext/automod/rule/rule_collection.py +++ b/commanderbot/ext/automod/rule/rule_collection.py @@ -71,16 +71,6 @@ async def for_event(self, event: AutomodEvent) -> AsyncIterable[Rule]: if await rule.poll_triggers(event): yield rule - def enable_by_name(self, name: str) -> Rule: - rule = self.require(name) - rule.disabled = False - return rule - - def disable_by_name(self, name: str) -> Rule: - rule = self.require(name) - rule.disabled = True - return rule - def increment_hits_by_name(self, name: str) -> Rule: rule = self.require(name) rule.hits += 1 From def509ce059cafa5f8b8735bfa70f26422413811 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Tue, 5 Oct 2021 13:32:11 -0400 Subject: [PATCH 14/26] Stop using an enum for `NodeKind` --- commanderbot/ext/automod/node/node_kind.py | 60 ++++++++++------------ 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/commanderbot/ext/automod/node/node_kind.py b/commanderbot/ext/automod/node/node_kind.py index 3e0753b..0470f54 100644 --- a/commanderbot/ext/automod/node/node_kind.py +++ b/commanderbot/ext/automod/node/node_kind.py @@ -1,6 +1,5 @@ from dataclasses import dataclass -from enum import Enum -from typing import Generic, Type, TypeVar +from typing import Dict, Type from discord.ext.commands import Context @@ -18,48 +17,41 @@ ) -NT = TypeVar("NT", bound=Node) - - -class NodeKind(Enum): - @dataclass - class _Value(Generic[NT]): - plural: str - singular: str - node_type: Type[NT] - - RULE = _Value("rule", "rules", Rule) - BUCKET = _Value("bucket", "buckets", Bucket) - TRIGGER = _Value("trigger", "triggers", Trigger) - CONDITION = _Value("condition", "conditions", Condition) - ACTION = _Value("action", "actions", Action) +@dataclass +class NodeKind: + node_type: Type[Node] + singular: str + plural: str def __str__(self) -> str: - return self.value.singular + return self.singular + - @property - def plural(self) -> str: - return self.value.plural +rule = NodeKind(Rule, "rule", "rules") +bucket = NodeKind(Bucket, "bucket", "buckets") +trigger = NodeKind(Trigger, "trigger", "triggers") +condition = NodeKind(Condition, "condition", "conditions") +action = NodeKind(Action, "action", "actions") - @property - def singular(self) -> str: - return self.value.singular - @property - def node_type(self) -> Type[NT]: - return self.value.node_type +NODE_KINDS: Dict[str, NodeKind] = { + "rule": rule, + "bucket": bucket, + "trigger": trigger, + "condition": condition, + "action": action, +} class NodeKindConverter: async def convert(self, ctx: Context, argument: str) -> NodeKind: try: - return NodeKind[argument] - except: - pass - - try: - return NodeKind[argument.upper()] + return NODE_KINDS[argument] except: pass - raise ResponsiveException(f"No such `{NodeKind.__name__}`: `{argument}`") + node_kinds = [node_kind for node_kind in NODE_KINDS.values()] + node_kinds_str = " ".join(f"`{node_kind}`" for node_kind in node_kinds) + raise ResponsiveException( + f"No such node type `{argument}`" + f" (must be one of: {node_kinds_str})" + ) From db33b6b6a0be5471f39ac475e65d02b4e9e71d05 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Tue, 5 Oct 2021 14:25:03 -0400 Subject: [PATCH 15/26] Update argument descriptions --- commanderbot/ext/automod/automod_cog.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index f0efd58..ca76e66 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -497,7 +497,7 @@ async def cmd_automod_nodes_print( @cmd_automod_nodes.command( name="add", - brief="Add a new automod rule.", + brief="Add a new automod node.", ) async def cmd_automod_nodes_add( self, @@ -511,7 +511,7 @@ async def cmd_automod_nodes_add( @cmd_automod_nodes.command( name="remove", - brief="Remove an automod rule.", + brief="Remove an automod node.", ) async def cmd_automod_nodes_remove( self, @@ -524,7 +524,7 @@ async def cmd_automod_nodes_remove( @cmd_automod_nodes.command( name="modify", - brief="Modify an automod rule", + brief="Modify an automod node", ) async def cmd_automod_nodes_modify( self, @@ -545,7 +545,7 @@ async def cmd_automod_nodes_modify( @cmd_automod_nodes.command( name="enable", - brief="Enable an automod rule", + brief="Enable an automod node", ) async def cmd_automod_nodes_enable( self, @@ -558,7 +558,7 @@ async def cmd_automod_nodes_enable( @cmd_automod_nodes.command( name="disable", - brief="Disable an automod rule", + brief="Disable an automod node", ) async def cmd_automod_nodes_disable( self, From 73d58c788fe84c9b9d4ced8b9d5ada7674c11f71 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Tue, 5 Oct 2021 14:33:40 -0400 Subject: [PATCH 16/26] Skip disabled triggers --- commanderbot/ext/automod/trigger/trigger_base.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/commanderbot/ext/automod/trigger/trigger_base.py b/commanderbot/ext/automod/trigger/trigger_base.py index 23ad4ed..292a320 100644 --- a/commanderbot/ext/automod/trigger/trigger_base.py +++ b/commanderbot/ext/automod/trigger/trigger_base.py @@ -20,12 +20,16 @@ class TriggerBase(ComponentBase): event_types: ClassVar[Tuple[Type[AutomodEvent], ...]] = tuple() async def poll(self, event: AutomodEvent) -> bool: - # Verify that we care about this event type. + # Skip if we're disabled. + if self.disabled: + return False + + # Skip unless 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. + # Skip if the event should be ignored. if await self.ignore(event): return False From cfe98aa838c9d56cfd327bd2be7a612ab844997e Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Tue, 5 Oct 2021 14:34:33 -0400 Subject: [PATCH 17/26] Skip disabled nodes when iterating over a `NodeCollection` --- commanderbot/ext/automod/node/node_collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/node/node_collection.py b/commanderbot/ext/automod/node/node_collection.py index 4b1f1ec..22b4305 100644 --- a/commanderbot/ext/automod/node/node_collection.py +++ b/commanderbot/ext/automod/node/node_collection.py @@ -77,7 +77,7 @@ def __init__(self, nodes: Optional[Iterable[NT]] = None): self.add(node) def __iter__(self) -> Iterator[NT]: - return iter(self._nodes) + return iter(node for node in self._nodes if not node.disabled) # @overrides ToData def to_data(self) -> Any: From 7bcc98dda963057cb33df44f933d37d6e389bb3c Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Tue, 5 Oct 2021 14:42:35 -0400 Subject: [PATCH 18/26] Remove unused command/method --- commanderbot/ext/automod/automod_cog.py | 13 ----- .../ext/automod/automod_guild_state.py | 48 ------------------- 2 files changed, 61 deletions(-) diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index ca76e66..6d39e6c 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -568,16 +568,3 @@ async def cmd_automod_nodes_disable( ): node_kind = cast(NodeKind, node_type) await self.state[ctx.guild].disable_node(ctx, node_kind, name) - - @cmd_automod_nodes.command( - name="explain", - brief="Explain an automod node.", - ) - async def cmd_automod_nodes_explain( - self, - ctx: GuildContext, - node_type: NodeKindConverter, - query: str, - ): - node_kind = cast(NodeKind, node_type) - await self.state[ctx.guild].explain_node(ctx, node_kind, query) diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index f3293a5..fae7520 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -309,54 +309,6 @@ async def modify_node( ) await self.reply(ctx, f"Modified automod {node_kind} `{node.name}`") - async def explain_node(self, ctx: GuildContext, node_kind: NodeKind, query: str): - nodes = await async_expand( - self.store.query_nodes(self.guild, node_kind.node_type, query) - ) - if nodes: - # If multiple nodes were found, just use the first. - node = nodes[0] - - # IMPL print node descriptions recursively - await self.reply(ctx, node.description or "(No description)") - - # # If multiple nodes were found, just use the first. - # rule = rules[0] - - # now = datetime.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) - - else: - await self.reply(ctx, f"No automod {node_kind} found matching `{query}`") - # @@ EVENT HANDLERS async def _handle_rule_error(self, rule: Rule, error: Exception): From cfa9500cf4db1c040cf8e905373b64a5d2b0947b Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Mon, 11 Oct 2021 10:49:56 -0400 Subject: [PATCH 19/26] Adjust phrasing of replies --- .../ext/automod/automod_guild_state.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index fae7520..1e201fa 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -215,10 +215,10 @@ async def list_nodes( await self.reply(ctx, content) elif query: - await self.reply(ctx, f"No automod {node_kind} found matching `{query}`") + await self.reply(ctx, f"No {node_kind} matching `{query}`") else: - await self.reply(ctx, f"No automod {node_kind} found") + await self.reply(ctx, f"No {node_kind.plural} currently registered") async def print_node( self, @@ -257,12 +257,12 @@ async def print_node( ) else: - await self.reply(ctx, f"No automod {node_kind} found matching `{query}`") + await self.reply(ctx, f"No {node_kind} matching `{query}`") async def add_node(self, ctx: GuildContext, node_kind: NodeKind, body: str): data = self._parse_body(body) node = await self.store.add_node(self.guild, node_kind.node_type, data) - await self.reply(ctx, f"Added automod {node_kind} `{node.name}`") + await self.reply(ctx, f"Added {node_kind} `{node.name}`") async def remove_node(self, ctx: GuildContext, node_kind: NodeKind, name: str): # Get the corresponding node. @@ -272,7 +272,7 @@ async def remove_node(self, ctx: GuildContext, node_kind: NodeKind, name: str): conf = await confirm_with_reaction( self.bot, ctx, - f"Are you sure you want to remove automod {node_kind} `{node.name}`?", + f"Are you sure you want to remove {node_kind} `{node.name}`?", ) # If the answer was yes, attempt to remove the node and send a response. @@ -280,19 +280,19 @@ async def remove_node(self, ctx: GuildContext, node_kind: NodeKind, name: str): removed_node = await self.store.remove_node( self.guild, node_kind.node_type, node.name ) - await self.reply(ctx, f"Removed automod {node_kind} `{removed_node.name}`") + await self.reply(ctx, f"Removed {node_kind} `{removed_node.name}`") # If the answer was no, send a response. elif conf == ConfirmationResult.NO: - await self.reply(ctx, f"Did not remove automod {node_kind} `{node.name}`") + await self.reply(ctx, f"Did not remove {node_kind} `{node.name}`") async def enable_node(self, ctx: GuildContext, node_kind: NodeKind, name: str): node = await self.store.enable_node(self.guild, node_kind.node_type, name) - await self.reply(ctx, f"Enabled automod {node_kind} `{node.name}`") + await self.reply(ctx, f"Enabled {node_kind} `{node.name}`") async def disable_node(self, ctx: GuildContext, node_kind: NodeKind, name: str): node = await self.store.disable_node(self.guild, node_kind.node_type, name) - await self.reply(ctx, f"Disabled automod {node_kind} `{node.name}`") + await self.reply(ctx, f"Disabled {node_kind} `{node.name}`") async def modify_node( self, @@ -307,7 +307,7 @@ async def modify_node( node = await self.store.modify_node( self.guild, node_kind.node_type, name, path, op, data ) - await self.reply(ctx, f"Modified automod {node_kind} `{node.name}`") + await self.reply(ctx, f"Modified {node_kind} `{node.name}`") # @@ EVENT HANDLERS From 5b1e268163924ad7a4eb2b4ccaee90cbfede1128 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Mon, 11 Oct 2021 12:04:47 -0400 Subject: [PATCH 20/26] Finish initial implementation of `NodeRef` --- commanderbot/ext/automod/action/action_ref.py | 8 ++++++-- commanderbot/ext/automod/bucket/bucket_ref.py | 8 ++++++-- .../ext/automod/condition/condition_ref.py | 8 ++++++-- .../ext/automod/node/node_collection.py | 2 +- commanderbot/ext/automod/node/node_ref.py | 18 ++++++++---------- commanderbot/ext/automod/rule/rule_ref.py | 13 +++++++++++++ .../ext/automod/trigger/trigger_ref.py | 8 ++++++-- 7 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 commanderbot/ext/automod/rule/rule_ref.py diff --git a/commanderbot/ext/automod/action/action_ref.py b/commanderbot/ext/automod/action/action_ref.py index a720052..6fe2ba4 100644 --- a/commanderbot/ext/automod/action/action_ref.py +++ b/commanderbot/ext/automod/action/action_ref.py @@ -1,6 +1,7 @@ -from typing import TypeVar +from typing import ClassVar, Generic, Type, TypeVar from commanderbot.ext.automod.action.action import Action +from commanderbot.ext.automod.action.action_base import ActionBase from commanderbot.ext.automod.node import NodeRef __all__ = ("ActionRef",) @@ -9,5 +10,8 @@ NT = TypeVar("NT", bound=Action) -class ActionRef(NodeRef[NT]): +class ActionRef(NodeRef[Action], Generic[NT]): """A reference to an action, by name.""" + + # @implements NodeRef + node_type: ClassVar[Type[Action]] = ActionBase diff --git a/commanderbot/ext/automod/bucket/bucket_ref.py b/commanderbot/ext/automod/bucket/bucket_ref.py index f505d22..30b9655 100644 --- a/commanderbot/ext/automod/bucket/bucket_ref.py +++ b/commanderbot/ext/automod/bucket/bucket_ref.py @@ -1,6 +1,7 @@ -from typing import TypeVar +from typing import ClassVar, Generic, Type, TypeVar from commanderbot.ext.automod.bucket.bucket import Bucket +from commanderbot.ext.automod.bucket.bucket_base import BucketBase from commanderbot.ext.automod.node import NodeRef __all__ = ("BucketRef",) @@ -9,5 +10,8 @@ NT = TypeVar("NT", bound=Bucket) -class BucketRef(NodeRef[NT]): +class BucketRef(NodeRef[NT], Generic[NT]): """A reference to a bucket, by name.""" + + # @implements NodeRef + node_type: ClassVar[Type[Bucket]] = BucketBase diff --git a/commanderbot/ext/automod/condition/condition_ref.py b/commanderbot/ext/automod/condition/condition_ref.py index 39841e1..daa8538 100644 --- a/commanderbot/ext/automod/condition/condition_ref.py +++ b/commanderbot/ext/automod/condition/condition_ref.py @@ -1,6 +1,7 @@ -from typing import TypeVar +from typing import ClassVar, Generic, Type, TypeVar from commanderbot.ext.automod.condition.condition import Condition +from commanderbot.ext.automod.condition.condition_base import ConditionBase from commanderbot.ext.automod.node import NodeRef __all__ = ("ConditionRef",) @@ -9,5 +10,8 @@ NT = TypeVar("NT", bound=Condition) -class ConditionRef(NodeRef[NT]): +class ConditionRef(NodeRef[Condition], Generic[NT]): """A reference to a condition, by name.""" + + # @implements NodeRef + node_type: ClassVar[Type[Condition]] = ConditionBase diff --git a/commanderbot/ext/automod/node/node_collection.py b/commanderbot/ext/automod/node/node_collection.py index 22b4305..1f8f771 100644 --- a/commanderbot/ext/automod/node/node_collection.py +++ b/commanderbot/ext/automod/node/node_collection.py @@ -56,7 +56,7 @@ class NodeCollection(FromData, ToData, Generic[NT]): @property @abstractmethod def node_type(cls) -> Type[NT]: - ... + """Return the concrete node type used to construct instances.""" @classmethod def build_node_from_data(cls: Type[ST], data: Any) -> NT: diff --git a/commanderbot/ext/automod/node/node_ref.py b/commanderbot/ext/automod/node/node_ref.py index 4f9cd6a..67544bb 100644 --- a/commanderbot/ext/automod/node/node_ref.py +++ b/commanderbot/ext/automod/node/node_ref.py @@ -1,4 +1,5 @@ import typing +from abc import abstractmethod from dataclasses import dataclass from typing import Any, Generic, Optional, Type, TypeVar, cast @@ -13,12 +14,19 @@ NT = TypeVar("NT", bound=Node) +# @abstract @dataclass class NodeRef(FromData, ToData, Generic[NT]): """A reference to a node, by name.""" name: str + @classmethod + @property + @abstractmethod + def node_type(cls) -> Type[NT]: + """Return the concrete node type used to construct instances.""" + # @overrides FromData @classmethod def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: @@ -29,16 +37,6 @@ def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: def to_data(self) -> Any: return self.name - @property - def node_type(self) -> Type[NT]: - # IMPL - t_args = typing.get_args(self) - t_origin = typing.get_origin(self) - t_hints = typing.get_type_hints(self) - node_type = t_args[0] - assert issubclass(node_type, Node) - return cast(Type[NT], node_type) - async def resolve(self, event: AutomodEvent) -> NT: node = await event.state.store.require_node_with_type( event.state.guild, self.node_type, self.name diff --git a/commanderbot/ext/automod/rule/rule_ref.py b/commanderbot/ext/automod/rule/rule_ref.py new file mode 100644 index 0000000..8649d49 --- /dev/null +++ b/commanderbot/ext/automod/rule/rule_ref.py @@ -0,0 +1,13 @@ +from typing import ClassVar, Type + +from commanderbot.ext.automod.node import NodeRef +from commanderbot.ext.automod.rule.rule import Rule + +__all__ = ("RuleRef",) + + +class RuleRef(NodeRef[Rule]): + """A reference to a rule, by name.""" + + # @implements NodeRef + node_type: ClassVar[Type[Rule]] = Rule diff --git a/commanderbot/ext/automod/trigger/trigger_ref.py b/commanderbot/ext/automod/trigger/trigger_ref.py index 66e94e1..29e5e1e 100644 --- a/commanderbot/ext/automod/trigger/trigger_ref.py +++ b/commanderbot/ext/automod/trigger/trigger_ref.py @@ -1,7 +1,8 @@ -from typing import TypeVar +from typing import ClassVar, Generic, Type, TypeVar from commanderbot.ext.automod.node import NodeRef from commanderbot.ext.automod.trigger.trigger import Trigger +from commanderbot.ext.automod.trigger.trigger_base import TriggerBase __all__ = ("TriggerRef",) @@ -9,5 +10,8 @@ NT = TypeVar("NT", bound=Trigger) -class TriggerRef(NodeRef[NT]): +class TriggerRef(NodeRef[Trigger], Generic[NT]): """A reference to a trigger, by name.""" + + # @implements NodeRef + node_type: ClassVar[Type[Trigger]] = TriggerBase From 811f1f1e7136c039d609ef472edc80d0e0780328 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Mon, 11 Oct 2021 12:16:43 -0400 Subject: [PATCH 21/26] Consolidate annotation comments --- commanderbot/ext/automod/action/action_base.py | 2 ++ commanderbot/ext/automod/action/action_collection.py | 1 + commanderbot/ext/automod/action/action_ref.py | 1 + commanderbot/ext/automod/bucket/bucket_base.py | 2 ++ commanderbot/ext/automod/bucket/bucket_collection.py | 1 + commanderbot/ext/automod/bucket/bucket_ref.py | 1 + commanderbot/ext/automod/component/component_base.py | 3 +++ commanderbot/ext/automod/condition/condition_base.py | 4 +++- commanderbot/ext/automod/condition/condition_collection.py | 1 + commanderbot/ext/automod/condition/condition_ref.py | 1 + commanderbot/ext/automod/node/node_base.py | 4 ++++ commanderbot/ext/automod/rule/rule_ref.py | 1 + commanderbot/ext/automod/trigger/trigger_base.py | 3 +++ commanderbot/ext/automod/trigger/trigger_collection.py | 1 + commanderbot/ext/automod/trigger/trigger_ref.py | 1 + 15 files changed, 26 insertions(+), 1 deletion(-) diff --git a/commanderbot/ext/automod/action/action_base.py b/commanderbot/ext/automod/action/action_base.py index cea594c..d82fb8e 100644 --- a/commanderbot/ext/automod/action/action_base.py +++ b/commanderbot/ext/automod/action/action_base.py @@ -8,6 +8,7 @@ __all__ = ("ActionBase",) +# @implements ComponentBase # @implements Action @dataclass class ActionBase(ComponentBase): @@ -17,5 +18,6 @@ class ActionBase(ComponentBase): # @implements ComponentBase module_function_name: ClassVar[str] = "create_action" + # @implements Action async def apply(self, event: AutomodEvent): """Override this to apply the action.""" diff --git a/commanderbot/ext/automod/action/action_collection.py b/commanderbot/ext/automod/action/action_collection.py index 6f3a197..9eb33d6 100644 --- a/commanderbot/ext/automod/action/action_collection.py +++ b/commanderbot/ext/automod/action/action_collection.py @@ -8,6 +8,7 @@ __all__ = ("ActionCollection",) +# @implements ComponentCollection @dataclass(init=False) class ActionCollection(ComponentCollection[Action]): """A collection of actions.""" diff --git a/commanderbot/ext/automod/action/action_ref.py b/commanderbot/ext/automod/action/action_ref.py index 6fe2ba4..b911391 100644 --- a/commanderbot/ext/automod/action/action_ref.py +++ b/commanderbot/ext/automod/action/action_ref.py @@ -10,6 +10,7 @@ NT = TypeVar("NT", bound=Action) +# @implements NodeRef class ActionRef(NodeRef[Action], Generic[NT]): """A reference to an action, by name.""" diff --git a/commanderbot/ext/automod/bucket/bucket_base.py b/commanderbot/ext/automod/bucket/bucket_base.py index 0bf9c09..6d0c75b 100644 --- a/commanderbot/ext/automod/bucket/bucket_base.py +++ b/commanderbot/ext/automod/bucket/bucket_base.py @@ -8,6 +8,7 @@ __all__ = ("BucketBase",) +# @implements ComponentBase # @implements Bucket @dataclass class BucketBase(ComponentBase): @@ -17,5 +18,6 @@ class BucketBase(ComponentBase): # @implements ComponentBase module_function_name: ClassVar[str] = "create_bucket" + # @implements Bucket async def add(self, event: AutomodEvent): """Override this to modify the bucket according to the event.""" diff --git a/commanderbot/ext/automod/bucket/bucket_collection.py b/commanderbot/ext/automod/bucket/bucket_collection.py index 9770819..125bbc1 100644 --- a/commanderbot/ext/automod/bucket/bucket_collection.py +++ b/commanderbot/ext/automod/bucket/bucket_collection.py @@ -8,6 +8,7 @@ __all__ = ("BucketCollection",) +# @implements ComponentCollection @dataclass(init=False) class BucketCollection(ComponentCollection[Bucket]): """A collection of buckets.""" diff --git a/commanderbot/ext/automod/bucket/bucket_ref.py b/commanderbot/ext/automod/bucket/bucket_ref.py index 30b9655..9755f0f 100644 --- a/commanderbot/ext/automod/bucket/bucket_ref.py +++ b/commanderbot/ext/automod/bucket/bucket_ref.py @@ -10,6 +10,7 @@ NT = TypeVar("NT", bound=Bucket) +# @implements NodeRef class BucketRef(NodeRef[NT], Generic[NT]): """A reference to a bucket, by name.""" diff --git a/commanderbot/ext/automod/component/component_base.py b/commanderbot/ext/automod/component/component_base.py index f993ba3..6e1e099 100644 --- a/commanderbot/ext/automod/component/component_base.py +++ b/commanderbot/ext/automod/component/component_base.py @@ -10,6 +10,7 @@ ST = TypeVar("ST", bound="ComponentBase") +# @abstract # @implements Component @dataclass class ComponentBase(NodeBase): @@ -22,12 +23,14 @@ class ComponentBase(NodeBase): of its functions to deserialize the given data and create a new object. """ + # @implements Component @classmethod @property @abstractmethod def default_module_prefix(cls) -> str: ... + # @implements Component @classmethod @property @abstractmethod diff --git a/commanderbot/ext/automod/condition/condition_base.py b/commanderbot/ext/automod/condition/condition_base.py index 6eda7d5..b484a0d 100644 --- a/commanderbot/ext/automod/condition/condition_base.py +++ b/commanderbot/ext/automod/condition/condition_base.py @@ -8,15 +8,17 @@ __all__ = ("ConditionBase",) +# @implements ComponentBase # @implements Condition @dataclass class ConditionBase(ComponentBase): # @implements ComponentBase default_module_prefix: ClassVar[str] = conditions.__name__ - + # @implements ComponentBase module_function_name: ClassVar[str] = "create_condition" + # @implements Component async def check(self, event: AutomodEvent) -> bool: """Override this to check whether the condition passes.""" return False diff --git a/commanderbot/ext/automod/condition/condition_collection.py b/commanderbot/ext/automod/condition/condition_collection.py index 7290e5c..2091d86 100644 --- a/commanderbot/ext/automod/condition/condition_collection.py +++ b/commanderbot/ext/automod/condition/condition_collection.py @@ -8,6 +8,7 @@ __all__ = ("ConditionCollection",) +# @implements ComponentCollection @dataclass(init=False) class ConditionCollection(ComponentCollection[Condition]): """A collection of conditions.""" diff --git a/commanderbot/ext/automod/condition/condition_ref.py b/commanderbot/ext/automod/condition/condition_ref.py index daa8538..8b2153b 100644 --- a/commanderbot/ext/automod/condition/condition_ref.py +++ b/commanderbot/ext/automod/condition/condition_ref.py @@ -10,6 +10,7 @@ NT = TypeVar("NT", bound=Condition) +# @implements NodeRef class ConditionRef(NodeRef[Condition], Generic[NT]): """A reference to a condition, by name.""" diff --git a/commanderbot/ext/automod/node/node_base.py b/commanderbot/ext/automod/node/node_base.py index 2afb3ee..7bd2e36 100644 --- a/commanderbot/ext/automod/node/node_base.py +++ b/commanderbot/ext/automod/node/node_base.py @@ -33,7 +33,11 @@ class NodeBase(FromData, ToData): # @implements Node name: str + + # @implements Node description: Optional[str] + + # @implements Node disabled: Optional[bool] # @overrides FromData diff --git a/commanderbot/ext/automod/rule/rule_ref.py b/commanderbot/ext/automod/rule/rule_ref.py index 8649d49..539dc93 100644 --- a/commanderbot/ext/automod/rule/rule_ref.py +++ b/commanderbot/ext/automod/rule/rule_ref.py @@ -6,6 +6,7 @@ __all__ = ("RuleRef",) +# @implements NodeRef class RuleRef(NodeRef[Rule]): """A reference to a rule, by name.""" diff --git a/commanderbot/ext/automod/trigger/trigger_base.py b/commanderbot/ext/automod/trigger/trigger_base.py index 292a320..4a3502a 100644 --- a/commanderbot/ext/automod/trigger/trigger_base.py +++ b/commanderbot/ext/automod/trigger/trigger_base.py @@ -8,6 +8,7 @@ __all__ = ("TriggerBase",) +# @implements ComponentBase # @implements Trigger @dataclass class TriggerBase(ComponentBase): @@ -17,8 +18,10 @@ class TriggerBase(ComponentBase): # @implements ComponentBase module_function_name: ClassVar[str] = "create_trigger" + # @implements Trigger event_types: ClassVar[Tuple[Type[AutomodEvent], ...]] = tuple() + # @implements Trigger async def poll(self, event: AutomodEvent) -> bool: # Skip if we're disabled. if self.disabled: diff --git a/commanderbot/ext/automod/trigger/trigger_collection.py b/commanderbot/ext/automod/trigger/trigger_collection.py index 644aeee..f8d4361 100644 --- a/commanderbot/ext/automod/trigger/trigger_collection.py +++ b/commanderbot/ext/automod/trigger/trigger_collection.py @@ -8,6 +8,7 @@ __all__ = ("TriggerCollection",) +# @implements ComponentCollection @dataclass(init=False) class TriggerCollection(ComponentCollection[Trigger]): """A collection of triggers.""" diff --git a/commanderbot/ext/automod/trigger/trigger_ref.py b/commanderbot/ext/automod/trigger/trigger_ref.py index 29e5e1e..9b26a27 100644 --- a/commanderbot/ext/automod/trigger/trigger_ref.py +++ b/commanderbot/ext/automod/trigger/trigger_ref.py @@ -10,6 +10,7 @@ NT = TypeVar("NT", bound=Trigger) +# @implements NodeRef class TriggerRef(NodeRef[Trigger], Generic[NT]): """A reference to a trigger, by name.""" From bea9c76c21347be74c3a0b38e2fdaa1838d97a2e Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Mon, 11 Oct 2021 12:37:07 -0400 Subject: [PATCH 22/26] Add missing NodeRef imports --- commanderbot/ext/automod/action/__init__.py | 1 + commanderbot/ext/automod/condition/__init__.py | 1 + commanderbot/ext/automod/rule/__init__.py | 1 + commanderbot/ext/automod/trigger/__init__.py | 1 + 4 files changed, 4 insertions(+) diff --git a/commanderbot/ext/automod/action/__init__.py b/commanderbot/ext/automod/action/__init__.py index c25b51c..f67c252 100644 --- a/commanderbot/ext/automod/action/__init__.py +++ b/commanderbot/ext/automod/action/__init__.py @@ -1,3 +1,4 @@ from .action import * from .action_base import * from .action_collection import * +from .action_ref import * diff --git a/commanderbot/ext/automod/condition/__init__.py b/commanderbot/ext/automod/condition/__init__.py index ab7642f..97e5539 100644 --- a/commanderbot/ext/automod/condition/__init__.py +++ b/commanderbot/ext/automod/condition/__init__.py @@ -1,3 +1,4 @@ from .condition import * from .condition_base import * from .condition_collection import * +from .condition_ref import * diff --git a/commanderbot/ext/automod/rule/__init__.py b/commanderbot/ext/automod/rule/__init__.py index 90f5e0a..5ba8121 100644 --- a/commanderbot/ext/automod/rule/__init__.py +++ b/commanderbot/ext/automod/rule/__init__.py @@ -1,2 +1,3 @@ from .rule import * from .rule_collection import * +from .rule_ref import * diff --git a/commanderbot/ext/automod/trigger/__init__.py b/commanderbot/ext/automod/trigger/__init__.py index 2f9e34b..cd6c2ea 100644 --- a/commanderbot/ext/automod/trigger/__init__.py +++ b/commanderbot/ext/automod/trigger/__init__.py @@ -1,3 +1,4 @@ from .trigger import * from .trigger_base import * from .trigger_collection import * +from .trigger_ref import * From a345fcfbb2e6b772795339eac4c968f83f74ce14 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Mon, 11 Oct 2021 12:37:22 -0400 Subject: [PATCH 23/26] Rename/refactor AutomodEvent -> Event --- commanderbot/ext/automod/__init__.py | 2 +- commanderbot/ext/automod/action/action.py | 4 +- .../ext/automod/action/action_base.py | 4 +- .../actions/abc/add_roles_to_target_base.py | 6 +- .../abc/remove_roles_from_target_base.py | 6 +- .../ext/automod/actions/add_reactions.py | 4 +- .../ext/automod/actions/add_roles_to_actor.py | 4 +- .../automod/actions/add_roles_to_author.py | 4 +- .../ext/automod/actions/add_to_bucket.py | 4 +- .../automod/actions/add_users_to_thread.py | 8 +- .../ext/automod/actions/delete_message.py | 4 +- .../ext/automod/actions/edit_thread.py | 4 +- .../ext/automod/actions/join_thread.py | 4 +- .../ext/automod/actions/log_message.py | 6 +- .../automod/actions/remove_all_reactions.py | 4 +- .../automod/actions/remove_own_reactions.py | 4 +- .../ext/automod/actions/remove_reactions.py | 4 +- .../actions/remove_roles_from_actor.py | 4 +- .../actions/remove_roles_from_author.py | 4 +- .../ext/automod/actions/reply_to_message.py | 4 +- .../ext/automod/actions/send_message.py | 6 +- .../ext/automod/actions/throw_error.py | 4 +- commanderbot/ext/automod/actions/wait.py | 4 +- commanderbot/ext/automod/automod_data.py | 6 +- .../ext/automod/automod_guild_state.py | 6 +- .../ext/automod/automod_json_store.py | 6 +- commanderbot/ext/automod/automod_store.py | 4 +- commanderbot/ext/automod/bucket/bucket.py | 4 +- .../ext/automod/bucket/bucket_base.py | 4 +- .../ext/automod/buckets/message_frequency.py | 4 +- .../ext/automod/condition/condition.py | 4 +- .../ext/automod/condition/condition_base.py | 4 +- .../conditions/abc/target_account_age_base.py | 6 +- .../conditions/abc/target_is_not_bot_base.py | 6 +- .../conditions/abc/target_is_not_self_base.py | 6 +- .../conditions/abc/target_roles_base.py | 6 +- .../automod/conditions/actor_account_age.py | 4 +- .../automod/conditions/actor_is_not_bot.py | 4 +- .../automod/conditions/actor_is_not_self.py | 4 +- .../ext/automod/conditions/actor_roles.py | 4 +- commanderbot/ext/automod/conditions/all_of.py | 4 +- commanderbot/ext/automod/conditions/any_of.py | 4 +- .../automod/conditions/author_account_age.py | 4 +- .../automod/conditions/author_is_not_bot.py | 4 +- .../automod/conditions/author_is_not_self.py | 4 +- .../ext/automod/conditions/author_roles.py | 4 +- .../conditions/message_content_contains.py | 4 +- .../conditions/message_content_matches.py | 4 +- .../conditions/message_has_attachments.py | 4 +- .../automod/conditions/message_has_embeds.py | 4 +- .../automod/conditions/message_has_links.py | 4 +- .../conditions/message_mentions_roles.py | 4 +- .../conditions/message_mentions_users.py | 4 +- .../ext/automod/conditions/none_of.py | 4 +- commanderbot/ext/automod/conditions/not.py | 4 +- .../thread_auto_archive_duration.py | 4 +- .../ext/automod/conditions/throw_error.py | 4 +- commanderbot/ext/automod/conditions/wait.py | 4 +- commanderbot/ext/automod/event/__init__.py | 2 + commanderbot/ext/automod/event/event.py | 60 +++++++++++++++ .../{automod_event.py => event/event_base.py} | 75 +++++-------------- .../event_state.py} | 2 +- .../event_state.pyi} | 2 +- .../automod/events/guild_channel_created.py | 4 +- .../automod/events/guild_channel_deleted.py | 4 +- .../automod/events/guild_channel_updated.py | 4 +- .../ext/automod/events/member_joined.py | 4 +- .../ext/automod/events/member_left.py | 4 +- .../ext/automod/events/member_typing.py | 4 +- .../ext/automod/events/member_updated.py | 4 +- .../ext/automod/events/message_deleted.py | 4 +- .../ext/automod/events/message_edited.py | 4 +- .../events/message_frequency_changed.py | 4 +- .../ext/automod/events/message_sent.py | 4 +- .../ext/automod/events/raw_message_deleted.py | 4 +- .../ext/automod/events/raw_message_edited.py | 4 +- .../ext/automod/events/raw_reaction_added.py | 4 +- .../automod/events/raw_reaction_removed.py | 4 +- .../ext/automod/events/reaction_added.py | 4 +- .../ext/automod/events/reaction_removed.py | 4 +- .../ext/automod/events/thread_created.py | 4 +- .../ext/automod/events/thread_deleted.py | 4 +- .../ext/automod/events/thread_joined.py | 4 +- .../automod/events/thread_member_joined.py | 4 +- .../ext/automod/events/thread_member_left.py | 4 +- .../ext/automod/events/thread_removed.py | 4 +- .../ext/automod/events/thread_updated.py | 4 +- .../ext/automod/events/user_banned.py | 4 +- .../ext/automod/events/user_unbanned.py | 4 +- .../ext/automod/events/user_updated.py | 4 +- commanderbot/ext/automod/node/node_ref.py | 7 +- commanderbot/ext/automod/rule/rule.py | 10 +-- .../ext/automod/rule/rule_collection.py | 6 +- commanderbot/ext/automod/trigger/trigger.py | 6 +- .../ext/automod/trigger/trigger_base.py | 8 +- .../ext/automod/triggers/abc/thread_base.py | 4 +- .../ext/automod/triggers/member_typing.py | 8 +- .../ext/automod/triggers/member_updated.py | 6 +- .../triggers/mentions_removed_from_message.py | 4 +- commanderbot/ext/automod/triggers/message.py | 10 +-- .../triggers/message_frequency_changed.py | 4 +- commanderbot/ext/automod/triggers/reaction.py | 12 +-- 102 files changed, 306 insertions(+), 284 deletions(-) create mode 100644 commanderbot/ext/automod/event/__init__.py create mode 100644 commanderbot/ext/automod/event/event.py rename commanderbot/ext/automod/{automod_event.py => event/event_base.py} (78%) rename commanderbot/ext/automod/{automod_event_state.py => event/event_state.py} (50%) rename commanderbot/ext/automod/{automod_event_state.pyi => event/event_state.pyi} (68%) diff --git a/commanderbot/ext/automod/__init__.py b/commanderbot/ext/automod/__init__.py index fa65b1b..1e9fc0e 100644 --- a/commanderbot/ext/automod/__init__.py +++ b/commanderbot/ext/automod/__init__.py @@ -1,7 +1,7 @@ from discord.ext.commands import Bot -from commanderbot.ext.automod.automod_cog import AutomodCog from commanderbot.core.utils import add_configured_cog +from commanderbot.ext.automod.automod_cog import AutomodCog def setup(bot: Bot): diff --git a/commanderbot/ext/automod/action/action.py b/commanderbot/ext/automod/action/action.py index 1a58997..d47e191 100644 --- a/commanderbot/ext/automod/action/action.py +++ b/commanderbot/ext/automod/action/action.py @@ -1,7 +1,7 @@ from typing import Protocol -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.component import Component +from commanderbot.ext.automod.event import Event __all__ = ("Action",) @@ -9,5 +9,5 @@ class Action(Component, Protocol): """An action defines a task to perform when conditions pass.""" - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): """Apply the action.""" diff --git a/commanderbot/ext/automod/action/action_base.py b/commanderbot/ext/automod/action/action_base.py index d82fb8e..2e652ff 100644 --- a/commanderbot/ext/automod/action/action_base.py +++ b/commanderbot/ext/automod/action/action_base.py @@ -2,8 +2,8 @@ from typing import ClassVar from commanderbot.ext.automod import actions -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.component import ComponentBase +from commanderbot.ext.automod.event import Event __all__ = ("ActionBase",) @@ -19,5 +19,5 @@ class ActionBase(ComponentBase): module_function_name: ClassVar[str] = "create_action" # @implements Action - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): """Override this to apply the action.""" 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 index 2ac1683..1a8df70 100644 --- a/commanderbot/ext/automod/actions/abc/add_roles_to_target_base.py +++ b/commanderbot/ext/automod/actions/abc/add_roles_to_target_base.py @@ -4,7 +4,7 @@ from discord import Guild, Member from commanderbot.ext.automod.action import ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.lib import RoleID @@ -13,10 +13,10 @@ class AddRolesToTargetBase(ActionBase): roles: Tuple[RoleID] reason: Optional[str] = None - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: raise NotImplementedError() - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if member := self.get_target(event): guild: Guild = member.guild # TODO Warn about unresolved roles. #logging 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 index 7301345..3cb6f6d 100644 --- a/commanderbot/ext/automod/actions/abc/remove_roles_from_target_base.py +++ b/commanderbot/ext/automod/actions/abc/remove_roles_from_target_base.py @@ -4,7 +4,7 @@ from discord import Guild, Member from commanderbot.ext.automod.action import ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.lib import RoleID @@ -13,10 +13,10 @@ class RemoveRolesFromTargetBase(ActionBase): roles: Tuple[RoleID] reason: Optional[str] = None - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: raise NotImplementedError() - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if member := self.get_target(event): guild: Guild = member.guild # TODO Warn about unresolved roles. #logging diff --git a/commanderbot/ext/automod/actions/add_reactions.py b/commanderbot/ext/automod/actions/add_reactions.py index c88f452..5f782c8 100644 --- a/commanderbot/ext/automod/actions/add_reactions.py +++ b/commanderbot/ext/automod/actions/add_reactions.py @@ -2,7 +2,7 @@ from typing import Any, Tuple from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -18,7 +18,7 @@ class AddReactions(ActionBase): reactions: Tuple[str] - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if message := event.message: for reaction in self.reactions: await message.add_reaction(reaction) diff --git a/commanderbot/ext/automod/actions/add_roles_to_actor.py b/commanderbot/ext/automod/actions/add_roles_to_actor.py index c7608d2..c323af3 100644 --- a/commanderbot/ext/automod/actions/add_roles_to_actor.py +++ b/commanderbot/ext/automod/actions/add_roles_to_actor.py @@ -7,7 +7,7 @@ from commanderbot.ext.automod.actions.abc.add_roles_to_target_base import ( AddRolesToTargetBase, ) -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -23,7 +23,7 @@ class AddRolesToActor(AddRolesToTargetBase): The reason why roles were added, if any. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.actor diff --git a/commanderbot/ext/automod/actions/add_roles_to_author.py b/commanderbot/ext/automod/actions/add_roles_to_author.py index 6ef2c88..d74f46c 100644 --- a/commanderbot/ext/automod/actions/add_roles_to_author.py +++ b/commanderbot/ext/automod/actions/add_roles_to_author.py @@ -7,7 +7,7 @@ from commanderbot.ext.automod.actions.abc.add_roles_to_target_base import ( AddRolesToTargetBase, ) -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -23,7 +23,7 @@ class AddRolesToAuthor(AddRolesToTargetBase): The reason why roles were added, if any. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.author diff --git a/commanderbot/ext/automod/actions/add_to_bucket.py b/commanderbot/ext/automod/actions/add_to_bucket.py index decd4ce..b2bd447 100644 --- a/commanderbot/ext/automod/actions/add_to_bucket.py +++ b/commanderbot/ext/automod/actions/add_to_bucket.py @@ -2,8 +2,8 @@ from typing import Any, Dict, Optional from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.bucket import BucketRef +from commanderbot.ext.automod.event import Event @dataclass @@ -27,7 +27,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: bucket=bucket, ) - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): # Resolve the bucket and add the event to it. bucket = await self.bucket.resolve(event) await bucket.add(event) diff --git a/commanderbot/ext/automod/actions/add_users_to_thread.py b/commanderbot/ext/automod/actions/add_users_to_thread.py index f21e26b..9100ff4 100644 --- a/commanderbot/ext/automod/actions/add_users_to_thread.py +++ b/commanderbot/ext/automod/actions/add_users_to_thread.py @@ -4,7 +4,7 @@ from discord import Thread from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.lib import RoleID, UserID @@ -24,7 +24,7 @@ class AddUsersToThread(ActionBase): users: Tuple[UserID] = field(default_factory=lambda: tuple()) roles: Tuple[RoleID] = field(default_factory=lambda: tuple()) - async def try_add_user(self, event: AutomodEvent, thread: Thread, user_id: UserID): + async def try_add_user(self, event: Event, thread: Thread, user_id: UserID): try: guild = thread.guild assert guild is not None @@ -34,7 +34,7 @@ async def try_add_user(self, event: AutomodEvent, thread: Thread, user_id: UserI except: event.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, event: Event, thread: Thread, role_id: RoleID): try: guild = thread.guild assert guild is not None @@ -45,7 +45,7 @@ async def try_add_role(self, event: AutomodEvent, thread: Thread, role_id: RoleI except: event.log.exception(f"Failed to add role {role_id} to thread {thread.id}") - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if thread := event.thread: for user_id in self.users: await self.try_add_user(event, thread, user_id) diff --git a/commanderbot/ext/automod/actions/delete_message.py b/commanderbot/ext/automod/actions/delete_message.py index 3586bd0..8c01837 100644 --- a/commanderbot/ext/automod/actions/delete_message.py +++ b/commanderbot/ext/automod/actions/delete_message.py @@ -2,7 +2,7 @@ from typing import Any from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -11,7 +11,7 @@ class DeleteMessage(ActionBase): Delete the message in context. """ - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if message := event.message: await message.delete() diff --git a/commanderbot/ext/automod/actions/edit_thread.py b/commanderbot/ext/automod/actions/edit_thread.py index 97dd441..636c11a 100644 --- a/commanderbot/ext/automod/actions/edit_thread.py +++ b/commanderbot/ext/automod/actions/edit_thread.py @@ -4,7 +4,7 @@ from discord import Thread from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.lib.utils import dict_without_nones @@ -35,7 +35,7 @@ class EditThread(ActionBase): slowmode_delay: Optional[int] = None auto_archive_duration: Optional[int] = None - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): thread = event.channel if not isinstance(thread, Thread): return diff --git a/commanderbot/ext/automod/actions/join_thread.py b/commanderbot/ext/automod/actions/join_thread.py index 10b8b98..326884c 100644 --- a/commanderbot/ext/automod/actions/join_thread.py +++ b/commanderbot/ext/automod/actions/join_thread.py @@ -2,7 +2,7 @@ from typing import Any from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -15,7 +15,7 @@ class JoinThread(ActionBase): first message may be duplicated if the bot is not listed as a member of it. """ - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if thread := event.thread: await thread.join() diff --git a/commanderbot/ext/automod/actions/log_message.py b/commanderbot/ext/automod/actions/log_message.py index ae58c45..5d08484 100644 --- a/commanderbot/ext/automod/actions/log_message.py +++ b/commanderbot/ext/automod/actions/log_message.py @@ -5,7 +5,7 @@ from discord.abc import Messageable from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.lib import AllowedMentions, ChannelID, ValueFormatter from commanderbot.lib.utils import color_from_field_optional, message_to_file @@ -53,14 +53,14 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: allowed_mentions=allowed_mentions, ) - async def resolve_channel(self, event: AutomodEvent) -> Optional[Messageable]: + async def resolve_channel(self, event: Event) -> Optional[Messageable]: if self.channel is not None: channel = event.bot.get_channel(self.channel) assert isinstance(channel, Messageable) return channel return event.channel - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if channel := await self.resolve_channel(event): parts = [] if self.emoji: diff --git a/commanderbot/ext/automod/actions/remove_all_reactions.py b/commanderbot/ext/automod/actions/remove_all_reactions.py index db0d83e..a9b6056 100644 --- a/commanderbot/ext/automod/actions/remove_all_reactions.py +++ b/commanderbot/ext/automod/actions/remove_all_reactions.py @@ -2,7 +2,7 @@ from typing import Any from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -11,7 +11,7 @@ class RemoveAllReactions(ActionBase): Remove all reactions from the message in context. """ - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if message := 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 71362c1..6c3af54 100644 --- a/commanderbot/ext/automod/actions/remove_own_reactions.py +++ b/commanderbot/ext/automod/actions/remove_own_reactions.py @@ -4,7 +4,7 @@ from discord import User from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -20,7 +20,7 @@ class RemoveOwnReactions(ActionBase): reactions: Tuple[str] - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if message := event.message: bot_user = event.bot.user assert isinstance(bot_user, User) diff --git a/commanderbot/ext/automod/actions/remove_reactions.py b/commanderbot/ext/automod/actions/remove_reactions.py index 55ae29a..37b1da6 100644 --- a/commanderbot/ext/automod/actions/remove_reactions.py +++ b/commanderbot/ext/automod/actions/remove_reactions.py @@ -2,7 +2,7 @@ from typing import Any, Tuple from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -18,7 +18,7 @@ class RemoveReactions(ActionBase): reactions: Tuple[str] - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if message := event.message: for reaction in self.reactions: await message.clear_reaction(reaction) diff --git a/commanderbot/ext/automod/actions/remove_roles_from_actor.py b/commanderbot/ext/automod/actions/remove_roles_from_actor.py index e326c02..9bb87f8 100644 --- a/commanderbot/ext/automod/actions/remove_roles_from_actor.py +++ b/commanderbot/ext/automod/actions/remove_roles_from_actor.py @@ -7,7 +7,7 @@ from commanderbot.ext.automod.actions.abc.remove_roles_from_target_base import ( RemoveRolesFromTargetBase, ) -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -21,7 +21,7 @@ class RemoveRolesFromActor(RemoveRolesFromTargetBase): The roles to remove. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.actor diff --git a/commanderbot/ext/automod/actions/remove_roles_from_author.py b/commanderbot/ext/automod/actions/remove_roles_from_author.py index ac60034..d2548df 100644 --- a/commanderbot/ext/automod/actions/remove_roles_from_author.py +++ b/commanderbot/ext/automod/actions/remove_roles_from_author.py @@ -7,7 +7,7 @@ from commanderbot.ext.automod.actions.abc.remove_roles_from_target_base import ( RemoveRolesFromTargetBase, ) -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -21,7 +21,7 @@ class RemoveRolesFromAuthor(RemoveRolesFromTargetBase): The roles to remove. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.author diff --git a/commanderbot/ext/automod/actions/reply_to_message.py b/commanderbot/ext/automod/actions/reply_to_message.py index 399aabd..88c122a 100644 --- a/commanderbot/ext/automod/actions/reply_to_message.py +++ b/commanderbot/ext/automod/actions/reply_to_message.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.lib import AllowedMentions @@ -31,7 +31,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: allowed_mentions=allowed_mentions, ) - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if message := event.message: content = event.format_content(self.content) allowed_mentions = self.allowed_mentions or AllowedMentions.not_everyone() diff --git a/commanderbot/ext/automod/actions/send_message.py b/commanderbot/ext/automod/actions/send_message.py index 8e722c7..ed88b8f 100644 --- a/commanderbot/ext/automod/actions/send_message.py +++ b/commanderbot/ext/automod/actions/send_message.py @@ -5,7 +5,7 @@ from discord.abc import Messageable from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.lib import AllowedMentions, ChannelID from commanderbot.lib.utils import timedelta_from_field_optional @@ -43,14 +43,14 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: delete_after=delete_after, ) - async def resolve_channel(self, event: AutomodEvent) -> Optional[Messageable]: + async def resolve_channel(self, event: Event) -> Optional[Messageable]: if self.channel is not None: channel = event.bot.get_channel(self.channel) assert isinstance(channel, Messageable) return channel return event.channel - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): if channel := await self.resolve_channel(event): content = event.format_content(self.content) allowed_mentions = self.allowed_mentions or AllowedMentions.not_everyone() diff --git a/commanderbot/ext/automod/actions/throw_error.py b/commanderbot/ext/automod/actions/throw_error.py index ac4eb64..4e2f9db 100644 --- a/commanderbot/ext/automod/actions/throw_error.py +++ b/commanderbot/ext/automod/actions/throw_error.py @@ -2,7 +2,7 @@ from typing import Any from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event @dataclass @@ -20,7 +20,7 @@ class ThrowError(ActionBase): error: str - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): raise Exception(self.error) diff --git a/commanderbot/ext/automod/actions/wait.py b/commanderbot/ext/automod/actions/wait.py index d166dd5..1cae4df 100644 --- a/commanderbot/ext/automod/actions/wait.py +++ b/commanderbot/ext/automod/actions/wait.py @@ -4,7 +4,7 @@ from typing import Any, Dict, Optional from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.lib.utils import timedelta_from_field_optional @@ -27,7 +27,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: delay = timedelta_from_field_optional(data, "delay") return dict(delay=delay) - async def apply(self, event: AutomodEvent): + async def apply(self, event: Event): await asyncio.sleep(self.delay.total_seconds()) diff --git a/commanderbot/ext/automod/automod_data.py b/commanderbot/ext/automod/automod_data.py index 5153338..9c34a6a 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -6,9 +6,9 @@ from discord import Guild from commanderbot.ext.automod.action import Action, ActionCollection -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.bucket import Bucket, BucketCollection from commanderbot.ext.automod.condition import Condition, ConditionCollection +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.node import Node, NodeCollection from commanderbot.ext.automod.rule import Rule, RuleCollection from commanderbot.ext.automod.trigger import Trigger, TriggerCollection @@ -256,9 +256,7 @@ async def modify_node( # @@ RULES # @implements AutomodStore - async def rules_for_event( - self, guild: Guild, event: AutomodEvent - ) -> AsyncIterable[Rule]: + async def rules_for_event(self, guild: Guild, event: Event) -> AsyncIterable[Rule]: async for rule in self.guilds[guild.id].rules.for_event(event): yield rule diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index 1e201fa..041989e 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -22,8 +22,8 @@ from yaml import YAMLError from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.automod_store import AutomodStore +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.node.node_kind import NodeKind from commanderbot.ext.automod.rule import Rule from commanderbot.lib import ( @@ -333,14 +333,14 @@ async def _handle_rule_error(self, rule: Rule, error: Exception): # If something went wrong here, print another exception to the console. self.log.exception("Failed to log message to error channel") - async def _run_rule(self, event: AutomodEvent, rule: Rule): + async def _run_rule(self, event: Event, rule: Rule): 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 dispatch_event(self, event: AutomodEvent): + async def dispatch_event(self, event: Event): # 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 async_expand(self.store.rules_for_event(self.guild, event)) diff --git a/commanderbot/ext/automod/automod_json_store.py b/commanderbot/ext/automod/automod_json_store.py index e0120b9..a16e7aa 100644 --- a/commanderbot/ext/automod/automod_json_store.py +++ b/commanderbot/ext/automod/automod_json_store.py @@ -4,8 +4,8 @@ from discord import Guild from commanderbot.ext.automod.automod_data import AutomodData -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.bucket import Bucket +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.node import Node from commanderbot.ext.automod.rule import Rule from commanderbot.lib import CogStore, JsonFileDatabaseAdapter, LogOptions, RoleSet @@ -135,9 +135,7 @@ async def modify_node( # @@ RULES # @implements AutomodStore - async def rules_for_event( - self, guild: Guild, event: AutomodEvent - ) -> AsyncIterable[Rule]: + async def rules_for_event(self, guild: Guild, event: Event) -> AsyncIterable[Rule]: cache = await self.db.get_cache() async for rule in cache.rules_for_event(guild, event): yield rule diff --git a/commanderbot/ext/automod/automod_store.py b/commanderbot/ext/automod/automod_store.py index 208c0e5..d586344 100644 --- a/commanderbot/ext/automod/automod_store.py +++ b/commanderbot/ext/automod/automod_store.py @@ -2,7 +2,7 @@ from discord import Guild -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.node import Node from commanderbot.ext.automod.rule import Rule from commanderbot.lib import LogOptions, RoleSet @@ -82,7 +82,7 @@ async def modify_node( # @@ RULES - def rules_for_event(self, guild: Guild, event: AutomodEvent) -> AsyncIterable[Rule]: + def rules_for_event(self, guild: Guild, event: Event) -> AsyncIterable[Rule]: ... async def increment_rule_hits(self, guild: Guild, name: str) -> Rule: diff --git a/commanderbot/ext/automod/bucket/bucket.py b/commanderbot/ext/automod/bucket/bucket.py index 7117a93..0751cb3 100644 --- a/commanderbot/ext/automod/bucket/bucket.py +++ b/commanderbot/ext/automod/bucket/bucket.py @@ -1,7 +1,7 @@ from typing import Protocol -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.component import Component +from commanderbot.ext.automod.event import Event __all__ = ("Bucket",) @@ -9,5 +9,5 @@ class Bucket(Component, Protocol): """A bucket can be used to carry state through multiple events.""" - async def add(self, event: AutomodEvent): + async def add(self, event: Event): """Add the event to the bucket.""" diff --git a/commanderbot/ext/automod/bucket/bucket_base.py b/commanderbot/ext/automod/bucket/bucket_base.py index 6d0c75b..ad14426 100644 --- a/commanderbot/ext/automod/bucket/bucket_base.py +++ b/commanderbot/ext/automod/bucket/bucket_base.py @@ -2,8 +2,8 @@ from typing import ClassVar from commanderbot.ext.automod import buckets -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.component import ComponentBase +from commanderbot.ext.automod.event import Event __all__ = ("BucketBase",) @@ -19,5 +19,5 @@ class BucketBase(ComponentBase): module_function_name: ClassVar[str] = "create_bucket" # @implements Bucket - async def add(self, event: AutomodEvent): + async def add(self, event: Event): """Override this to modify the bucket according to the event.""" diff --git a/commanderbot/ext/automod/buckets/message_frequency.py b/commanderbot/ext/automod/buckets/message_frequency.py index a9e3d74..60d26f4 100644 --- a/commanderbot/ext/automod/buckets/message_frequency.py +++ b/commanderbot/ext/automod/buckets/message_frequency.py @@ -8,8 +8,8 @@ from discord import Member, Message, User from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.bucket import Bucket, BucketBase +from commanderbot.ext.automod.event import Event from commanderbot.lib import ChannelID, UserID from commanderbot.lib.utils import timedelta_from_field_optional @@ -125,7 +125,7 @@ def clean_buckets(self): # IMPL clean buckets to free memory ... - async def add(self, event: AutomodEvent): + async def add(self, event: Event): # Short-circuit if the event does not contain a message. message = event.message if not message: diff --git a/commanderbot/ext/automod/condition/condition.py b/commanderbot/ext/automod/condition/condition.py index acb70a9..126471f 100644 --- a/commanderbot/ext/automod/condition/condition.py +++ b/commanderbot/ext/automod/condition/condition.py @@ -1,7 +1,7 @@ from typing import Protocol -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.component import Component +from commanderbot.ext.automod.event import Event __all__ = ("Condition",) @@ -9,5 +9,5 @@ class Condition(Component, Protocol): """A condition is a predicate that must pass in order to run actions.""" - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: """Check whether the condition passes.""" diff --git a/commanderbot/ext/automod/condition/condition_base.py b/commanderbot/ext/automod/condition/condition_base.py index b484a0d..8090693 100644 --- a/commanderbot/ext/automod/condition/condition_base.py +++ b/commanderbot/ext/automod/condition/condition_base.py @@ -2,8 +2,8 @@ from typing import ClassVar from commanderbot.ext.automod import conditions -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.component import ComponentBase +from commanderbot.ext.automod.event import Event __all__ = ("ConditionBase",) @@ -19,6 +19,6 @@ class ConditionBase(ComponentBase): module_function_name: ClassVar[str] = "create_condition" # @implements Component - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: """Override this to check whether the condition passes.""" return False diff --git a/commanderbot/ext/automod/conditions/abc/target_account_age_base.py b/commanderbot/ext/automod/conditions/abc/target_account_age_base.py index 7c2765e..50e88e5 100644 --- a/commanderbot/ext/automod/conditions/abc/target_account_age_base.py +++ b/commanderbot/ext/automod/conditions/abc/target_account_age_base.py @@ -4,8 +4,8 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import ConditionBase +from commanderbot.ext.automod.event import Event from commanderbot.lib.utils import timedelta_from_field_optional, utcnow_aware @@ -24,10 +24,10 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: less_than=less_than, ) - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: raise NotImplementedError() - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: member = self.get_target(event) if member is None: return False 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 index ece3c4b..9afcb7c 100644 --- a/commanderbot/ext/automod/conditions/abc/target_is_not_bot_base.py +++ b/commanderbot/ext/automod/conditions/abc/target_is_not_bot_base.py @@ -3,18 +3,18 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import ConditionBase +from commanderbot.ext.automod.event import Event ST = TypeVar("ST") @dataclass class TargetIsNotBotBase(ConditionBase): - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: raise NotImplementedError() - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> 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_base.py b/commanderbot/ext/automod/conditions/abc/target_is_not_self_base.py index 66fde3b..6a4f7e0 100644 --- a/commanderbot/ext/automod/conditions/abc/target_is_not_self_base.py +++ b/commanderbot/ext/automod/conditions/abc/target_is_not_self_base.py @@ -3,18 +3,18 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import ConditionBase +from commanderbot.ext.automod.event import Event ST = TypeVar("ST") @dataclass class TargetIsNotSelfBase(ConditionBase): - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: raise NotImplementedError() - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> 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_roles_base.py b/commanderbot/ext/automod/conditions/abc/target_roles_base.py index 7f242e5..ed57659 100644 --- a/commanderbot/ext/automod/conditions/abc/target_roles_base.py +++ b/commanderbot/ext/automod/conditions/abc/target_roles_base.py @@ -3,8 +3,8 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import ConditionBase +from commanderbot.ext.automod.event import Event from commanderbot.lib.guards.roles_guard import RolesGuard @@ -20,10 +20,10 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: roles=roles, ) - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: raise NotImplementedError() - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> 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 2d472f5..ee77062 100644 --- a/commanderbot/ext/automod/conditions/actor_account_age.py +++ b/commanderbot/ext/automod/conditions/actor_account_age.py @@ -3,11 +3,11 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_account_age_base import ( TargetAccountAgeBase, ) +from commanderbot.ext.automod.event import Event ST = TypeVar("ST") @@ -25,7 +25,7 @@ class ActorAccountAge(TargetAccountAgeBase): The upper bound to check against, if any. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.actor diff --git a/commanderbot/ext/automod/conditions/actor_is_not_bot.py b/commanderbot/ext/automod/conditions/actor_is_not_bot.py index cab90cb..a7ebe86 100644 --- a/commanderbot/ext/automod/conditions/actor_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/actor_is_not_bot.py @@ -3,11 +3,11 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_is_not_bot_base import ( TargetIsNotBotBase, ) +from commanderbot.ext.automod.event import Event ST = TypeVar("ST") @@ -18,7 +18,7 @@ class ActorIsNotBot(TargetIsNotBotBase): Check if the actor in context is not a bot. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.actor diff --git a/commanderbot/ext/automod/conditions/actor_is_not_self.py b/commanderbot/ext/automod/conditions/actor_is_not_self.py index c1decaa..d9b3d4d 100644 --- a/commanderbot/ext/automod/conditions/actor_is_not_self.py +++ b/commanderbot/ext/automod/conditions/actor_is_not_self.py @@ -3,11 +3,11 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_is_not_self_base import ( TargetIsNotSelfBase, ) +from commanderbot.ext.automod.event import Event ST = TypeVar("ST") @@ -18,7 +18,7 @@ class ActorIsNotSelf(TargetIsNotSelfBase): Check if the actor in context is not the bot itself. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.actor diff --git a/commanderbot/ext/automod/conditions/actor_roles.py b/commanderbot/ext/automod/conditions/actor_roles.py index e1f3f3f..dac083f 100644 --- a/commanderbot/ext/automod/conditions/actor_roles.py +++ b/commanderbot/ext/automod/conditions/actor_roles.py @@ -3,9 +3,9 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_roles_base import TargetRolesBase +from commanderbot.ext.automod.event import Event ST = TypeVar("ST") @@ -21,7 +21,7 @@ class ActorRoles(TargetRolesBase): The roles to match against. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.actor diff --git a/commanderbot/ext/automod/conditions/all_of.py b/commanderbot/ext/automod/conditions/all_of.py index 0f56d4c..6d12984 100644 --- a/commanderbot/ext/automod/conditions/all_of.py +++ b/commanderbot/ext/automod/conditions/all_of.py @@ -1,12 +1,12 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import ( Condition, ConditionBase, ConditionCollection, ) +from commanderbot.ext.automod.event import Event @dataclass @@ -33,7 +33,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: conditions=conditions, ) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: for condition in self.conditions: if not await condition.check(event): return False diff --git a/commanderbot/ext/automod/conditions/any_of.py b/commanderbot/ext/automod/conditions/any_of.py index c169d74..c81c444 100644 --- a/commanderbot/ext/automod/conditions/any_of.py +++ b/commanderbot/ext/automod/conditions/any_of.py @@ -1,12 +1,12 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import ( Condition, ConditionBase, ConditionCollection, ) +from commanderbot.ext.automod.event import Event @dataclass @@ -34,7 +34,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: conditions=conditions, ) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: remainder = self.count or 1 for condition in self.conditions: if await condition.check(event): diff --git a/commanderbot/ext/automod/conditions/author_account_age.py b/commanderbot/ext/automod/conditions/author_account_age.py index a16b69b..7a137c4 100644 --- a/commanderbot/ext/automod/conditions/author_account_age.py +++ b/commanderbot/ext/automod/conditions/author_account_age.py @@ -3,11 +3,11 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_account_age_base import ( TargetAccountAgeBase, ) +from commanderbot.ext.automod.event import Event ST = TypeVar("ST") @@ -25,7 +25,7 @@ class AuthorAccountAge(TargetAccountAgeBase): The upper bound to check against, if any. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.author diff --git a/commanderbot/ext/automod/conditions/author_is_not_bot.py b/commanderbot/ext/automod/conditions/author_is_not_bot.py index 866972e..1b07147 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_bot.py +++ b/commanderbot/ext/automod/conditions/author_is_not_bot.py @@ -3,11 +3,11 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_is_not_bot_base import ( TargetIsNotBotBase, ) +from commanderbot.ext.automod.event import Event ST = TypeVar("ST") @@ -18,7 +18,7 @@ class AuthorIsNotBot(TargetIsNotBotBase): Check if the author in context is not a bot. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.author diff --git a/commanderbot/ext/automod/conditions/author_is_not_self.py b/commanderbot/ext/automod/conditions/author_is_not_self.py index 181c8cb..a1edb29 100644 --- a/commanderbot/ext/automod/conditions/author_is_not_self.py +++ b/commanderbot/ext/automod/conditions/author_is_not_self.py @@ -3,11 +3,11 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_is_not_self_base import ( TargetIsNotSelfBase, ) +from commanderbot.ext.automod.event import Event ST = TypeVar("ST") @@ -18,7 +18,7 @@ class AuthorIsNotSelf(TargetIsNotSelfBase): Check if the author in context is not the bot itself. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.author diff --git a/commanderbot/ext/automod/conditions/author_roles.py b/commanderbot/ext/automod/conditions/author_roles.py index 49cc9e2..dd26238 100644 --- a/commanderbot/ext/automod/conditions/author_roles.py +++ b/commanderbot/ext/automod/conditions/author_roles.py @@ -3,9 +3,9 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition from commanderbot.ext.automod.conditions.abc.target_roles_base import TargetRolesBase +from commanderbot.ext.automod.event import Event ST = TypeVar("ST") @@ -21,7 +21,7 @@ class AuthorRoles(TargetRolesBase): The roles to match against. """ - def get_target(self, event: AutomodEvent) -> Optional[Member]: + def get_target(self, event: Event) -> Optional[Member]: return event.author diff --git a/commanderbot/ext/automod/conditions/message_content_contains.py b/commanderbot/ext/automod/conditions/message_content_contains.py index c19a035..a448171 100644 --- a/commanderbot/ext/automod/conditions/message_content_contains.py +++ b/commanderbot/ext/automod/conditions/message_content_contains.py @@ -2,8 +2,8 @@ from dataclasses import dataclass from typing import Any, Dict, Optional, Tuple -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.ext.automod.event import Event DEFAULT_NORMALIZATION_FORM = "NFKD" @@ -52,7 +52,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: contains=contains, ) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: message = event.message # Short-circuit if there's no message or the message is empty. if not (message and message.content): diff --git a/commanderbot/ext/automod/conditions/message_content_matches.py b/commanderbot/ext/automod/conditions/message_content_matches.py index 2e3aadb..adf1abd 100644 --- a/commanderbot/ext/automod/conditions/message_content_matches.py +++ b/commanderbot/ext/automod/conditions/message_content_matches.py @@ -2,8 +2,8 @@ from dataclasses import dataclass from typing import Any, Dict, Optional, Tuple -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.ext.automod.event import Event from commanderbot.lib import PatternWrapper DEFAULT_NORMALIZATION_FORM = "NFKD" @@ -58,7 +58,7 @@ def is_match(self, pattern: PatternWrapper, content: str) -> bool: match = pattern.match(content) return bool(match) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: message = event.message # Short-circuit if there's no message or the message is empty. if not (message and message.content): diff --git a/commanderbot/ext/automod/conditions/message_has_attachments.py b/commanderbot/ext/automod/conditions/message_has_attachments.py index 4ae63ad..8f10f6b 100644 --- a/commanderbot/ext/automod/conditions/message_has_attachments.py +++ b/commanderbot/ext/automod/conditions/message_has_attachments.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.ext.automod.event import Event from commanderbot.lib import IntegerRange @@ -27,7 +27,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: count=count, ) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: message = event.message if message is None: return False diff --git a/commanderbot/ext/automod/conditions/message_has_embeds.py b/commanderbot/ext/automod/conditions/message_has_embeds.py index 7bc75cd..82ff4be 100644 --- a/commanderbot/ext/automod/conditions/message_has_embeds.py +++ b/commanderbot/ext/automod/conditions/message_has_embeds.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.ext.automod.event import Event from commanderbot.lib import IntegerRange @@ -27,7 +27,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: count=count, ) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: message = event.message if message is None: return False diff --git a/commanderbot/ext/automod/conditions/message_has_links.py b/commanderbot/ext/automod/conditions/message_has_links.py index 0e76c72..34d4bee 100644 --- a/commanderbot/ext/automod/conditions/message_has_links.py +++ b/commanderbot/ext/automod/conditions/message_has_links.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.ext.automod.event import Event from commanderbot.lib import IntegerRange @@ -30,7 +30,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: count=count, ) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: message = event.message if (message is None) or not message.content: return False diff --git a/commanderbot/ext/automod/conditions/message_mentions_roles.py b/commanderbot/ext/automod/conditions/message_mentions_roles.py index 594923d..309a3e8 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_roles.py +++ b/commanderbot/ext/automod/conditions/message_mentions_roles.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.ext.automod.event import Event from commanderbot.lib import RolesGuard @@ -27,7 +27,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: roles=roles, ) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: message = event.message # Short-circuit if there's no message or the message is empty. if not (message and message.content): diff --git a/commanderbot/ext/automod/conditions/message_mentions_users.py b/commanderbot/ext/automod/conditions/message_mentions_users.py index 300c1ae..b1319f5 100644 --- a/commanderbot/ext/automod/conditions/message_mentions_users.py +++ b/commanderbot/ext/automod/conditions/message_mentions_users.py @@ -1,15 +1,15 @@ from dataclasses import dataclass from typing import Any -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.ext.automod.event import Event @dataclass class MessageMentionsUsers(ConditionBase): """Check if the message contains user mentions.""" - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: message = event.message # Short-circuit if there's no message or the message is empty. if not (message and message.content): diff --git a/commanderbot/ext/automod/conditions/none_of.py b/commanderbot/ext/automod/conditions/none_of.py index 344aa93..a8de68b 100644 --- a/commanderbot/ext/automod/conditions/none_of.py +++ b/commanderbot/ext/automod/conditions/none_of.py @@ -1,12 +1,12 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import ( Condition, ConditionBase, ConditionCollection, ) +from commanderbot.ext.automod.event import Event @dataclass @@ -30,7 +30,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: conditions=conditions, ) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: for condition in self.conditions: if await condition.check(event): return False diff --git a/commanderbot/ext/automod/conditions/not.py b/commanderbot/ext/automod/conditions/not.py index 4eda564..b66caf8 100644 --- a/commanderbot/ext/automod/conditions/not.py +++ b/commanderbot/ext/automod/conditions/not.py @@ -1,12 +1,12 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import ( Condition, ConditionBase, ConditionCollection, ) +from commanderbot.ext.automod.event import Event @dataclass @@ -30,7 +30,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: conditions=conditions, ) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: for condition in self.conditions: if not await condition.check(event): return True diff --git a/commanderbot/ext/automod/conditions/thread_auto_archive_duration.py b/commanderbot/ext/automod/conditions/thread_auto_archive_duration.py index e2f66a0..67f76e0 100644 --- a/commanderbot/ext/automod/conditions/thread_auto_archive_duration.py +++ b/commanderbot/ext/automod/conditions/thread_auto_archive_duration.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.ext.automod.event import Event from commanderbot.lib.integer_range import IntegerRange @@ -27,7 +27,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: auto_archive_duration=auto_archive_duration, ) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: if thread := event.thread: return self.auto_archive_duration.includes(thread.auto_archive_duration) return False diff --git a/commanderbot/ext/automod/conditions/throw_error.py b/commanderbot/ext/automod/conditions/throw_error.py index 6dcaa7d..7d2f9ca 100644 --- a/commanderbot/ext/automod/conditions/throw_error.py +++ b/commanderbot/ext/automod/conditions/throw_error.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import Any -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.ext.automod.event import Event @dataclass @@ -20,7 +20,7 @@ class ThrowError(ConditionBase): error: str - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: raise Exception(self.error) diff --git a/commanderbot/ext/automod/conditions/wait.py b/commanderbot/ext/automod/conditions/wait.py index 9b038f0..76b2e8a 100644 --- a/commanderbot/ext/automod/conditions/wait.py +++ b/commanderbot/ext/automod/conditions/wait.py @@ -3,8 +3,8 @@ from datetime import timedelta from typing import Any, Dict, Optional -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import Condition, ConditionBase +from commanderbot.ext.automod.event import Event from commanderbot.lib.utils import timedelta_from_field_optional @@ -27,7 +27,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: delay = timedelta_from_field_optional(data, "delay") return dict(delay=delay) - async def check(self, event: AutomodEvent) -> bool: + async def check(self, event: Event) -> bool: await asyncio.sleep(self.delay.total_seconds()) return True diff --git a/commanderbot/ext/automod/event/__init__.py b/commanderbot/ext/automod/event/__init__.py new file mode 100644 index 0000000..43603c5 --- /dev/null +++ b/commanderbot/ext/automod/event/__init__.py @@ -0,0 +1,2 @@ +from .event import * +from .event_base import * diff --git a/commanderbot/ext/automod/event/event.py b/commanderbot/ext/automod/event/event.py new file mode 100644 index 0000000..3793b6f --- /dev/null +++ b/commanderbot/ext/automod/event/event.py @@ -0,0 +1,60 @@ +from logging import Logger +from typing import Any, Dict, Optional, Protocol + +from discord import Member, TextChannel, Thread, User +from discord.ext.commands import Bot + +from commanderbot.ext.automod.event.event_state import EventState +from commanderbot.lib import TextMessage, TextReaction + +__all__ = ("Event",) + + +class Event(Protocol): + state: EventState + 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.""" diff --git a/commanderbot/ext/automod/automod_event.py b/commanderbot/ext/automod/event/event_base.py similarity index 78% rename from commanderbot/ext/automod/automod_event.py rename to commanderbot/ext/automod/event/event_base.py index e19fe20..5eadae5 100644 --- a/commanderbot/ext/automod/automod_event.py +++ b/commanderbot/ext/automod/event/event_base.py @@ -1,69 +1,22 @@ from dataclasses import dataclass, field -from logging import Logger, getLogger -from typing import Any, ClassVar, Dict, Iterable, Optional, Protocol, Tuple, Type, cast +from logging import Logger +from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple, Type, cast from discord import Member, TextChannel, Thread, User from discord.ext.commands import Bot -from commanderbot.ext.automod.automod_event_state import AutomodEventState +from commanderbot.ext.automod.event.event_state import EventState from commanderbot.lib import ShallowFormatter, TextMessage, TextReaction, ValueFormatter from commanderbot.lib.utils import yield_member_date_fields - -class AutomodEvent(Protocol): - state: AutomodEventState - 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.""" +__all__ = ("EventBase",) -# @implements AutomodEvent +# @implements Event @dataclass -class AutomodEventBase: - state: AutomodEventState +class EventBase: + # @implements Event + state: EventState bot: Bot log: Logger @@ -81,50 +34,62 @@ def __init__( self.log = log self._metadata = {} + # @implements Event @property def channel(self) -> Optional[TextChannel | Thread]: return None + # @implements Event @property def thread(self) -> Optional[Thread]: if isinstance(self.channel, Thread): return self.channel + # @implements Event @property def message(self) -> Optional[TextMessage]: return None + # @implements Event @property def reaction(self) -> Optional[TextReaction]: return None + # @implements Event @property def author(self) -> Optional[Member]: return None + # @implements Event @property def actor(self) -> Optional[Member]: return None + # @implements Event @property def member(self) -> Optional[Member]: return None + # @implements Event @property def user(self) -> Optional[User]: return cast(User, self.member) + # @implements Event def set_metadata(self, key: str, value: Any): self._metadata[key] = value + # @implements Event def remove_metadata(self, key: str): del self._metadata[key] + # @implements Event def get_fields(self, unsafe: bool = False) -> Dict[str, Any]: if unsafe: return self._get_fields_unsafe() return self._get_fields_safe() + # @implements Event 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 diff --git a/commanderbot/ext/automod/automod_event_state.py b/commanderbot/ext/automod/event/event_state.py similarity index 50% rename from commanderbot/ext/automod/automod_event_state.py rename to commanderbot/ext/automod/event/event_state.py index eb75447..42c06dd 100644 --- a/commanderbot/ext/automod/automod_event_state.py +++ b/commanderbot/ext/automod/event/event_state.py @@ -1,3 +1,3 @@ from typing import Any, TypeAlias -AutomodEventState: TypeAlias = Any +EventState: TypeAlias = Any diff --git a/commanderbot/ext/automod/automod_event_state.pyi b/commanderbot/ext/automod/event/event_state.pyi similarity index 68% rename from commanderbot/ext/automod/automod_event_state.pyi rename to commanderbot/ext/automod/event/event_state.pyi index 114f79f..f245b0b 100644 --- a/commanderbot/ext/automod/automod_event_state.pyi +++ b/commanderbot/ext/automod/event/event_state.pyi @@ -2,4 +2,4 @@ from typing import TypeAlias from commanderbot.ext.automod.automod_guild_state import AutomodGuildState -AutomodEventState: TypeAlias = AutomodGuildState +EventState: TypeAlias = AutomodGuildState diff --git a/commanderbot/ext/automod/events/guild_channel_created.py b/commanderbot/ext/automod/events/guild_channel_created.py index 1bf167e..7f350d4 100644 --- a/commanderbot/ext/automod/events/guild_channel_created.py +++ b/commanderbot/ext/automod/events/guild_channel_created.py @@ -2,13 +2,13 @@ from discord import TextChannel, Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("GuildChannelCreated",) @dataclass -class GuildChannelCreated(AutomodEventBase): +class GuildChannelCreated(EventBase): _channel: TextChannel | Thread @property diff --git a/commanderbot/ext/automod/events/guild_channel_deleted.py b/commanderbot/ext/automod/events/guild_channel_deleted.py index 2f89f93..a8a164d 100644 --- a/commanderbot/ext/automod/events/guild_channel_deleted.py +++ b/commanderbot/ext/automod/events/guild_channel_deleted.py @@ -2,13 +2,13 @@ from discord import TextChannel, Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("GuildChannelDeleted",) @dataclass -class GuildChannelDeleted(AutomodEventBase): +class GuildChannelDeleted(EventBase): _channel: TextChannel | Thread @property diff --git a/commanderbot/ext/automod/events/guild_channel_updated.py b/commanderbot/ext/automod/events/guild_channel_updated.py index efa8fcf..ac4a548 100644 --- a/commanderbot/ext/automod/events/guild_channel_updated.py +++ b/commanderbot/ext/automod/events/guild_channel_updated.py @@ -2,13 +2,13 @@ from discord import TextChannel, Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("GuildChannelUpdated",) @dataclass -class GuildChannelUpdated(AutomodEventBase): +class GuildChannelUpdated(EventBase): _before: TextChannel | Thread _after: TextChannel | Thread diff --git a/commanderbot/ext/automod/events/member_joined.py b/commanderbot/ext/automod/events/member_joined.py index 1d5f810..3ea205f 100644 --- a/commanderbot/ext/automod/events/member_joined.py +++ b/commanderbot/ext/automod/events/member_joined.py @@ -2,13 +2,13 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("MemberJoined",) @dataclass -class MemberJoined(AutomodEventBase): +class MemberJoined(EventBase): _member: Member @property diff --git a/commanderbot/ext/automod/events/member_left.py b/commanderbot/ext/automod/events/member_left.py index 034350e..36dc070 100644 --- a/commanderbot/ext/automod/events/member_left.py +++ b/commanderbot/ext/automod/events/member_left.py @@ -2,13 +2,13 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("MemberLeft",) @dataclass -class MemberLeft(AutomodEventBase): +class MemberLeft(EventBase): _member: Member @property diff --git a/commanderbot/ext/automod/events/member_typing.py b/commanderbot/ext/automod/events/member_typing.py index be3423f..687c4a9 100644 --- a/commanderbot/ext/automod/events/member_typing.py +++ b/commanderbot/ext/automod/events/member_typing.py @@ -3,13 +3,13 @@ from discord import Member, TextChannel, Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("MemberTyping",) @dataclass -class MemberTyping(AutomodEventBase): +class MemberTyping(EventBase): _channel: TextChannel | Thread _member: Member _when: datetime diff --git a/commanderbot/ext/automod/events/member_updated.py b/commanderbot/ext/automod/events/member_updated.py index 4d71688..0dfc5f7 100644 --- a/commanderbot/ext/automod/events/member_updated.py +++ b/commanderbot/ext/automod/events/member_updated.py @@ -2,13 +2,13 @@ from discord import Member -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("MemberUpdated",) @dataclass -class MemberUpdated(AutomodEventBase): +class MemberUpdated(EventBase): _before: Member _after: Member diff --git a/commanderbot/ext/automod/events/message_deleted.py b/commanderbot/ext/automod/events/message_deleted.py index e09d3b5..e4785c3 100644 --- a/commanderbot/ext/automod/events/message_deleted.py +++ b/commanderbot/ext/automod/events/message_deleted.py @@ -2,14 +2,14 @@ from discord import Member, TextChannel, Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase from commanderbot.lib.types import TextMessage __all__ = ("MessageDeleted",) @dataclass -class MessageDeleted(AutomodEventBase): +class MessageDeleted(EventBase): _message: TextMessage @property diff --git a/commanderbot/ext/automod/events/message_edited.py b/commanderbot/ext/automod/events/message_edited.py index 2966822..ba36b8f 100644 --- a/commanderbot/ext/automod/events/message_edited.py +++ b/commanderbot/ext/automod/events/message_edited.py @@ -2,14 +2,14 @@ from discord import Member, TextChannel, Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase from commanderbot.lib.types import TextMessage __all__ = ("MessageEdited",) @dataclass -class MessageEdited(AutomodEventBase): +class MessageEdited(EventBase): _before: TextMessage _after: TextMessage diff --git a/commanderbot/ext/automod/events/message_frequency_changed.py b/commanderbot/ext/automod/events/message_frequency_changed.py index bb346ab..a98cae4 100644 --- a/commanderbot/ext/automod/events/message_frequency_changed.py +++ b/commanderbot/ext/automod/events/message_frequency_changed.py @@ -2,14 +2,14 @@ from discord import Member, TextChannel, Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase from commanderbot.lib.types import TextMessage __all__ = ("MessageFrequencyChanged",) @dataclass -class MessageFrequencyChanged(AutomodEventBase): +class MessageFrequencyChanged(EventBase): _message: TextMessage @property diff --git a/commanderbot/ext/automod/events/message_sent.py b/commanderbot/ext/automod/events/message_sent.py index d6e909d..da6be7e 100644 --- a/commanderbot/ext/automod/events/message_sent.py +++ b/commanderbot/ext/automod/events/message_sent.py @@ -2,14 +2,14 @@ from discord import Member, TextChannel, Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase from commanderbot.lib.types import TextMessage __all__ = ("MessageSent",) @dataclass -class MessageSent(AutomodEventBase): +class MessageSent(EventBase): _message: TextMessage @property diff --git a/commanderbot/ext/automod/events/raw_message_deleted.py b/commanderbot/ext/automod/events/raw_message_deleted.py index 3930100..8944177 100644 --- a/commanderbot/ext/automod/events/raw_message_deleted.py +++ b/commanderbot/ext/automod/events/raw_message_deleted.py @@ -2,11 +2,11 @@ from discord import RawMessageDeleteEvent -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("RawMessageDeleted",) @dataclass -class RawMessageDeleted(AutomodEventBase): +class RawMessageDeleted(EventBase): payload: RawMessageDeleteEvent diff --git a/commanderbot/ext/automod/events/raw_message_edited.py b/commanderbot/ext/automod/events/raw_message_edited.py index 47269f9..17d1ebd 100644 --- a/commanderbot/ext/automod/events/raw_message_edited.py +++ b/commanderbot/ext/automod/events/raw_message_edited.py @@ -2,11 +2,11 @@ from discord import RawMessageUpdateEvent -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("RawMessageEdited",) @dataclass -class RawMessageEdited(AutomodEventBase): +class RawMessageEdited(EventBase): payload: RawMessageUpdateEvent diff --git a/commanderbot/ext/automod/events/raw_reaction_added.py b/commanderbot/ext/automod/events/raw_reaction_added.py index ff3c4b2..bd04c4f 100644 --- a/commanderbot/ext/automod/events/raw_reaction_added.py +++ b/commanderbot/ext/automod/events/raw_reaction_added.py @@ -2,11 +2,11 @@ from discord import RawReactionActionEvent -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("RawReactionAdded",) @dataclass -class RawReactionAdded(AutomodEventBase): +class RawReactionAdded(EventBase): payload: RawReactionActionEvent diff --git a/commanderbot/ext/automod/events/raw_reaction_removed.py b/commanderbot/ext/automod/events/raw_reaction_removed.py index f904603..ea9cdf8 100644 --- a/commanderbot/ext/automod/events/raw_reaction_removed.py +++ b/commanderbot/ext/automod/events/raw_reaction_removed.py @@ -2,11 +2,11 @@ from discord import RawReactionActionEvent -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("RawReactionRemoved",) @dataclass -class RawReactionRemoved(AutomodEventBase): +class RawReactionRemoved(EventBase): payload: RawReactionActionEvent diff --git a/commanderbot/ext/automod/events/reaction_added.py b/commanderbot/ext/automod/events/reaction_added.py index f079dfa..b495c30 100644 --- a/commanderbot/ext/automod/events/reaction_added.py +++ b/commanderbot/ext/automod/events/reaction_added.py @@ -2,14 +2,14 @@ from discord import Member, TextChannel, Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase from commanderbot.lib.types import TextMessage, TextReaction __all__ = ("ReactionAdded",) @dataclass -class ReactionAdded(AutomodEventBase): +class ReactionAdded(EventBase): _reaction: TextReaction _member: Member diff --git a/commanderbot/ext/automod/events/reaction_removed.py b/commanderbot/ext/automod/events/reaction_removed.py index b9099d7..d783951 100644 --- a/commanderbot/ext/automod/events/reaction_removed.py +++ b/commanderbot/ext/automod/events/reaction_removed.py @@ -2,14 +2,14 @@ from discord import Member, TextChannel, Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase from commanderbot.lib.types import TextMessage, TextReaction __all__ = ("ReactionRemoved",) @dataclass -class ReactionRemoved(AutomodEventBase): +class ReactionRemoved(EventBase): _reaction: TextReaction _member: Member diff --git a/commanderbot/ext/automod/events/thread_created.py b/commanderbot/ext/automod/events/thread_created.py index 8976e28..41026ea 100644 --- a/commanderbot/ext/automod/events/thread_created.py +++ b/commanderbot/ext/automod/events/thread_created.py @@ -2,13 +2,13 @@ from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("ThreadCreated",) @dataclass -class ThreadCreated(AutomodEventBase): +class ThreadCreated(EventBase): _thread: Thread @property diff --git a/commanderbot/ext/automod/events/thread_deleted.py b/commanderbot/ext/automod/events/thread_deleted.py index 5adc947..204b4d1 100644 --- a/commanderbot/ext/automod/events/thread_deleted.py +++ b/commanderbot/ext/automod/events/thread_deleted.py @@ -2,13 +2,13 @@ from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("ThreadDeleted",) @dataclass -class ThreadDeleted(AutomodEventBase): +class ThreadDeleted(EventBase): _thread: Thread @property diff --git a/commanderbot/ext/automod/events/thread_joined.py b/commanderbot/ext/automod/events/thread_joined.py index 982b8b3..5924128 100644 --- a/commanderbot/ext/automod/events/thread_joined.py +++ b/commanderbot/ext/automod/events/thread_joined.py @@ -2,13 +2,13 @@ from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("ThreadJoined",) @dataclass -class ThreadJoined(AutomodEventBase): +class ThreadJoined(EventBase): _thread: Thread @property diff --git a/commanderbot/ext/automod/events/thread_member_joined.py b/commanderbot/ext/automod/events/thread_member_joined.py index 4211da9..87b2c0e 100644 --- a/commanderbot/ext/automod/events/thread_member_joined.py +++ b/commanderbot/ext/automod/events/thread_member_joined.py @@ -2,13 +2,13 @@ from discord import Member, Thread, ThreadMember -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("ThreadMemberJoined",) @dataclass -class ThreadMemberJoined(AutomodEventBase): +class ThreadMemberJoined(EventBase): _member: ThreadMember @property diff --git a/commanderbot/ext/automod/events/thread_member_left.py b/commanderbot/ext/automod/events/thread_member_left.py index 972fa6a..6c947dd 100644 --- a/commanderbot/ext/automod/events/thread_member_left.py +++ b/commanderbot/ext/automod/events/thread_member_left.py @@ -2,13 +2,13 @@ from discord import Member, Thread, ThreadMember -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("ThreadMemberLeft",) @dataclass -class ThreadMemberLeft(AutomodEventBase): +class ThreadMemberLeft(EventBase): _member: ThreadMember @property diff --git a/commanderbot/ext/automod/events/thread_removed.py b/commanderbot/ext/automod/events/thread_removed.py index 849a3b2..9bed98b 100644 --- a/commanderbot/ext/automod/events/thread_removed.py +++ b/commanderbot/ext/automod/events/thread_removed.py @@ -2,13 +2,13 @@ from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("ThreadRemoved",) @dataclass -class ThreadRemoved(AutomodEventBase): +class ThreadRemoved(EventBase): _thread: Thread @property diff --git a/commanderbot/ext/automod/events/thread_updated.py b/commanderbot/ext/automod/events/thread_updated.py index aca5154..691523d 100644 --- a/commanderbot/ext/automod/events/thread_updated.py +++ b/commanderbot/ext/automod/events/thread_updated.py @@ -2,13 +2,13 @@ from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("ThreadUpdated",) @dataclass -class ThreadUpdated(AutomodEventBase): +class ThreadUpdated(EventBase): _before: Thread _after: Thread diff --git a/commanderbot/ext/automod/events/user_banned.py b/commanderbot/ext/automod/events/user_banned.py index a5b3b8a..4db448f 100644 --- a/commanderbot/ext/automod/events/user_banned.py +++ b/commanderbot/ext/automod/events/user_banned.py @@ -2,13 +2,13 @@ from discord import User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("UserBanned",) @dataclass -class UserBanned(AutomodEventBase): +class UserBanned(EventBase): _user: User @property diff --git a/commanderbot/ext/automod/events/user_unbanned.py b/commanderbot/ext/automod/events/user_unbanned.py index 5c418ff..e78322e 100644 --- a/commanderbot/ext/automod/events/user_unbanned.py +++ b/commanderbot/ext/automod/events/user_unbanned.py @@ -2,13 +2,13 @@ from discord import User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("UserUnbanned",) @dataclass -class UserUnbanned(AutomodEventBase): +class UserUnbanned(EventBase): _user: User @property diff --git a/commanderbot/ext/automod/events/user_updated.py b/commanderbot/ext/automod/events/user_updated.py index 78e1c2b..983c503 100644 --- a/commanderbot/ext/automod/events/user_updated.py +++ b/commanderbot/ext/automod/events/user_updated.py @@ -2,13 +2,13 @@ from discord import Member, User -from commanderbot.ext.automod.automod_event import AutomodEventBase +from commanderbot.ext.automod.event import EventBase __all__ = ("UserUpdated",) @dataclass -class UserUpdated(AutomodEventBase): +class UserUpdated(EventBase): _before: User _after: User _member: Member diff --git a/commanderbot/ext/automod/node/node_ref.py b/commanderbot/ext/automod/node/node_ref.py index 67544bb..5346756 100644 --- a/commanderbot/ext/automod/node/node_ref.py +++ b/commanderbot/ext/automod/node/node_ref.py @@ -1,9 +1,8 @@ -import typing from abc import abstractmethod from dataclasses import dataclass -from typing import Any, Generic, Optional, Type, TypeVar, cast +from typing import Any, Generic, Optional, Type, TypeVar -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.node.node import Node from commanderbot.lib import FromData, ToData @@ -37,7 +36,7 @@ def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: def to_data(self) -> Any: return self.name - async def resolve(self, event: AutomodEvent) -> NT: + async def resolve(self, event: Event) -> NT: node = await event.state.store.require_node_with_type( event.state.guild, self.node_type, self.name ) diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index 01cba33..63860a7 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -3,8 +3,8 @@ from typing import Any, Optional, Type, TypeVar from commanderbot.ext.automod.action import ActionCollection -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.condition import ConditionCollection +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.node.node_base import NodeBase from commanderbot.ext.automod.trigger import TriggerCollection from commanderbot.lib import LogOptions @@ -89,26 +89,26 @@ def build_title(self) -> str: parts.append(super().build_title()) return " ".join(parts) - async def poll_triggers(self, event: AutomodEvent) -> bool: + async def poll_triggers(self, event: Event) -> bool: """Check whether the event activates any triggers.""" for trigger in self.triggers: if await trigger.poll(event): return True return False - async def check_conditions(self, event: AutomodEvent) -> bool: + async def check_conditions(self, event: Event) -> 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): + async def apply_actions(self, event: Event): """Apply all actions.""" for action in self.actions: await action.apply(event) - async def run(self, event: AutomodEvent) -> bool: + async def run(self, event: Event) -> bool: """Apply actions if conditions pass.""" if (not self.disabled) and await self.check_conditions(event): await self.apply_actions(event) diff --git a/commanderbot/ext/automod/rule/rule_collection.py b/commanderbot/ext/automod/rule/rule_collection.py index c138e7d..d7892f2 100644 --- a/commanderbot/ext/automod/rule/rule_collection.py +++ b/commanderbot/ext/automod/rule/rule_collection.py @@ -12,7 +12,7 @@ Type, ) -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.node import NodeCollection from commanderbot.ext.automod.rule.rule import Rule from commanderbot.lib.utils import JsonPath, JsonPathOp @@ -31,7 +31,7 @@ class RuleCollection(NodeCollection[Rule]): node_type: ClassVar[Type[Rule]] = RuleBase # Index rules by event type for faster look-up during event dispatch. - _rules_by_event_type: DefaultDict[Type[AutomodEvent], Set[Rule]] + _rules_by_event_type: DefaultDict[Type[Event], Set[Rule]] # @overrides NodeCollection def __init__(self, nodes: Optional[Iterable[Rule]] = None): @@ -63,7 +63,7 @@ def modify(self, name: str, path: JsonPath, op: JsonPathOp, data: Any) -> Rule: rule.modified_on = datetime.utcnow() return rule - async def for_event(self, event: AutomodEvent) -> AsyncIterable[Rule]: + async def for_event(self, event: Event) -> AsyncIterable[Rule]: 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]: diff --git a/commanderbot/ext/automod/trigger/trigger.py b/commanderbot/ext/automod/trigger/trigger.py index 4457840..d9f1688 100644 --- a/commanderbot/ext/automod/trigger/trigger.py +++ b/commanderbot/ext/automod/trigger/trigger.py @@ -1,7 +1,7 @@ from typing import ClassVar, Optional, Protocol, Tuple, Type -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.component import Component +from commanderbot.ext.automod.event import Event __all__ = ("Trigger",) @@ -9,7 +9,7 @@ class Trigger(Component, Protocol): """A trigger details precisely which events to listen for.""" - event_types: ClassVar[Tuple[Type[AutomodEvent], ...]] + event_types: ClassVar[Tuple[Type[Event], ...]] - async def poll(self, event: AutomodEvent) -> Optional[bool]: + async def poll(self, event: Event) -> Optional[bool]: """Check whether an event activates the trigger.""" diff --git a/commanderbot/ext/automod/trigger/trigger_base.py b/commanderbot/ext/automod/trigger/trigger_base.py index 4a3502a..f80ed1b 100644 --- a/commanderbot/ext/automod/trigger/trigger_base.py +++ b/commanderbot/ext/automod/trigger/trigger_base.py @@ -2,8 +2,8 @@ from typing import ClassVar, Tuple, Type from commanderbot.ext.automod import triggers -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.component import ComponentBase +from commanderbot.ext.automod.event import Event __all__ = ("TriggerBase",) @@ -19,10 +19,10 @@ class TriggerBase(ComponentBase): module_function_name: ClassVar[str] = "create_trigger" # @implements Trigger - event_types: ClassVar[Tuple[Type[AutomodEvent], ...]] = tuple() + event_types: ClassVar[Tuple[Type[Event], ...]] = tuple() # @implements Trigger - async def poll(self, event: AutomodEvent) -> bool: + async def poll(self, event: Event) -> bool: # Skip if we're disabled. if self.disabled: return False @@ -39,6 +39,6 @@ async def poll(self, event: AutomodEvent) -> bool: # If we get here, we probably care about the event. return True - async def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: Event) -> bool: """Override this if more than just the event type needs to be checked.""" return False diff --git a/commanderbot/ext/automod/triggers/abc/thread_base.py b/commanderbot/ext/automod/triggers/abc/thread_base.py index 617068a..9693f63 100644 --- a/commanderbot/ext/automod/triggers/abc/thread_base.py +++ b/commanderbot/ext/automod/triggers/abc/thread_base.py @@ -3,12 +3,12 @@ from discord import Thread -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.trigger import TriggerBase from commanderbot.lib import ChannelsGuard -class EventWithThread(AutomodEvent, Protocol): +class EventWithThread(Event, Protocol): thread: Thread diff --git a/commanderbot/ext/automod/triggers/member_typing.py b/commanderbot/ext/automod/triggers/member_typing.py index 4288331..54f0c5d 100644 --- a/commanderbot/ext/automod/triggers/member_typing.py +++ b/commanderbot/ext/automod/triggers/member_typing.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib import ChannelsGuard, RolesGuard @@ -37,17 +37,17 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: roles=roles, ) - def ignore_by_channel(self, event: AutomodEvent) -> bool: + def ignore_by_channel(self, event: Event) -> bool: if self.channels is None: return False return self.channels.ignore(event.channel) - def ignore_by_role(self, event: AutomodEvent) -> bool: + def ignore_by_role(self, event: Event) -> bool: if self.roles is None: return False return self.roles.ignore(event.member) - async def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: Event) -> bool: return self.ignore_by_channel(event) or self.ignore_by_role(event) diff --git a/commanderbot/ext/automod/triggers/member_updated.py b/commanderbot/ext/automod/triggers/member_updated.py index 9ec42e4..6813799 100644 --- a/commanderbot/ext/automod/triggers/member_updated.py +++ b/commanderbot/ext/automod/triggers/member_updated.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib import RolesGuard @@ -39,12 +39,12 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: roles=roles, ) - def ignore_by_role(self, event: AutomodEvent) -> bool: + def ignore_by_role(self, event: Event) -> bool: if self.roles is None: return False return self.roles.ignore(event.member) - async def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: Event) -> bool: return self.ignore_by_role(event) diff --git a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py index fc697a2..91c092f 100644 --- a/commanderbot/ext/automod/triggers/mentions_removed_from_message.py +++ b/commanderbot/ext/automod/triggers/mentions_removed_from_message.py @@ -4,7 +4,7 @@ from discord import Member, Message, Role, TextChannel, Thread, User from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib import ChannelsGuard, RolesGuard @@ -47,7 +47,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: victim_roles=victim_roles, ) - async def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: Event) -> bool: channel = cast(TextChannel | Thread, event.channel) author = cast(Member, event.author) diff --git a/commanderbot/ext/automod/triggers/message.py b/commanderbot/ext/automod/triggers/message.py index d7e4fce..1799aef 100644 --- a/commanderbot/ext/automod/triggers/message.py +++ b/commanderbot/ext/automod/triggers/message.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Optional from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib import ChannelsGuard, RolesGuard @@ -48,22 +48,22 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: author_roles=author_roles, ) - def ignore_by_content(self, event: AutomodEvent) -> bool: + def ignore_by_content(self, event: Event) -> 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(self, event: AutomodEvent) -> bool: + def ignore_by_channel(self, event: Event) -> bool: if self.channels is None: return False return self.channels.ignore(event.channel) - def ignore_by_author_role(self, event: AutomodEvent) -> bool: + def ignore_by_author_role(self, event: Event) -> bool: if self.author_roles is None: return False return self.author_roles.ignore(event.author) - async def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: Event) -> bool: return ( self.ignore_by_content(event) or self.ignore_by_channel(event) diff --git a/commanderbot/ext/automod/triggers/message_frequency_changed.py b/commanderbot/ext/automod/triggers/message_frequency_changed.py index c5ed79d..84a0360 100644 --- a/commanderbot/ext/automod/triggers/message_frequency_changed.py +++ b/commanderbot/ext/automod/triggers/message_frequency_changed.py @@ -3,9 +3,9 @@ from typing import Any, Dict, Optional from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent from commanderbot.ext.automod.bucket import BucketRef from commanderbot.ext.automod.buckets.message_frequency import MessageFrequency +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib.utils import timedelta_from_field @@ -46,7 +46,7 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: timeframe=timeframe, ) - async def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: Event) -> bool: # Ignore events without a message. message = event.message if not message: diff --git a/commanderbot/ext/automod/triggers/reaction.py b/commanderbot/ext/automod/triggers/reaction.py index dee7b53..a69322e 100644 --- a/commanderbot/ext/automod/triggers/reaction.py +++ b/commanderbot/ext/automod/triggers/reaction.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional from commanderbot.ext.automod import events -from commanderbot.ext.automod.automod_event import AutomodEvent +from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.trigger import Trigger, TriggerBase from commanderbot.lib import ChannelsGuard, ReactionsGuard, RolesGuard @@ -49,27 +49,27 @@ def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: actor_roles=actor_roles, ) - def ignore_by_reaction(self, event: AutomodEvent) -> bool: + def ignore_by_reaction(self, event: Event) -> bool: if self.reactions is None: return False return self.reactions.ignore(event.reaction) - def ignore_by_channel(self, event: AutomodEvent) -> bool: + def ignore_by_channel(self, event: Event) -> bool: if self.channels is None: return False return self.channels.ignore(event.channel) - def ignore_by_author_role(self, event: AutomodEvent) -> bool: + def ignore_by_author_role(self, event: Event) -> 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: + def ignore_by_actor_role(self, event: Event) -> bool: if self.actor_roles is None: return False return self.actor_roles.ignore(event.actor) - async def ignore(self, event: AutomodEvent) -> bool: + async def ignore(self, event: Event) -> bool: return ( self.ignore_by_reaction(event) or self.ignore_by_channel(event) From bf1d63d1d2e787851412777dd09e619b9c3e0dfe Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Mon, 11 Oct 2021 15:32:34 -0400 Subject: [PATCH 24/26] Fix wording --- commanderbot/ext/automod/node/node_base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commanderbot/ext/automod/node/node_base.py b/commanderbot/ext/automod/node/node_base.py index 7bd2e36..b848fe3 100644 --- a/commanderbot/ext/automod/node/node_base.py +++ b/commanderbot/ext/automod/node/node_base.py @@ -49,8 +49,8 @@ def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: return obj @classmethod - def new_rule_name(cls) -> str: - """Create a new, unique rule name.""" + def new_node_name(cls) -> str: + """Create a new, unique node name.""" return str(uuid.uuid4()) @classmethod @@ -63,7 +63,7 @@ def build_base_data(cls, data: Dict[str, Any]) -> Dict[str, Any]: """ name = data.get("name") if not name: - name = cls.new_rule_name() + name = cls.new_node_name() base_data: Dict[str, Any] = { "name": name, "description": None, From 1a87b161be87729db4d9d09e57fb2e09cb52d302 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Mon, 11 Oct 2021 15:33:20 -0400 Subject: [PATCH 25/26] Update `Rule` factory --- commanderbot/ext/automod/rule/rule.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/commanderbot/ext/automod/rule/rule.py b/commanderbot/ext/automod/rule/rule.py index 63860a7..47c6b25 100644 --- a/commanderbot/ext/automod/rule/rule.py +++ b/commanderbot/ext/automod/rule/rule.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from datetime import datetime -from typing import Any, Optional, Type, TypeVar +from typing import Any, Dict, Optional from commanderbot.ext.automod.action import ActionCollection from commanderbot.ext.automod.condition import ConditionCollection @@ -10,8 +10,6 @@ from commanderbot.lib import LogOptions from commanderbot.lib.utils import datetime_from_field_optional -ST = TypeVar("ST", bound="Rule") - @dataclass class Rule(NodeBase): @@ -46,11 +44,9 @@ class Rule(NodeBase): conditions: ConditionCollection actions: ActionCollection - # @overrides FromData + # @overrides NodeBase @classmethod - def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: - if not isinstance(data, dict): - return + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: now = datetime.utcnow() added_on = datetime_from_field_optional(data, "added_on") or now modified_on = datetime_from_field_optional(data, "modified_on") or now @@ -65,10 +61,7 @@ def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: actions = ( ActionCollection.from_field_optional(data, "actions") or ActionCollection() ) - return cls( - name=data["name"], - description=data.get("description"), - disabled=data.get("disabled"), + return dict( added_on=added_on, modified_on=modified_on, hits=data.get("hits", 0), From 76d5d5059f52c3a918f16c9d4dbf254628e00f53 Mon Sep 17 00:00:00 2001 From: Arcensoth Date: Tue, 12 Oct 2021 00:01:26 -0400 Subject: [PATCH 26/26] Working implementation of buckets --- commanderbot/ext/automod/action/action_ref.py | 3 +- .../ext/automod/actions/add_to_bucket.py | 4 +- commanderbot/ext/automod/automod_cog.py | 2 +- commanderbot/ext/automod/automod_data.py | 12 +- .../ext/automod/automod_guild_state.py | 2 +- .../ext/automod/automod_json_store.py | 13 +- commanderbot/ext/automod/automod_store.py | 7 +- commanderbot/ext/automod/bucket/bucket_ref.py | 3 +- .../ext/automod/buckets/message_frequency.py | 194 +++++++++++++----- .../ext/automod/condition/condition_ref.py | 3 +- commanderbot/ext/automod/events/__init__.py | 1 - .../events/message_frequency_changed.py | 33 --- commanderbot/ext/automod/node/node_kind.py | 57 ----- commanderbot/ext/automod/node/node_ref.py | 23 ++- commanderbot/ext/automod/node_kind.py | 61 ++++++ .../ext/automod/trigger/trigger_ref.py | 3 +- .../ext/automod/triggers/message_frequency.py | 75 +++++++ .../triggers/message_frequency_changed.py | 67 ------ commanderbot/lib/data.py | 125 ++++++++--- commanderbot/lib/errors.py | 6 + commanderbot/lib/from_data_mixin.py | 2 +- commanderbot/lib/integer_range.py | 16 +- commanderbot/lib/types.py | 3 + commanderbot/lib/utils/__init__.py | 1 + commanderbot/lib/utils/colors.py | 2 +- commanderbot/lib/utils/dataclasses.py | 13 ++ commanderbot/lib/utils/datetimes.py | 2 +- commanderbot/lib/utils/timedeltas.py | 2 +- commanderbot/lib/utils/yield_fields.py | 2 +- 29 files changed, 444 insertions(+), 293 deletions(-) delete mode 100644 commanderbot/ext/automod/events/message_frequency_changed.py delete mode 100644 commanderbot/ext/automod/node/node_kind.py create mode 100644 commanderbot/ext/automod/node_kind.py create mode 100644 commanderbot/ext/automod/triggers/message_frequency.py delete mode 100644 commanderbot/ext/automod/triggers/message_frequency_changed.py create mode 100644 commanderbot/lib/errors.py create mode 100644 commanderbot/lib/utils/dataclasses.py diff --git a/commanderbot/ext/automod/action/action_ref.py b/commanderbot/ext/automod/action/action_ref.py index b911391..9e30958 100644 --- a/commanderbot/ext/automod/action/action_ref.py +++ b/commanderbot/ext/automod/action/action_ref.py @@ -1,7 +1,6 @@ from typing import ClassVar, Generic, Type, TypeVar from commanderbot.ext.automod.action.action import Action -from commanderbot.ext.automod.action.action_base import ActionBase from commanderbot.ext.automod.node import NodeRef __all__ = ("ActionRef",) @@ -15,4 +14,4 @@ class ActionRef(NodeRef[Action], Generic[NT]): """A reference to an action, by name.""" # @implements NodeRef - node_type: ClassVar[Type[Action]] = ActionBase + node_type: ClassVar[Type[Action]] = Action diff --git a/commanderbot/ext/automod/actions/add_to_bucket.py b/commanderbot/ext/automod/actions/add_to_bucket.py index b2bd447..b6342f7 100644 --- a/commanderbot/ext/automod/actions/add_to_bucket.py +++ b/commanderbot/ext/automod/actions/add_to_bucket.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional from commanderbot.ext.automod.action import Action, ActionBase -from commanderbot.ext.automod.bucket import BucketRef +from commanderbot.ext.automod.bucket import Bucket, BucketRef from commanderbot.ext.automod.event import Event @@ -17,7 +17,7 @@ class AddToBucket(ActionBase): The bucket to add to. """ - bucket: BucketRef + bucket: BucketRef[Bucket] # @overrides NodeBase @classmethod diff --git a/commanderbot/ext/automod/automod_cog.py b/commanderbot/ext/automod/automod_cog.py index 6d39e6c..5416320 100644 --- a/commanderbot/ext/automod/automod_cog.py +++ b/commanderbot/ext/automod/automod_cog.py @@ -26,7 +26,7 @@ from commanderbot.ext.automod.automod_options import AutomodOptions from commanderbot.ext.automod.automod_state import AutomodState from commanderbot.ext.automod.automod_store import AutomodStore -from commanderbot.ext.automod.node.node_kind import NodeKind, NodeKindConverter +from commanderbot.ext.automod.node_kind import NodeKind, NodeKindConverter from commanderbot.lib import ( CogGuildStateManager, GuildContext, diff --git a/commanderbot/ext/automod/automod_data.py b/commanderbot/ext/automod/automod_data.py index 9c34a6a..1b5dbca 100644 --- a/commanderbot/ext/automod/automod_data.py +++ b/commanderbot/ext/automod/automod_data.py @@ -1,4 +1,3 @@ -from asyncio.locks import Condition from collections import defaultdict from dataclasses import dataclass, field from typing import Any, AsyncIterable, DefaultDict, Dict, Optional, Type, TypeVar, cast @@ -24,6 +23,7 @@ ST = TypeVar("ST") NT = TypeVar("NT", bound=Node) +NST = TypeVar("NST", bound=Node) @dataclass @@ -102,7 +102,7 @@ def get_collection(self, node_type: Type[NT]) -> NodeCollection[NT]: return cast(Any, self.conditions) if node_type is Action: return cast(Any, self.actions) - raise ResponsiveException(f"Invalid node type: {node_type}") + raise ResponsiveException(f"Unknown node type: `{node_type.__name__}`") def set_default_log_options( self, log_options: Optional[LogOptions] @@ -214,11 +214,11 @@ async def require_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT return collection.require(name) # @implements AutomodStore - async def require_node_with_type( - self, guild: Guild, node_type: Type[NT], name: str - ) -> NT: + async def require_node_with_subtype( + self, guild: Guild, node_type: Type[NT], name: str, node_subtype: Type[NST] + ) -> NST: collection = self.guilds[guild.id].get_collection(node_type) - return collection.require_with_type(name, node_type) + return collection.require_with_type(name, node_subtype) # @implements AutomodStore async def add_node(self, guild: Guild, node_type: Type[NT], data: Any) -> NT: diff --git a/commanderbot/ext/automod/automod_guild_state.py b/commanderbot/ext/automod/automod_guild_state.py index 041989e..98d63bc 100644 --- a/commanderbot/ext/automod/automod_guild_state.py +++ b/commanderbot/ext/automod/automod_guild_state.py @@ -24,7 +24,7 @@ from commanderbot.ext.automod import events from commanderbot.ext.automod.automod_store import AutomodStore from commanderbot.ext.automod.event import Event -from commanderbot.ext.automod.node.node_kind import NodeKind +from commanderbot.ext.automod.node_kind import NodeKind from commanderbot.ext.automod.rule import Rule from commanderbot.lib import ( CogGuildState, diff --git a/commanderbot/ext/automod/automod_json_store.py b/commanderbot/ext/automod/automod_json_store.py index a16e7aa..723acaf 100644 --- a/commanderbot/ext/automod/automod_json_store.py +++ b/commanderbot/ext/automod/automod_json_store.py @@ -4,15 +4,14 @@ from discord import Guild from commanderbot.ext.automod.automod_data import AutomodData -from commanderbot.ext.automod.bucket import Bucket from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.node import Node from commanderbot.ext.automod.rule import Rule from commanderbot.lib import CogStore, JsonFileDatabaseAdapter, LogOptions, RoleSet from commanderbot.lib.utils import JsonPath, JsonPathOp -BT = TypeVar("BT", bound=Bucket) NT = TypeVar("NT", bound=Node) +NST = TypeVar("NST", bound=Node) # @implements AutomodStore @@ -83,11 +82,13 @@ async def require_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT return await cache.require_node(guild, node_type, name) # @implements AutomodStore - async def require_node_with_type( - self, guild: Guild, node_type: Type[NT], name: str - ) -> NT: + async def require_node_with_subtype( + self, guild: Guild, node_type: Type[NT], name: str, node_subtype: Type[NST] + ) -> NST: cache = await self.db.get_cache() - return await cache.require_node_with_type(guild, node_type, name) + return await cache.require_node_with_subtype( + guild, node_type, name, node_subtype + ) # @implements AutomodStore async def add_node(self, guild: Guild, node_type: Type[NT], data: Any) -> NT: diff --git a/commanderbot/ext/automod/automod_store.py b/commanderbot/ext/automod/automod_store.py index d586344..ad488af 100644 --- a/commanderbot/ext/automod/automod_store.py +++ b/commanderbot/ext/automod/automod_store.py @@ -9,6 +9,7 @@ from commanderbot.lib.utils import JsonPath, JsonPathOp NT = TypeVar("NT", bound=Node) +NST = TypeVar("NST", bound=Node) class AutomodStore(Protocol): @@ -52,9 +53,9 @@ async def get_node( async def require_node(self, guild: Guild, node_type: Type[NT], name: str) -> NT: ... - async def require_node_with_type( - self, guild: Guild, node_type: Type[NT], name: str - ) -> NT: + async def require_node_with_subtype( + self, guild: Guild, node_type: Type[NT], name: str, node_subtype: Type[NST] + ) -> NST: ... async def add_node(self, guild: Guild, node_type: Type[NT], data: Any) -> NT: diff --git a/commanderbot/ext/automod/bucket/bucket_ref.py b/commanderbot/ext/automod/bucket/bucket_ref.py index 9755f0f..6ba89b3 100644 --- a/commanderbot/ext/automod/bucket/bucket_ref.py +++ b/commanderbot/ext/automod/bucket/bucket_ref.py @@ -1,7 +1,6 @@ from typing import ClassVar, Generic, Type, TypeVar from commanderbot.ext.automod.bucket.bucket import Bucket -from commanderbot.ext.automod.bucket.bucket_base import BucketBase from commanderbot.ext.automod.node import NodeRef __all__ = ("BucketRef",) @@ -15,4 +14,4 @@ class BucketRef(NodeRef[NT], Generic[NT]): """A reference to a bucket, by name.""" # @implements NodeRef - node_type: ClassVar[Type[Bucket]] = BucketBase + node_type: ClassVar[Type[Bucket]] = Bucket diff --git a/commanderbot/ext/automod/buckets/message_frequency.py b/commanderbot/ext/automod/buckets/message_frequency.py index 60d26f4..70f4e9f 100644 --- a/commanderbot/ext/automod/buckets/message_frequency.py +++ b/commanderbot/ext/automod/buckets/message_frequency.py @@ -2,61 +2,126 @@ from collections import defaultdict from dataclasses import dataclass, field -from datetime import datetime, timedelta -from typing import Any, Iterable, Optional, Type, TypeAlias, TypeVar +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Iterable, List, Optional, TypeAlias -from discord import Member, Message, User +from discord import Member, Message, TextChannel, Thread, User -from commanderbot.ext.automod import events from commanderbot.ext.automod.bucket import Bucket, BucketBase -from commanderbot.ext.automod.event import Event -from commanderbot.lib import ChannelID, UserID -from commanderbot.lib.utils import timedelta_from_field_optional +from commanderbot.ext.automod.event import Event, EventBase +from commanderbot.ext.automod.event.event_base import EventBase +from commanderbot.lib import ChannelID, MessageID, TextMessage, ToData, UserID +from commanderbot.lib.utils import timedelta_from_field, utcnow_aware -ST = TypeVar("ST") +@dataclass +class _MessageRecord: + channel_id: ChannelID + message_id: MessageID + time: datetime + + +@dataclass +class _ChannelRecord: + channel_id: ChannelID + + _messages: Dict[MessageID, _MessageRecord] = field(default_factory=lambda: {}) + + @property + def messages(self) -> List[_MessageRecord]: + return list(self._messages.values()) -class UserTicket: - def __init__(self): - self.message_count: int = 0 - self.unique_channels: set[ChannelID] = set() + +@dataclass +class _UserTicket: + _channels: Dict[ChannelID, _ChannelRecord] = field(default_factory=lambda: {}) @property - def channel_count(self) -> int: - return len(self.unique_channels) + def channels(self) -> List[_ChannelRecord]: + return list(self._channels.values()) - def increment(self, message: Message): - """Increment this ticket with the given message.""" - self.message_count += 1 - self.unique_channels.add(message.channel.id) + @property + def messages(self) -> List[_MessageRecord]: + messages = [] + for channel in self.channels: + messages += channel.messages + return messages + + def add_message_record(self, message_record: _MessageRecord): + """Add a message record to this ticket.""" + 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 ticket.""" + message_time = message.edited_at or message.created_at + message_record = _MessageRecord( + channel_id=message.channel.id, + message_id=message.id, + time=message_time, + ) + self.add_message_record(message_record) - def add(self, other: UserTicket): - """Add another ticket to this one.""" - self.message_count += other.message_count - self.unique_channels.update(other.unique_channels) + def add_from(self, other: _UserTicket, since: datetime): + """Add the message records of another ticket to this ticket.""" + for message_record in other.messages: + if message_record.time >= since: + self.add_message_record(message_record) -UserBuckets: TypeAlias = defaultdict[UserID, UserTicket] -IntervalBuckets: TypeAlias = defaultdict[int, UserBuckets] +_UserBuckets: TypeAlias = defaultdict[UserID, _UserTicket] +_IntervalBuckets: TypeAlias = defaultdict[datetime, _UserBuckets] -def user_buckets_factory() -> UserBuckets: - return defaultdict(default_factory=lambda: UserTicket()) +def _user_buckets_factory() -> _UserBuckets: + return defaultdict(lambda: _UserTicket()) -def interval_buckets_factory() -> IntervalBuckets: - return defaultdict(default_factory=user_buckets_factory) +def _interval_buckets_factory() -> _IntervalBuckets: + return defaultdict(_user_buckets_factory) @dataclass -class MessageFrequencyState: - interval_buckets: IntervalBuckets = field( - init=False, default_factory=interval_buckets_factory +class _MessageFrequencyState: + interval_buckets: _IntervalBuckets = field( + default_factory=_interval_buckets_factory ) @dataclass -class MessageFrequency(BucketBase): +class MessageFrequencyEvent(EventBase): + bucket: MessageFrequencyBucket + + _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 + + @property + def actor(self) -> Member: + return self._message.author + + @property + def member(self) -> Member: + return self._message.author + + +@dataclass +class MessageFrequencyBucket(BucketBase): """ Track user activity across channels for potential spam. @@ -74,13 +139,21 @@ class MessageFrequency(BucketBase): bucket_lifetime: timedelta bucket_interval: timedelta - _state: MessageFrequencyState + _state: _MessageFrequencyState = field( + init=False, + default_factory=lambda: _MessageFrequencyState(), + metadata={ToData.Flags: [ToData.Flags.ExcludeFromData]}, + ) + # @overrides NodeBase @classmethod - def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: - if isinstance(data, dict): - bucket_interval = timedelta_from_field_optional(data, "bucket_interval") - return cls(bucket_interval=bucket_interval) + def build_complex_fields(cls, data: dict[str, Any]) -> Optional[dict[str, Any]]: + bucket_lifetime = timedelta_from_field(data, "bucket_lifetime") + bucket_interval = timedelta_from_field(data, "bucket_interval") + return dict( + bucket_lifetime=bucket_lifetime, + bucket_interval=bucket_interval, + ) @property def bucket_lifetime_in_seconds(self) -> int: @@ -90,14 +163,16 @@ def bucket_lifetime_in_seconds(self) -> int: def bucket_interval_in_seconds(self) -> int: return int(self.bucket_interval.total_seconds()) - def _message_to_interval(self, message: Message) -> int: - message_seconds = int(message.created_at.timestamp()) - interval = message_seconds // self.bucket_interval_in_seconds + def _to_interval(self, dt: datetime) -> datetime: + message_seconds = int(dt.timestamp()) + accuracy = self.bucket_interval_in_seconds + interval_ts = (message_seconds // accuracy) * accuracy + interval = datetime.fromtimestamp(interval_ts, tz=timezone.utc) return interval - def get_user_buckets_since(self, since: datetime) -> Iterable[UserBuckets]: - # Calculate the interval in which the since-time lies. - since_interval = int(since.timestamp()) + def get_user_buckets_since( + self, since_interval: datetime + ) -> Iterable[_UserBuckets]: # Iterate over each interval... for interval, user_buckets in self._state.interval_buckets.items(): # If it's happened since, yield it. @@ -105,25 +180,29 @@ def get_user_buckets_since(self, since: datetime) -> Iterable[UserBuckets]: yield user_buckets def get_user_tickets_since( - self, user: User | Member, since: datetime - ) -> Iterable[UserTicket]: + self, user: User | Member, since_interval: datetime + ) -> Iterable[_UserTicket]: # Iterate over each interval bucket within the given timeframe... - for user_buckets in self.get_user_buckets_since(since): + for user_buckets in self.get_user_buckets_since(since_interval): # If the user has a ticket in this bucket, yield it. if user_ticket := user_buckets.get(user.id): yield user_ticket def build_user_record_since( self, user: User | Member, since: datetime - ) -> UserTicket: - record = UserTicket() - for ticket in self.get_user_tickets_since(user, since): - record.add(ticket) + ) -> _UserTicket: + since_interval = self._to_interval(since) + record = _UserTicket() + for ticket in self.get_user_tickets_since(user, since_interval): + record.add_from(ticket, since) return record def clean_buckets(self): - # IMPL clean buckets to free memory - ... + # Clean expired buckets to free memory. + cutoff = utcnow_aware() - self.bucket_lifetime + for interval in self._state.interval_buckets.copy(): + if interval < cutoff: + del self._state.interval_buckets[interval] async def add(self, event: Event): # Short-circuit if the event does not contain a message. @@ -136,19 +215,20 @@ async def add(self, event: Event): # Calculate the interval based on the most recent message timestamp, and use it # to get/create the corresponding interval bucket. - interval = self._message_to_interval(message) - interval_bucket = self._state.interval_buckets[interval] + message_time = message.edited_at or message.created_at + message_interval = self._to_interval(message_time) + interval_bucket = self._state.interval_buckets[message_interval] # Within this interval, get/create and increment the user's ticket. author = message.author user_ticket = interval_bucket[author.id] - user_ticket.increment(message) + user_ticket.add_message(message) # Dispatch an event. await event.state.dispatch_event( - events.MessageFrequencyChanged(event.state, event.bot, event.log, message) + MessageFrequencyEvent(event.state, event.bot, event.log, self, message) ) def create_bucket(data: Any) -> Bucket: - return MessageFrequency.from_data(data) + return MessageFrequencyBucket.from_data(data) diff --git a/commanderbot/ext/automod/condition/condition_ref.py b/commanderbot/ext/automod/condition/condition_ref.py index 8b2153b..4b3ed0f 100644 --- a/commanderbot/ext/automod/condition/condition_ref.py +++ b/commanderbot/ext/automod/condition/condition_ref.py @@ -1,7 +1,6 @@ from typing import ClassVar, Generic, Type, TypeVar from commanderbot.ext.automod.condition.condition import Condition -from commanderbot.ext.automod.condition.condition_base import ConditionBase from commanderbot.ext.automod.node import NodeRef __all__ = ("ConditionRef",) @@ -15,4 +14,4 @@ class ConditionRef(NodeRef[Condition], Generic[NT]): """A reference to a condition, by name.""" # @implements NodeRef - node_type: ClassVar[Type[Condition]] = ConditionBase + node_type: ClassVar[Type[Condition]] = Condition diff --git a/commanderbot/ext/automod/events/__init__.py b/commanderbot/ext/automod/events/__init__.py index 9f3b48a..8ac746b 100644 --- a/commanderbot/ext/automod/events/__init__.py +++ b/commanderbot/ext/automod/events/__init__.py @@ -7,7 +7,6 @@ from .member_updated import * from .message_deleted import * from .message_edited import * -from .message_frequency_changed import * from .message_sent import * from .raw_message_deleted import * from .raw_message_edited import * diff --git a/commanderbot/ext/automod/events/message_frequency_changed.py b/commanderbot/ext/automod/events/message_frequency_changed.py deleted file mode 100644 index a98cae4..0000000 --- a/commanderbot/ext/automod/events/message_frequency_changed.py +++ /dev/null @@ -1,33 +0,0 @@ -from dataclasses import dataclass - -from discord import Member, TextChannel, Thread - -from commanderbot.ext.automod.event import EventBase -from commanderbot.lib.types import TextMessage - -__all__ = ("MessageFrequencyChanged",) - - -@dataclass -class MessageFrequencyChanged(EventBase): - _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 - - @property - def actor(self) -> Member: - return self._message.author - - @property - def member(self) -> Member: - return self._message.author diff --git a/commanderbot/ext/automod/node/node_kind.py b/commanderbot/ext/automod/node/node_kind.py deleted file mode 100644 index 0470f54..0000000 --- a/commanderbot/ext/automod/node/node_kind.py +++ /dev/null @@ -1,57 +0,0 @@ -from dataclasses import dataclass -from typing import Dict, Type - -from discord.ext.commands import Context - -from commanderbot.ext.automod.action.action import Action -from commanderbot.ext.automod.bucket.bucket import Bucket -from commanderbot.ext.automod.condition.condition import Condition -from commanderbot.ext.automod.node.node import Node -from commanderbot.ext.automod.rule.rule import Rule -from commanderbot.ext.automod.trigger.trigger import Trigger -from commanderbot.lib.responsive_exception import ResponsiveException - -__all__ = ( - "NodeKind", - "NodeKindConverter", -) - - -@dataclass -class NodeKind: - node_type: Type[Node] - singular: str - plural: str - - def __str__(self) -> str: - return self.singular - - -rule = NodeKind(Rule, "rule", "rules") -bucket = NodeKind(Bucket, "bucket", "buckets") -trigger = NodeKind(Trigger, "trigger", "triggers") -condition = NodeKind(Condition, "condition", "conditions") -action = NodeKind(Action, "action", "actions") - - -NODE_KINDS: Dict[str, NodeKind] = { - "rule": rule, - "bucket": bucket, - "trigger": trigger, - "condition": condition, - "action": action, -} - - -class NodeKindConverter: - async def convert(self, ctx: Context, argument: str) -> NodeKind: - try: - return NODE_KINDS[argument] - except: - pass - - node_kinds = [node_kind for node_kind in NODE_KINDS.values()] - node_kinds_str = " ".join(f"`{node_kind}`" for node_kind in node_kinds) - raise ResponsiveException( - f"No such node type `{argument}`" + f" (must be one of: {node_kinds_str})" - ) diff --git a/commanderbot/ext/automod/node/node_ref.py b/commanderbot/ext/automod/node/node_ref.py index 5346756..6d4628f 100644 --- a/commanderbot/ext/automod/node/node_ref.py +++ b/commanderbot/ext/automod/node/node_ref.py @@ -1,6 +1,7 @@ +import typing from abc import abstractmethod from dataclasses import dataclass -from typing import Any, Generic, Optional, Type, TypeVar +from typing import Any, Generic, Optional, Type, TypeVar, cast from commanderbot.ext.automod.event import Event from commanderbot.ext.automod.node.node import Node @@ -11,6 +12,7 @@ ST = TypeVar("ST") NT = TypeVar("NT", bound=Node) +NST = TypeVar("NST", bound=Node) # @abstract @@ -24,7 +26,7 @@ class NodeRef(FromData, ToData, Generic[NT]): @property @abstractmethod def node_type(cls) -> Type[NT]: - """Return the concrete node type used to construct instances.""" + """Return the abstract node type used to identify a node collection.""" # @overrides FromData @classmethod @@ -36,8 +38,15 @@ def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: def to_data(self) -> Any: return self.name - async def resolve(self, event: Event) -> NT: - node = await event.state.store.require_node_with_type( - event.state.guild, self.node_type, self.name - ) - return node + async def resolve( + self, event: Event, node_subtype: Optional[Type[NST]] = None + ) -> NT: + if node_subtype is not None: + node = await event.state.store.require_node_with_subtype( + event.state.guild, self.node_type, self.name, node_subtype + ) + else: + node = await event.state.store.require_node( + event.state.guild, self.node_type, self.name + ) + return cast(NT, node) diff --git a/commanderbot/ext/automod/node_kind.py b/commanderbot/ext/automod/node_kind.py new file mode 100644 index 0000000..e9f8193 --- /dev/null +++ b/commanderbot/ext/automod/node_kind.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass +from typing import Dict, Generic, Type, TypeVar + +from discord.ext.commands import Context + +from commanderbot.ext.automod.node.node import Node +from commanderbot.lib.responsive_exception import ResponsiveException + +__all__ = ( + "NodeKind", + "NodeKindConverter", +) + + +NT = TypeVar("NT", bound=Node) + + +@dataclass +class NodeKind(Generic[NT]): + node_type: Type[NT] + singular: str + plural: str + + def __str__(self) -> str: + return self.singular + + +class NodeKindConverter: + def __init__(self): + self._node_kinds: Dict[str, NodeKind] = {} + + @property + def node_kinds(self) -> Dict[str, NodeKind]: + if not self._node_kinds: + from commanderbot.ext.automod.action.action import Action + from commanderbot.ext.automod.bucket.bucket import Bucket + from commanderbot.ext.automod.condition.condition import Condition + from commanderbot.ext.automod.rule.rule import Rule + from commanderbot.ext.automod.trigger.trigger import Trigger + + self._node_kinds = { + "rule": NodeKind(Rule, "rule", "rules"), + "bucket": NodeKind(Bucket, "bucket", "buckets"), + "trigger": NodeKind(Trigger, "trigger", "triggers"), + "condition": NodeKind(Condition, "condition", "conditions"), + "action": NodeKind(Action, "action", "actions"), + } + + return self._node_kinds + + async def convert(self, ctx: Context, argument: str) -> NodeKind: + try: + return self.node_kinds[argument] + except: + pass + + node_kinds = [node_kind for node_kind in self.node_kinds.values()] + node_kinds_str = " ".join(f"`{node_kind}`" for node_kind in node_kinds) + raise ResponsiveException( + f"No such node type `{argument}`" + f" (must be one of: {node_kinds_str})" + ) diff --git a/commanderbot/ext/automod/trigger/trigger_ref.py b/commanderbot/ext/automod/trigger/trigger_ref.py index 9b26a27..35de82f 100644 --- a/commanderbot/ext/automod/trigger/trigger_ref.py +++ b/commanderbot/ext/automod/trigger/trigger_ref.py @@ -2,7 +2,6 @@ from commanderbot.ext.automod.node import NodeRef from commanderbot.ext.automod.trigger.trigger import Trigger -from commanderbot.ext.automod.trigger.trigger_base import TriggerBase __all__ = ("TriggerRef",) @@ -15,4 +14,4 @@ class TriggerRef(NodeRef[Trigger], Generic[NT]): """A reference to a trigger, by name.""" # @implements NodeRef - node_type: ClassVar[Type[Trigger]] = TriggerBase + node_type: ClassVar[Type[Trigger]] = Trigger diff --git a/commanderbot/ext/automod/triggers/message_frequency.py b/commanderbot/ext/automod/triggers/message_frequency.py new file mode 100644 index 0000000..640ea0f --- /dev/null +++ b/commanderbot/ext/automod/triggers/message_frequency.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, Dict, Optional + +from commanderbot.ext.automod.bucket import BucketRef +from commanderbot.ext.automod.buckets.message_frequency import ( + MessageFrequencyBucket, + MessageFrequencyEvent, +) +from commanderbot.ext.automod.trigger import Trigger, TriggerBase +from commanderbot.lib.integer_range import IntegerRange +from commanderbot.lib.utils import timedelta_from_field + + +@dataclass +class MessageFrequencyTrigger(TriggerBase): + """ + Fires when a message author is suspect of spamming. + + Attributes + ---------- + bucket + The bucket being used to track message frequency. + timeframe + How far back to consider a user's activity for spam. + message_count + The number of messages a user must send before being suspected of spam. + channel_count + The number of channels in which user must send messages before being suspected + of spam. + """ + + event_types = (MessageFrequencyEvent,) + + bucket: BucketRef[MessageFrequencyBucket] + timeframe: timedelta + message_count: IntegerRange + channel_count: IntegerRange + + # @overrides NodeBase + @classmethod + def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + bucket = BucketRef.from_field(data, "bucket") + timeframe = timedelta_from_field(data, "timeframe") + message_count = IntegerRange.from_field(data, "message_count") + channel_count = IntegerRange.from_field(data, "channel_count") + return dict( + bucket=bucket, + timeframe=timeframe, + message_count=message_count, + channel_count=channel_count, + ) + + async def ignore(self, event: MessageFrequencyEvent) -> bool: + # Ignore events dispatched by other buckets. + bucket = event.bucket + if bucket.name != self.bucket.name: + return True + + # Use the bucket to build a record out of our timeframe. + message = event.message + since = message.created_at - self.timeframe + record = bucket.build_user_record_since(message.author, since) + + # Ignore if the record does not meet our thresholds. + record_messages = record.messages + record_channels = record.channels + enough_messages = self.message_count.includes(len(record_messages)) + enough_channels = self.channel_count.includes(len(record_channels)) + ignore = not (enough_messages and enough_channels) + return ignore + + +def create_trigger(data: Any) -> Trigger: + return MessageFrequencyTrigger.from_data(data) diff --git a/commanderbot/ext/automod/triggers/message_frequency_changed.py b/commanderbot/ext/automod/triggers/message_frequency_changed.py deleted file mode 100644 index 84a0360..0000000 --- a/commanderbot/ext/automod/triggers/message_frequency_changed.py +++ /dev/null @@ -1,67 +0,0 @@ -from dataclasses import dataclass -from datetime import timedelta -from typing import Any, Dict, Optional - -from commanderbot.ext.automod import events -from commanderbot.ext.automod.bucket import BucketRef -from commanderbot.ext.automod.buckets.message_frequency import MessageFrequency -from commanderbot.ext.automod.event import Event -from commanderbot.ext.automod.trigger import Trigger, TriggerBase -from commanderbot.lib.utils import timedelta_from_field - - -@dataclass -class MessageFrequencyChanged(TriggerBase): - """ - Fires when a message author is suspect of spamming. - - Attributes - ---------- - bucket - The bucket being used to track message frequency. - message_threshold - The minimum number of messages a user must send before being suspected of spam. - Defaults to 3 messages. - channel_threshold - The minimum number of channels in which user must send messages before being - suspected of spam. Defaults to 3 channels. - timeframe - How far back to consider a user's activity for spam. Defaults to 30 seconds. - """ - - event_types = (events.MessageFrequencyChanged,) - - bucket: BucketRef[MessageFrequency] - message_threshold: int - channel_threshold: int - timeframe: timedelta - - # @overrides NodeBase - @classmethod - def build_complex_fields(cls, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - bucket = BucketRef.from_field(data, "bucket") - timeframe = timedelta_from_field(data, "timeframe") - return dict( - bucket=bucket, - timeframe=timeframe, - ) - - async def ignore(self, event: Event) -> bool: - # Ignore events without a message. - message = event.message - if not message: - return True - - # Use the bucket to build a record out of our timeframe. - bucket = await self.bucket.resolve(event) - since = message.created_at - self.timeframe - record = bucket.build_user_record_since(message.author, since) - - # Ignore if the record does not meet our thresholds. - enough_messages = record.message_count >= self.message_threshold - enough_channels = record.channel_count >= self.channel_threshold - return enough_messages and enough_channels - - -def create_trigger(data: Any) -> Trigger: - return MessageFrequencyChanged.from_data(data) diff --git a/commanderbot/lib/data.py b/commanderbot/lib/data.py index 3d4892c..442d8ba 100644 --- a/commanderbot/lib/data.py +++ b/commanderbot/lib/data.py @@ -1,11 +1,28 @@ import dataclasses +from collections import defaultdict from datetime import datetime, timedelta -from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, cast +from typing import ( + Any, + Callable, + ClassVar, + Collection, + Dict, + List, + Mapping, + Optional, + Sequence, + Tuple, + Type, + TypeVar, + cast, +) from discord import Color +from commanderbot.lib.errors import MalformedData +from commanderbot.lib.utils.dataclasses import is_field_optional + __all__ = ( - "MalformedData", "FromData", "ToData", ) @@ -14,11 +31,6 @@ MISSING = object() -class MalformedData(Exception): - def __init__(self, cls: Type, data: Any): - super().__init__(f"Cannot create {cls.__name__} from {type(data).__name__}") - - ST = TypeVar("ST", bound="FromData") @@ -81,14 +93,69 @@ def from_field_default( class ToData: """An object that can be serialized into raw data.""" + class Flags: + ExcludeFromData: ClassVar[str] = "exclude_from_data" + @classmethod - def attributes_to_data(cls, attrs: Dict[str, Any]) -> Dict[str, Any]: - """Convert object attributes to data.""" - return {k: cls.attribute_to_data(v) for k, v in attrs.items()} + def attributes_to_dict(cls, value: object) -> Dict[str, Any]: + if dataclasses.is_dataclass(value): + return cls.dataclass_to_dict(value) + return dict(value.__dict__) @classmethod - def set_to_data(cls, value: set) -> List[Any]: - return list(value) + def dataclass_to_dict(cls, value: object) -> Dict[str, Any]: + d: Dict[str, Any] = {} + for field in dataclasses.fields(value): + # Check for field flags. + flags = field.metadata.get(cls.Flags) + if isinstance(flags, tuple | list | set): + # Skip excluded fields. + if cls.Flags.ExcludeFromData in flags: + continue + + # Get the field value. + field_value = getattr(value, field.name) + + # Skip optional fields with a null value. + if (field_value is None) and is_field_optional(field): + continue + + # If we get here, include the final field value. + d[field.name] = field_value + + return d + + @classmethod + def value_to_data(cls, value: Any) -> Any: + """Convert a value to data, if possible.""" + if isinstance(value, ToData): + return value.to_data() + if dataclasses.is_dataclass(value): + return cls.dataclass_to_data(value) + if isinstance(value, dict | defaultdict): + return cls.mapping_to_data(value) + if isinstance(value, tuple | list | set): + return cls.collection_to_data(value) + if isinstance(value, datetime): + return cls.datetime_to_data(value) + if isinstance(value, timedelta): + return cls.timedelta_to_data(value) + if isinstance(value, Color): + return cls.color_to_data(value) + return value + + @classmethod + def dataclass_to_data(cls, value: object) -> Dict[str, Any]: + attrs = cls.attributes_to_dict(value) + return cls.mapping_to_data(attrs) + + @classmethod + def mapping_to_data(cls, value: Mapping[Any, Any]) -> Dict[Any, Any]: + return {k: cls.value_to_data(v) for k, v in value.items()} + + @classmethod + def collection_to_data(cls, value: Collection[Any]) -> List[Any]: + return [cls.value_to_data(v) for v in value] @classmethod def datetime_to_data(cls, value: datetime) -> str: @@ -106,23 +173,6 @@ def timedelta_to_data(cls, value: timedelta) -> Dict[str, int]: def color_to_data(cls, value: Color) -> str: return str(value) - @classmethod - def attribute_to_data(cls, value: Any) -> Any: - """Convert an attribute to data, if possible.""" - if isinstance(value, ToData): - return value.to_data() - if dataclasses.is_dataclass(value): - return cls.attributes_to_data(value.__dict__) - if isinstance(value, set): - return cls.set_to_data(value) - if isinstance(value, datetime): - return cls.datetime_to_data(value) - if isinstance(value, timedelta): - return cls.timedelta_to_data(value) - if isinstance(value, Color): - return cls.color_to_data(value) - return value - def to_data(self) -> Any: """Turn the object into raw data.""" # Start with a new, empty copy of data. @@ -132,9 +182,12 @@ def to_data(self) -> Any: if base_fields := self.base_fields_to_data(): data.update(base_fields) - # Update with converted fields from `__dict__`. - converted_attributes = self.attributes_to_data(self.__dict__) - data.update(converted_attributes) + # Get base instance attributes as a dict. + instance_attrs = self.attributes_to_dict(self) + + # Update with converted attributes. + converted_attrs = self.mapping_to_data(instance_attrs) + data.update(converted_attrs) # Update with additional complex fields, if any. if complex_fields := self.complex_fields_to_data(): @@ -151,6 +204,14 @@ def base_fields_to_data(self) -> Optional[Dict[str, Any]]: instances of the class, but should be included in data. """ + def exclude_fields_to_data(self) -> Optional[Tuple[str, ...]]: + """ + Exclude certain fields from data, if any. + + Override this if the inheriting class has certain attributes that should not be + included in data. + """ + def complex_fields_to_data(self) -> Optional[Dict[str, Any]]: """ Convert complex fields into raw data, if any. diff --git a/commanderbot/lib/errors.py b/commanderbot/lib/errors.py new file mode 100644 index 0000000..61d50ca --- /dev/null +++ b/commanderbot/lib/errors.py @@ -0,0 +1,6 @@ +from typing import Any, Type + + +class MalformedData(Exception): + def __init__(self, cls: Type, data: Any): + super().__init__(f"Cannot create `{cls.__name__}` from `{type(data).__name__}`") diff --git a/commanderbot/lib/from_data_mixin.py b/commanderbot/lib/from_data_mixin.py index 5be2f03..5981c00 100644 --- a/commanderbot/lib/from_data_mixin.py +++ b/commanderbot/lib/from_data_mixin.py @@ -1,6 +1,6 @@ from typing import Any, Optional, Type, TypeVar -from commanderbot.lib.data import MalformedData +from commanderbot.lib.errors import MalformedData from commanderbot.lib.json import JsonObject __all__ = ("FromDataMixin",) diff --git a/commanderbot/lib/integer_range.py b/commanderbot/lib/integer_range.py index d871bdf..dd112e2 100644 --- a/commanderbot/lib/integer_range.py +++ b/commanderbot/lib/integer_range.py @@ -22,8 +22,8 @@ class IntegerRange(FromData, ToData): The upper bound of the range. """ - min: Optional[int] - max: Optional[int] + min: Optional[int] = None + max: Optional[int] = None # @overrides FromData @classmethod @@ -31,12 +31,14 @@ def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]: 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"), + args = dict( + min=data[0], + max=data[1], ) + args = {k: v for k, v in args.items() if v is not ...} + return cls(**args) + elif isinstance(data, dict): + return cls(**data) def includes(self, value: int) -> bool: if (self.min is not None) and (value < self.min): diff --git a/commanderbot/lib/types.py b/commanderbot/lib/types.py index a5748a5..4d6d25c 100644 --- a/commanderbot/lib/types.py +++ b/commanderbot/lib/types.py @@ -18,6 +18,7 @@ "IDType", "GuildID", "ChannelID", + "MessageID", "RoleID", "UserID", "RawOptions", @@ -36,6 +37,7 @@ GuildID = IDType ChannelID = IDType +MessageID = IDType RoleID = IDType UserID = IDType @@ -61,6 +63,7 @@ class TextMessage(Message): channel: TextChannel | Thread guild: Guild + author: Member @classmethod async def convert(cls, ctx: Context, argument: Any): diff --git a/commanderbot/lib/utils/__init__.py b/commanderbot/lib/utils/__init__.py index 4e17afd..5ebd9b2 100644 --- a/commanderbot/lib/utils/__init__.py +++ b/commanderbot/lib/utils/__init__.py @@ -1,4 +1,5 @@ from .colors import * +from .dataclasses import * from .datetimes import * from .json_path import * from .timedeltas import * diff --git a/commanderbot/lib/utils/colors.py b/commanderbot/lib/utils/colors.py index 86383a7..e07ac66 100644 --- a/commanderbot/lib/utils/colors.py +++ b/commanderbot/lib/utils/colors.py @@ -3,7 +3,7 @@ from discord import Color from discord.ext.commands import ColourConverter -from commanderbot.lib.data import MalformedData +from commanderbot.lib.errors import MalformedData from commanderbot.lib.types import JsonObject __all__ = ( diff --git a/commanderbot/lib/utils/dataclasses.py b/commanderbot/lib/utils/dataclasses.py new file mode 100644 index 0000000..19db137 --- /dev/null +++ b/commanderbot/lib/utils/dataclasses.py @@ -0,0 +1,13 @@ +import dataclasses +import typing +from typing import Union + +__all__ = ("is_field_optional",) + + +def is_field_optional(field: dataclasses.Field) -> bool: + field_type_origin = typing.get_origin(field.type) + field_type_args = typing.get_args(field.type) + is_union = field_type_origin is Union + is_nullable = type(None) in field_type_args + return is_union and is_nullable diff --git a/commanderbot/lib/utils/datetimes.py b/commanderbot/lib/utils/datetimes.py index 444912e..a7078d1 100644 --- a/commanderbot/lib/utils/datetimes.py +++ b/commanderbot/lib/utils/datetimes.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Any, Optional -from commanderbot.lib.data import MalformedData +from commanderbot.lib.errors import MalformedData from commanderbot.lib.types import JsonObject __all__ = ( diff --git a/commanderbot/lib/utils/timedeltas.py b/commanderbot/lib/utils/timedeltas.py index cca96c4..7ae28b0 100644 --- a/commanderbot/lib/utils/timedeltas.py +++ b/commanderbot/lib/utils/timedeltas.py @@ -1,7 +1,7 @@ from datetime import timedelta from typing import Any, Dict, Optional -from commanderbot.lib.data import MalformedData +from commanderbot.lib.errors import MalformedData from commanderbot.lib.types import JsonObject __all__ = ( diff --git a/commanderbot/lib/utils/yield_fields.py b/commanderbot/lib/utils/yield_fields.py index 56b6d57..97518d5 100644 --- a/commanderbot/lib/utils/yield_fields.py +++ b/commanderbot/lib/utils/yield_fields.py @@ -3,7 +3,7 @@ from discord import Member -from commanderbot.lib.utils import utcnow_aware +from commanderbot.lib.utils.utils import utcnow_aware from commanderbot.lib.value_formatter import ValueFormatter