diff --git a/.github/workflows/pytest-push.yml b/.github/workflows/pytest-push.yml index d359ada56..178abd365 100644 --- a/.github/workflows/pytest-push.yml +++ b/.github/workflows/pytest-push.yml @@ -42,7 +42,7 @@ jobs: BOT_TOKEN: ${{ secrets.BOT_TOKEN }} RUN_TESTBOT: ${{ matrix.RUN_TESTBOT }} run: | - pytest + pytest --cov=./ --cov-report xml:coverage.xml coverage xml -i - name: Upload Coverage run: | diff --git a/interactions/api/events/base.py b/interactions/api/events/base.py index f5aaccc31..bba1c4c56 100644 --- a/interactions/api/events/base.py +++ b/interactions/api/events/base.py @@ -27,6 +27,11 @@ class BaseEvent: bot: "Client" = attrs.field(repr=False, kw_only=True, default=MISSING) """The client instance that dispatched this event.""" + @property + def client(self) -> "Client": + """The client instance that dispatched this event.""" + return self.bot + @property def resolved_name(self) -> str: """The name of the event, defaults to the class name if not overridden.""" diff --git a/interactions/api/events/processors/reaction_events.py b/interactions/api/events/processors/reaction_events.py index 4cf82ec8a..112a8638a 100644 --- a/interactions/api/events/processors/reaction_events.py +++ b/interactions/api/events/processors/reaction_events.py @@ -2,6 +2,7 @@ import interactions.api.events as events from interactions.models import PartialEmoji, Reaction + from ._template import EventMixinTemplate, Processor if TYPE_CHECKING: @@ -14,6 +15,8 @@ class ReactionEvents(EventMixinTemplate): async def _handle_message_reaction_change(self, event: "RawGatewayEvent", add: bool) -> None: if member := event.data.get("member"): author = self.cache.place_member_data(event.data.get("guild_id"), member) + elif guild_id := event.data.get("guild_id"): + author = await self.cache.fetch_member(guild_id, event.data.get("user_id")) else: author = await self.cache.fetch_user(event.data.get("user_id")) diff --git a/interactions/client/client.py b/interactions/client/client.py index 67ebd5b97..5ef89207c 100644 --- a/interactions/client/client.py +++ b/interactions/client/client.py @@ -571,11 +571,17 @@ async def _async_wrap(_coro: Listener, _event: BaseEvent, *_args, **_kwargs) -> ): await self.wait_until_ready() - if len(_event.__attrs_attrs__) == 2 and coro.event != "event": - # override_name & bot & logging - await _coro() - else: + # don't pass event object if listener doesn't expect it + if _coro.pass_event_object: await _coro(_event, *_args, **_kwargs) + else: + if not _coro.warned_no_event_arg and len(_event.__attrs_attrs__) > 2 and _coro.event != "event": + self.logger.warning( + f"{_coro} is listening to {_coro.event} event which contains event data. " + f"Add an event argument to this listener to receive the event data object." + ) + _coro.warned_no_event_arg = True + await _coro() except asyncio.CancelledError: pass except Exception as e: @@ -1202,6 +1208,8 @@ def add_listener(self, listener: Listener) -> None: self.logger.debug(f"Listener {listener} has already been hooked, not re-hooking it again") return + listener.lazy_parse_params() + if listener.event not in self.listeners: self.listeners[listener.event] = [] self.listeners[listener.event].append(listener) diff --git a/interactions/ext/prefixed_commands/manager.py b/interactions/ext/prefixed_commands/manager.py index 29b9ffcd7..c462f1d0c 100644 --- a/interactions/ext/prefixed_commands/manager.py +++ b/interactions/ext/prefixed_commands/manager.py @@ -239,7 +239,7 @@ async def _register_command(self, event: CallbackAdded) -> None: @listen("extension_unload") async def _handle_ext_unload(self, event: ExtensionUnload) -> None: """Unregisters all prefixed commands in an extension as it is being unloaded.""" - for name in self._ext_command_list[event.extension.extension_name]: + for name in self._ext_command_list[event.extension.extension_name].copy(): self.remove_command(name) @listen("raw_message_create", is_default_listener=True) diff --git a/interactions/models/discord/auto_mod.py b/interactions/models/discord/auto_mod.py index 9886d93f6..c9be2d142 100644 --- a/interactions/models/discord/auto_mod.py +++ b/interactions/models/discord/auto_mod.py @@ -121,7 +121,6 @@ class HarmfulLinkFilter(BaseTrigger): repr=True, metadata=docs("The type of trigger"), ) - ... @attrs.define(eq=False, order=False, hash=False, kw_only=True) @@ -151,12 +150,24 @@ class MentionSpamTrigger(BaseTrigger): ) +@attrs.define(eq=False, order=False, hash=False, kw_only=True) +class MemberProfileTrigger(BaseTrigger): + regex_patterns: list[str] = attrs.field( + factory=list, repr=True, metadata=docs("The regex patterns to check against") + ) + keyword_filter: str | list[str] = attrs.field( + factory=list, repr=True, metadata=docs("The keywords to check against") + ) + allow_list: list["Snowflake_Type"] = attrs.field( + factory=list, repr=True, metadata=docs("The roles exempt from this rule") + ) + + @attrs.define(eq=False, order=False, hash=False, kw_only=True) class BlockMessage(BaseAction): """blocks the content of a message according to the rule""" type: AutoModAction = attrs.field(repr=False, default=AutoModAction.BLOCK_MESSAGE, converter=AutoModAction) - ... @attrs.define(eq=False, order=False, hash=False, kw_only=True) @@ -175,6 +186,13 @@ class TimeoutUser(BaseAction): type: AutoModAction = attrs.field(repr=False, default=AutoModAction.TIMEOUT_USER, converter=AutoModAction) +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class BlockMemberInteraction(BaseAction): + """Block a member from using text, voice, or other interactions""" + + # this action has no metadata + + @attrs.define(eq=False, order=False, hash=False, kw_only=True) class AutoModRule(DiscordObject): """A representation of an auto mod rule""" @@ -345,6 +363,7 @@ def member(self) -> "Optional[Member]": AutoModAction.BLOCK_MESSAGE: BlockMessage, AutoModAction.ALERT_MESSAGE: AlertMessage, AutoModAction.TIMEOUT_USER: TimeoutUser, + AutoModAction.BLOCK_MEMBER_INTERACTION: BlockMemberInteraction, } TRIGGER_MAPPING = { @@ -352,4 +371,5 @@ def member(self) -> "Optional[Member]": AutoModTriggerType.HARMFUL_LINK: HarmfulLinkFilter, AutoModTriggerType.KEYWORD_PRESET: KeywordPresetTrigger, AutoModTriggerType.MENTION_SPAM: MentionSpamTrigger, + AutoModTriggerType.MEMBER_PROFILE: MemberProfileTrigger, } diff --git a/interactions/models/discord/channel.py b/interactions/models/discord/channel.py index 79521aaa2..9eff06738 100644 --- a/interactions/models/discord/channel.py +++ b/interactions/models/discord/channel.py @@ -1901,6 +1901,11 @@ def permission_overwrites(self) -> List["PermissionOverwrite"]: """The permission overwrites for this channel.""" return [] + @property + def clyde_created(self) -> bool: + """Whether this thread was created by Clyde.""" + return ChannelFlags.CLYDE_THREAD in self.flags + def permissions_for(self, instance: Snowflake_Type) -> Permissions: """ Calculates permissions for an instance diff --git a/interactions/models/discord/enums.py b/interactions/models/discord/enums.py index d4d47a2e9..d8a3d2523 100644 --- a/interactions/models/discord/enums.py +++ b/interactions/models/discord/enums.py @@ -457,6 +457,8 @@ class MessageFlags(DiscordIntFlag): # type: ignore """This message contains a abusive website link, pops up a warning when clicked""" SILENT = 1 << 12 """This message should not trigger push or desktop notifications""" + VOICE_MESSAGE = 1 << 13 + """This message is a voice message""" # Special members NONE = 0 @@ -551,6 +553,16 @@ class Permissions(DiscordIntFlag): # type: ignore """Allows for using Activities (applications with the `EMBEDDED` flag) in a voice channel""" MODERATE_MEMBERS = 1 << 40 """Allows for timing out users to prevent them from sending or reacting to messages in chat and threads, and from speaking in voice and stage channels""" + VIEW_CREATOR_MONETIZATION_ANALYTICS = 1 << 41 + """Allows for viewing guild monetization insights""" + USE_SOUNDBOARD = 1 << 42 + """Allows for using the soundboard in a voice channel""" + CREATE_GUILD_EXPRESSIONS = 1 << 43 + """Allows for creating emojis, stickers, and soundboard sounds""" + USE_EXTERNAL_SOUNDS = 1 << 45 + """Allows the usage of custom sounds from other servers""" + SEND_VOICE_MESSAGES = 1 << 46 + """Allows for sending audio messages""" # Shortcuts/grouping/aliases REQUIRES_MFA = ( @@ -780,6 +792,8 @@ class SystemChannelFlags(DiscordIntFlag): class ChannelFlags(DiscordIntFlag): PINNED = 1 << 1 """ Thread is pinned to the top of its parent forum channel """ + CLYDE_THREAD = 1 << 8 + """This thread was created by Clyde""" # Special members NONE = 0 @@ -964,6 +978,7 @@ class AuditLogEventType(CursedIntEnum): ONBOARDING_UPDATE = 167 GUILD_HOME_FEATURE_ITEM = 171 GUILD_HOME_FEATURE_ITEM_UPDATE = 172 + BLOCKED_PHISHING_LINK = 180 SERVER_GUIDE_CREATE = 190 SERVER_GUIDE_UPDATE = 191 @@ -974,16 +989,19 @@ class AutoModTriggerType(CursedIntEnum): SPAM = 3 KEYWORD_PRESET = 4 MENTION_SPAM = 5 + MEMBER_PROFILE = 6 class AutoModAction(CursedIntEnum): BLOCK_MESSAGE = 1 ALERT_MESSAGE = 2 TIMEOUT_USER = 3 + BLOCK_MEMBER_INTERACTION = 4 class AutoModEvent(CursedIntEnum): MESSAGE_SEND = 1 + MEMBER_UPDATE = 2 class AutoModLanuguageType(Enum): diff --git a/interactions/models/discord/guild.py b/interactions/models/discord/guild.py index dc18c2df8..cdb72883c 100644 --- a/interactions/models/discord/guild.py +++ b/interactions/models/discord/guild.py @@ -439,6 +439,21 @@ def voice_states(self) -> List["models.VoiceState"]: # noinspection PyProtectedMember return [v_state for v_state in self._client.cache.voice_state_cache.values() if v_state._guild_id == self.id] + @property + def mention_onboarding_customize(self) -> str: + """Return a mention string for the customise section of Onboarding""" + return "" + + @property + def mention_onboarding_browse(self) -> str: + """Return a mention string for the browse section of Onboarding""" + return "" + + @property + def mention_onboarding_guide(self) -> str: + """Return a mention string for the guide section of Onboarding""" + return "" + async def fetch_member(self, member_id: Snowflake_Type, *, force: bool = False) -> Optional["models.Member"]: """ Return the Member with the given discord ID, fetching from the API if necessary. diff --git a/interactions/models/discord/message.py b/interactions/models/discord/message.py index 4ec906ae0..84959fc0e 100644 --- a/interactions/models/discord/message.py +++ b/interactions/models/discord/message.py @@ -1,4 +1,5 @@ import asyncio +import base64 import re from dataclasses import dataclass from typing import ( @@ -89,12 +90,22 @@ class Attachment(DiscordObject): """width of file (if image)""" ephemeral: bool = attrs.field(repr=False, default=False) """whether this attachment is ephemeral""" + duration_secs: Optional[int] = attrs.field(repr=False, default=None) + """the duration of the audio file (currently for voice messages)""" + waveform: bytearray = attrs.field(repr=False, default=None) + """base64 encoded bytearray representing a sampled waveform (currently for voice messages)""" @property def resolution(self) -> tuple[Optional[int], Optional[int]]: """Returns the image resolution of the attachment file""" return self.height, self.width + @classmethod + def _process_dict(cls, data: Dict[str, Any], _) -> Dict[str, Any]: + if waveform := data.pop("waveform", None): + data["waveform"] = bytearray(base64.b64decode(waveform)) + return data + @attrs.define(eq=False, order=False, hash=False, kw_only=True) class ChannelMention(DiscordObject): @@ -369,6 +380,13 @@ def thread(self) -> "models.TYPE_THREAD_CHANNEL": """The thread that was started from this message, if any""" return self._client.cache.get_channel(self.id) + @property + def editable(self) -> bool: + """Whether this message can be edited by the current user""" + if self.author.id == self._client.user.id: + return MessageFlags.VOICE_MESSAGE not in self.flags + return False + async def fetch_referenced_message(self, *, force: bool = False) -> Optional["Message"]: """ Fetch the message this message is referencing, if any. diff --git a/interactions/models/discord/user.py b/interactions/models/discord/user.py index 1cc0b8bc7..0860e27bd 100644 --- a/interactions/models/discord/user.py +++ b/interactions/models/discord/user.py @@ -51,7 +51,9 @@ class BaseUser(DiscordObject, _SendDMMixin): global_name: str | None = attrs.field( repr=True, metadata=docs("The user's chosen display name, platform-wide"), default=None ) - discriminator: int = attrs.field(repr=True, metadata=docs("The user's 4-digit discord-tag")) + discriminator: str = attrs.field( + repr=True, metadata=docs("The user's 4-digit discord-tag"), default="0" + ) # will likely be removed in future api version avatar: "Asset" = attrs.field(repr=False, metadata=docs("The user's default avatar")) def __str__(self) -> str: @@ -62,6 +64,8 @@ def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any] if not isinstance(data["avatar"], Asset): if data["avatar"]: data["avatar"] = Asset.from_path_hash(client, f"avatars/{data['id']}/{{}}", data["avatar"]) + elif data["discriminator"] == "0": + data["avatar"] = Asset(client, f"{Asset.BASE}/embed/avatars/{(int(data['id']) >> 22) % 5}") else: data["avatar"] = Asset(client, f"{Asset.BASE}/embed/avatars/{int(data['discriminator']) % 5}") return data @@ -69,6 +73,8 @@ def _process_dict(cls, data: Dict[str, Any], client: "Client") -> Dict[str, Any] @property def tag(self) -> str: """Returns the user's Discord tag.""" + if self.discriminator == "0": + return f"@{self.username}" return f"{self.username}#{self.discriminator}" @property diff --git a/interactions/models/discord/user.pyi b/interactions/models/discord/user.pyi index 4e02ae2f0..a55d5ec9b 100644 --- a/interactions/models/discord/user.pyi +++ b/interactions/models/discord/user.pyi @@ -39,7 +39,7 @@ class _SendDMMixin(SendMixin): class FakeBaseUserMixin(DiscordObject, _SendDMMixin): username: str global_name: str | None - discriminator: int + discriminator: str avatar: Asset def __str__(self) -> str: ... @classmethod diff --git a/interactions/models/internal/application_commands.py b/interactions/models/internal/application_commands.py index b30343e27..d7444309d 100644 --- a/interactions/models/internal/application_commands.py +++ b/interactions/models/internal/application_commands.py @@ -720,6 +720,7 @@ def group( group_name=name, group_description=description, scopes=self.scopes, + default_member_permissions=self.default_member_permissions, dm_permission=self.dm_permission, checks=self.checks.copy() if inherit_checks else [], ) diff --git a/interactions/models/internal/context.py b/interactions/models/internal/context.py index bb30b51c1..2cf93be64 100644 --- a/interactions/models/internal/context.py +++ b/interactions/models/internal/context.py @@ -36,6 +36,7 @@ from interactions.models.internal.application_commands import ( OptionType, CallbackType, + SlashCommandChoice, SlashCommandOption, InteractionCommand, ) @@ -780,6 +781,16 @@ async def edit_origin( self.message_id = message.id return message + @property + def component(self) -> typing.Optional[BaseComponent]: + """The component that was interacted with.""" + if self.message is None or self.message.components is None: + return None + for action_row in self.message.components: + for component in action_row.components: + if component.custom_id == self.custom_id: + return component + class ModalContext(InteractionContext): responses: dict[str, str] @@ -852,7 +863,9 @@ def option_processing_hook(self, option: dict) -> None: self.focussed_option = SlashCommandOption.from_dict(option) return - async def send(self, choices: typing.Iterable[str | int | float | dict[str, int | float | str]]) -> None: + async def send( + self, choices: typing.Iterable[str | int | float | dict[str, int | float | str] | SlashCommandChoice] + ) -> None: """ Send your autocomplete choices to discord. Choices must be either a list of strings, or a dictionary following the following format: @@ -882,6 +895,9 @@ async def send(self, choices: typing.Iterable[str | int | float | dict[str, int if isinstance(choice, dict): name = choice["name"] value = choice["value"] + elif isinstance(choice, SlashCommandChoice): + name = choice.name + value = choice.value else: name = str(choice) value = choice diff --git a/interactions/models/internal/extension.py b/interactions/models/internal/extension.py index f30571278..9d1f95864 100644 --- a/interactions/models/internal/extension.py +++ b/interactions/models/internal/extension.py @@ -101,7 +101,7 @@ def __new__(cls, bot: "Client", *args, **kwargs) -> "Extension": for _name, val in callables: if isinstance(val, models.BaseCommand): val.extension = instance - val = wrap_partial(val, instance) + val = val.copy_with_binding(instance) bot.add_command(val) instance._commands.append(val) @@ -110,12 +110,12 @@ def __new__(cls, bot: "Client", *args, **kwargs) -> "Extension": elif isinstance(val, models.Listener): val.extension = instance - val = wrap_partial(val, instance) + val = val.copy_with_binding(instance) bot.add_listener(val) # type: ignore instance._listeners.append(val) elif isinstance(val, models.GlobalAutoComplete): val.extension = instance - val = wrap_partial(val, instance) + val = val.copy_with_binding(instance) bot.add_global_autocomplete(val) bot.dispatch(events.ExtensionCommandParse(extension=instance, callables=callables)) diff --git a/interactions/models/internal/listener.py b/interactions/models/internal/listener.py index f36e28653..a4c09d6b6 100644 --- a/interactions/models/internal/listener.py +++ b/interactions/models/internal/listener.py @@ -30,6 +30,7 @@ def __init__( delay_until_ready: bool = False, is_default_listener: bool = False, disable_default_listeners: bool = False, + pass_event_object: Absent[bool] = MISSING, ) -> None: super().__init__() @@ -42,6 +43,10 @@ def __init__( self.is_default_listener = is_default_listener self.disable_default_listeners = disable_default_listeners + self._params = inspect.signature(func).parameters.copy() + self.pass_event_object = pass_event_object + self.warned_no_event_arg = False + def __repr__(self) -> str: return f"" @@ -98,6 +103,17 @@ def wrapper(coro: AsyncCallable) -> "Listener": return wrapper + def lazy_parse_params(self): + """Process the parameters of this listener.""" + if self.pass_event_object is not MISSING: + return + + if self.has_binding: + # discard the first parameter, which is the class instance + self._params = list(self._params.values())[1:] + + self.pass_event_object = len(self._params) != 0 + def listen( event_name: Absent[str | BaseEvent] = MISSING, diff --git a/pyproject.toml b/pyproject.toml index 0d656d5c3..b1205f5d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "interactions.py" -version = "5.2.0" +version = "5.3.0" description = "Easy, simple, scalable and modular: a Python API wrapper for interactions." authors = [ "LordOfPolls ", @@ -29,7 +29,6 @@ mkdocs-minify-plugin = { version = "*", optional = true } mkdocs-git-committers-plugin-2 = { version = "*", optional = true } mkdocs-git-revision-date-localized-plugin = { version = "*", optional = true } pytest = { version = "*", optional = true } -pytest-recording = { version = "*", optional = true } pytest-asyncio = { version = "*", optional = true } pytest-cov = { version = "*", optional = true } python-dotenv = { version = "*", optional = true } @@ -66,7 +65,6 @@ mkdocs-git-revision-date-localized-plugin = "*" [tool.poetry.group.tests.dependencies] pytest = "*" -pytest-recording = "*" pytest-asyncio = "*" pytest-cov = "*" python-dotenv = "*" @@ -116,7 +114,7 @@ source = [ ] [tool.pytest.ini_options] -addopts = "-l -ra --durations=2 --cov=./ --cov-report xml:coverage.xml --junitxml=TestResults.xml" +addopts = "-l -ra --durations=2 --junitxml=TestResults.xml" doctest_optionflags = "NORMALIZE_WHITESPACE" asyncio_mode="auto" log_cli = "1" diff --git a/setup.py b/setup.py index fafad2f90..eded233a5 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,6 @@ ] extras_require["tests"] = [ "pytest", - "pytest-recording", "pytest-asyncio", "pytest-cov", "python-dotenv",