Skip to content

5.3.0 #1381

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
May 6, 2023
Merged

5.3.0 #1381

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pytest-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
5 changes: 5 additions & 0 deletions interactions/api/events/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
3 changes: 3 additions & 0 deletions interactions/api/events/processors/reaction_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import interactions.api.events as events
from interactions.models import PartialEmoji, Reaction

from ._template import EventMixinTemplate, Processor

if TYPE_CHECKING:
Expand All @@ -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"))

Expand Down
16 changes: 12 additions & 4 deletions interactions/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion interactions/ext/prefixed_commands/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 22 additions & 2 deletions interactions/models/discord/auto_mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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"""
Expand Down Expand Up @@ -345,11 +363,13 @@ def member(self) -> "Optional[Member]":
AutoModAction.BLOCK_MESSAGE: BlockMessage,
AutoModAction.ALERT_MESSAGE: AlertMessage,
AutoModAction.TIMEOUT_USER: TimeoutUser,
AutoModAction.BLOCK_MEMBER_INTERACTION: BlockMemberInteraction,
}

TRIGGER_MAPPING = {
AutoModTriggerType.KEYWORD: KeywordTrigger,
AutoModTriggerType.HARMFUL_LINK: HarmfulLinkFilter,
AutoModTriggerType.KEYWORD_PRESET: KeywordPresetTrigger,
AutoModTriggerType.MENTION_SPAM: MentionSpamTrigger,
AutoModTriggerType.MEMBER_PROFILE: MemberProfileTrigger,
}
5 changes: 5 additions & 0 deletions interactions/models/discord/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions interactions/models/discord/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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):
Expand Down
15 changes: 15 additions & 0 deletions interactions/models/discord/guild.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<id:customize>"

@property
def mention_onboarding_browse(self) -> str:
"""Return a mention string for the browse section of Onboarding"""
return "<id:browse>"

@property
def mention_onboarding_guide(self) -> str:
"""Return a mention string for the guide section of Onboarding"""
return "<id:guide>"

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.
Expand Down
18 changes: 18 additions & 0 deletions interactions/models/discord/message.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import base64
import re
from dataclasses import dataclass
from typing import (
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion interactions/models/discord/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -62,13 +64,17 @@ 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

@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
Expand Down
2 changes: 1 addition & 1 deletion interactions/models/discord/user.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions interactions/models/internal/application_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [],
)
Expand Down
18 changes: 17 additions & 1 deletion interactions/models/internal/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from interactions.models.internal.application_commands import (
OptionType,
CallbackType,
SlashCommandChoice,
SlashCommandOption,
InteractionCommand,
)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions interactions/models/internal/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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))

Expand Down
Loading