diff --git a/discord/abc.py b/discord/abc.py index fa267d8b1..578f4e937 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -43,9 +43,10 @@ from typing import ( runtime_checkable, ) -from .iterators import HistoryIterator +from .object import Object +from .iterators import CommandIterator, HistoryIterator from .context_managers import Typing -from .enums import ChannelType +from .enums import CommandType, ChannelType from .errors import InvalidArgument, ClientException from .mentions import AllowedMentions from .permissions import PermissionOverwrite, Permissions @@ -69,8 +70,7 @@ __all__ = ( T = TypeVar('T', bound=VoiceProtocol) if TYPE_CHECKING: - from datetime import datetime - + from .abc import PrivateChannel from .client import Client from .user import ClientUser, User from .asset import Asset @@ -80,7 +80,7 @@ if TYPE_CHECKING: from .channel import CategoryChannel from .embeds import Embed from .message import Message, MessageReference, PartialMessage - from .channel import DMChannel, GroupChannel, PartialMessageable, PrivateChannel, TextChannel, VocalGuildChannel + from .channel import DMChannel, GroupChannel, PartialMessageable, TextChannel, VocalGuildChannel from .threads import Thread from .enums import InviteTarget from .types.channel import ( @@ -347,7 +347,7 @@ class GuildChannel: options['permission_overwrites'] = [c._asdict() for c in category._overwrites] options['parent_id'] = parent_id elif lock_permissions and self.category_id is not None: - # if we're syncing permissions on a pre-existing channel category without changing it + # If we're syncing permissions on a pre-existing channel category without changing it # we need to update the permissions to point to the pre-existing category category = self.guild.get_channel(self.category_id) if category: @@ -447,6 +447,14 @@ class GuildChannel: """:class:`datetime.datetime`: Returns the channel's creation time in UTC.""" return utils.snowflake_time(self.id) + @property + def jump_url(self) -> str: + """:class:`str`: Returns a URL that allows the client to jump to the channel. + + .. versionadded:: 2.0 + """ + return f'https://discord.com/channels/{self.guild.id}/{self.id}' + def overwrites_for(self, obj: Union[Role, User]) -> PermissionOverwrite: """Returns the channel-specific overwrites for a member or a role. @@ -478,7 +486,7 @@ class GuildChannel: return PermissionOverwrite() @property - def overwrites(self) -> Dict[Union[Role, Member], PermissionOverwrite]: + def overwrites(self) -> Dict[Union[Object, Role, Member], PermissionOverwrite]: """Returns all of the channel's overwrites. This is returned as a dictionary where the key contains the target which @@ -487,7 +495,7 @@ class GuildChannel: Returns -------- - Dict[Union[:class:`~discord.Role`, :class:`~discord.Member`], :class:`~discord.PermissionOverwrite`] + Dict[Union[:class:`~discord.Object`, :class:`~discord.Role`, :class:`~discord.Member`], :class:`~discord.PermissionOverwrite`] The channel's permission overwrites. """ ret = {} @@ -502,13 +510,10 @@ class GuildChannel: elif ow.is_member(): target = self.guild.get_member(ow.id) - # TODO: There is potential data loss here in the non-chunked - # case, i.e. target is None because get_member returned nothing. - # This can be fixed with a slight breaking change to the return type, - # i.e. adding discord.Object to the list of it - # However, for now this is an acceptable compromise. - if target is not None: - ret[target] = overwrite + if target is None: + target = Object(ow.id) + + ret[target] = overwrite return ret @property @@ -570,18 +575,18 @@ class GuildChannel: """ # The current cases can be explained as: - # Guild owner get all permissions -- no questions asked. Otherwise... - # The @everyone role gets the first application. + # Guild owner get all permissions -- no questions asked + # The @everyone role gets the first application # After that, the applied roles that the user has in the channel - # (or otherwise) are then OR'd together. + # (or otherwise) are then OR'd together # After the role permissions are resolved, the member permissions - # have to take into effect. - # After all that is done.. you have to do the following: + # have to take into effect + # After all that is done, you have to do the following: - # If manage permissions is True, then all permissions are set to True. + # If manage permissions is True, then all permissions are set to True # The operation first takes into consideration the denied - # and then the allowed. + # and then the allowed if self.guild.owner_id == obj.id: return Permissions.all() @@ -828,7 +833,7 @@ class GuildChannel: data = await self._state.http.create_channel(guild_id, self.type.value, reason=reason, **base_attrs) obj = cls(state=self._state, guild=self.guild, data=data) - # temporarily add it to the cache + # Temporarily add it to the cache self.guild._channels[obj.id] = obj # type: ignore return obj @@ -1232,7 +1237,7 @@ class Messageable: self, content=None, *, - tts=None, + tts=False, embed=None, embeds=None, file=None, @@ -1336,7 +1341,7 @@ class Messageable: content = str(content) if content is not None else None if embed is not None and embeds is not None: - raise InvalidArgument('Cannot pass both embed and embeds parameter to send()') + raise InvalidArgument('Cannot pass both embed and embeds') if embed is not None: embed = embed.to_dict() @@ -1368,10 +1373,10 @@ class Messageable: raise InvalidArgument('reference parameter must be Message, MessageReference, or PartialMessage') from None if nonce is MISSING: - nonce = utils.time_snowflake(datetime.utcnow()) + nonce = str(utils.time_snowflake(datetime.utcnow())) if file is not None and files is not None: - raise InvalidArgument('Cannot pass both file and files parameter to send()') + raise InvalidArgument('Cannot pass both file and files') if file is not None: if not isinstance(file, File): @@ -1590,6 +1595,81 @@ class Messageable: """ return HistoryIterator(self, limit=limit, before=before, after=after, around=around, oldest_first=oldest_first) + def slash_commands( + self, + query: Optional[str] = None, + *, + limit: Optional[int] = None, + command_ids: Optional[List[int]] = None, + applications: bool = True, + application: Optional[Snowflake] = None, + ): + """Returns an iterator that allows you to see what slash commands are available to use. + + .. note:: + If this is a DM context, all parameters here are faked, as the only way to get commands is to fetch them all at once. + Because of this, all except ``query``, ``limit``, and ``command_ids`` are ignored. + It is recommended to not pass any parameters in that case. + + Examples + --------- + + Usage :: + + async for command in channel.slash_commands(): + print(command.name) + + Flattening into a list :: + + commands = await channel.slash_commands().flatten() + # commands is now a list of SlashCommand... + + All parameters are optional. + + Parameters + ---------- + query: Optional[:class:`str`] + The query to search for. + limit: Optional[:class:`int`] + The maximum number of commands to send back. + cache: :class:`bool` + Whether to cache the commands internally. + command_ids: Optional[List[:class:`int`]] + List of command IDs to search for. If the command doesn't exist it won't be returned. + applications: :class:`bool` + Whether to include applications in the response. This defaults to ``False``. + application: Optional[:class:`Snowflake`] + Query commands only for this application. + + Raises + ------ + :exc:`.InvalidArgument` + The user is not a bot. + The limit was not > 0. + Both query and command_ids were passed. + :exc:`.HTTPException` + Getting the commands failed. + + Yields + ------- + :class:`.SlashCommand` + A slash command. + """ + if query and command_ids: + raise InvalidArgument('Cannot specify both query and command_ids') + if limit is not None and limit <= 0: + raise InvalidArgument('limit must be > 0') + + return CommandIterator( + self, + CommandType.chat_input, + query, + limit, + command_ids, + applications=applications, + application=application, + ) + class Connectable(Protocol): """An ABC that details the common operations on a channel that can diff --git a/discord/channel.py b/discord/channel.py index 3481fd3df..0645da3e6 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -24,7 +24,6 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -import asyncio from typing import ( Any, Callable, @@ -51,7 +50,7 @@ from .object import Object from . import utils from .utils import MISSING from .asset import Asset -from .errors import ClientException, InvalidArgument, NotFound +from .errors import ClientException, InvalidArgument from .stage_instance import StageInstance from .threads import Thread from .iterators import ArchivedThreadIterator @@ -86,7 +85,6 @@ if TYPE_CHECKING: StoreChannel as StoreChannelPayload, GroupDMChannel as GroupChannelPayload, ) - from .types.snowflake import SnowflakeList class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): @@ -1751,6 +1749,14 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """:class:`datetime.datetime`: Returns the direct message channel's creation time in UTC.""" return utils.snowflake_time(self.id) + @property + def jump_url(self) -> str: + """:class:`str`: Returns a URL that allows the client to jump to the channel. + + .. versionadded:: 2.0 + """ + return f'https://discord.com/channels/@me/{self.id}' + def permissions_for(self, obj: Any = None, /) -> Permissions: """Handles permission resolution for a :class:`User`. @@ -1936,6 +1942,14 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """:class:`datetime.datetime`: Returns the channel's creation time in UTC.""" return utils.snowflake_time(self.id) + @property + def jump_url(self) -> str: + """:class:`str`: Returns a URL that allows the client to jump to the channel. + + .. versionadded:: 2.0 + """ + return f'https://discord.com/channels/@me/{self.id}' + def permissions_for(self, obj: Snowflake, /) -> Permissions: """Handles permission resolution for a :class:`User`. diff --git a/discord/member.py b/discord/member.py index 7130f45b2..ac8d6d19a 100644 --- a/discord/member.py +++ b/discord/member.py @@ -39,9 +39,11 @@ from .utils import MISSING from .user import BaseUser, User, _UserTag from .activity import create_activity, ActivityTypes from .permissions import Permissions -from .enums import RelationshipAction, Status, try_enum +from .enums import CommandType, RelationshipAction, Status, try_enum +from .errors import InvalidArgument from .colour import Colour from .object import Object +from .iterators import CommandIterator __all__ = ( 'VoiceState', @@ -720,12 +722,13 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): guild_id = self.guild.id me = self._state.self_id == self.id payload: Dict[str, Any] = {} + data = None if nick is not MISSING: payload['nick'] = nick if avatar is not MISSING: - payload['avatar'] = utils._bytes_to_base64_data(avatar) + payload['avatar'] = utils._bytes_to_base64_data(avatar) # type: ignore if me and payload: data = await http.edit_me(**payload) @@ -763,7 +766,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): data = await http.edit_member(guild_id, self.id, reason=reason, **payload) if data: - return Member(data=data, guild=self.guild, state=self._state) + return Member(data=data, guild=self.guild, state=self._state) # type: ignore async def request_to_speak(self) -> None: """|coro| @@ -934,3 +937,72 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): Sending the friend request failed. """ await self._state.http.add_relationship(self._user.id, action=RelationshipAction.send_friend_request) + + def user_commands( + self, + query: Optional[str] = None, + *, + limit: Optional[int] = None, + command_ids: Optional[List[int]] = None, + applications: bool = True, + application: Optional[Snowflake] = None, + ): + """Returns an iterator that allows you to see what user commands are available to use. + + Examples + --------- + + Usage :: + + async for command in member.user_commands(): + print(command.name) + + Flattening into a list :: + + commands = await member.user_commands().flatten() + # commands is now a list of UserCommand... + + All parameters are optional. + + Parameters + ---------- + query: Optional[:class:`str`] + The query to search for. + limit: Optional[:class:`int`] + The maximum number of commands to send back. + cache: :class:`bool` + Whether to cache the commands internally. + command_ids: Optional[List[:class:`int`]] + List of command IDs to search for. If the command doesn't exist it won't be returned. + applications: :class:`bool` + Whether to include applications in the response. This defaults to ``False``. + application: Optional[:class:`Snowflake`] + Query commands only for this application. + + Raises + ------ + :exc:`.InvalidArgument` + The limit was not > 0. + Both query and command_ids were passed. + :exc:`.HTTPException` + Getting the commands failed. + + Yields + ------- + :class:`.UserCommand` + A user command. + """ + if query and command_ids: + raise InvalidArgument('Cannot specify both query and command_ids') + if limit is not None and limit <= 0: + raise InvalidArgument('limit must be > 0') + + return CommandIterator( + self, + CommandType.user, + query, + limit, + command_ids, + applications=applications, + application=application, + ) diff --git a/discord/message.py b/discord/message.py index 9eeef2248..b997f62cc 100644 --- a/discord/message.py +++ b/discord/message.py @@ -36,9 +36,9 @@ from .reaction import Reaction from .emoji import Emoji from .partial_emoji import PartialEmoji from .calls import CallMessage -from .enums import MessageType, ChannelType, try_enum +from .enums import MessageType, ChannelType, CommandType, try_enum from .errors import InvalidArgument, HTTPException -from .components import _component_factory, Interaction +from .components import _component_factory from .embeds import Embed from .member import Member from .flags import MessageFlags @@ -48,6 +48,8 @@ from .guild import Guild from .mixins import Hashable from .sticker import StickerItem from .threads import Thread +from .iterators import CommandIterator +from .interactions import Interaction if TYPE_CHECKING: from .types.message import ( @@ -1602,6 +1604,79 @@ class Message(Hashable): return await self.channel.send(content, reference=self, **kwargs) + def message_commands( + self, + query: Optional[str] = None, + *, + limit: Optional[int] = None, + command_ids: Optional[List[int]] = None, + applications: bool = True, + application: Optional[Snowflake] = None, + ): + """Returns an iterator that allows you to see what message commands are available to use. + + .. note:: + If this is a DM context, all parameters here are faked, as the only way to get commands is to fetch them all at once. + Because of this, all except ``query``, ``limit``, and ``command_ids`` are ignored. + It is recommended to not pass any parameters in that case. + + Examples + --------- + + Usage :: + + async for command in message.message_commands(): + print(command.name) + + Flattening into a list :: + + commands = await message.message_commands().flatten() + # commands is now a list of SlashCommand... + + All parameters are optional. + + Parameters + ---------- + query: Optional[:class:`str`] + The query to search for. + limit: Optional[:class:`int`] + The maximum number of commands to send back. + command_ids: Optional[List[:class:`int`]] + List of command IDs to search for. If the command doesn't exist it won't be returned. + applications: :class:`bool` + Whether to include applications in the response. This defaults to ``False``. + application: Optional[:class:`Snowflake`] + Query commands only for this application. + + Raises + ------ + :exc:`.InvalidArgument` + The user is not a bot. + The limit was not > 0. + Both query and command_ids were passed. + :exc:`.HTTPException` + Getting the commands failed. + + Yields + ------- + :class:`.MessageCommand` + A message command. + """ + if query and command_ids: + raise InvalidArgument('Cannot specify both query and command_ids') + if limit is not None and limit <= 0: + raise InvalidArgument('limit must be > 0') + + return CommandIterator( + self, + CommandType.message, + query, + limit, + command_ids, + applications=applications, + application=application, + ) + def to_reference(self, *, fail_if_not_exists: bool = True) -> MessageReference: """Creates a :class:`~discord.MessageReference` from the current message. diff --git a/discord/user.py b/discord/user.py index a17490ace..30a3530c2 100644 --- a/discord/user.py +++ b/discord/user.py @@ -24,15 +24,15 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from copy import copy from typing import Any, Dict, List, Optional, Type, TypeVar, TYPE_CHECKING, Union import discord.abc from .asset import Asset from .colour import Colour -from .enums import DefaultAvatar, HypeSquadHouse, PremiumType, RelationshipAction, RelationshipType, try_enum, UserFlags -from .errors import ClientException, NotFound +from .enums import CommandType, DefaultAvatar, HypeSquadHouse, PremiumType, RelationshipAction, RelationshipType, try_enum, UserFlags +from .errors import ClientException, InvalidArgument, NotFound from .flags import PublicUserFlags +from .iterators import FakeCommandIterator from .object import Object from .relationship import Relationship from .settings import UserSettings @@ -531,7 +531,6 @@ class BaseUser(_UserTag): :class:`bool` Indicates if the user is mentioned in the message. """ - if message.mention_everyone: return True @@ -1099,6 +1098,69 @@ class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable): data: DMChannelPayload = await state.http.start_private_message(self.id) return state.add_dm_channel(data) + def user_commands( + self, + query: Optional[str] = None, + *, + limit: Optional[int] = None, + command_ids: Optional[List[int]] = [], + **kwargs, + ): + """Returns an iterator that allows you to see what user commands are available to use on this user. + + Only available on bots. + + .. note:: + + All parameters here are faked, as the only way to get commands in a DM is to fetch them all at once. + Because of this, some are silently ignored. The ones below currently work. + It is recommended to not pass any parameters to this iterator. + + Examples + --------- + + Usage :: + + async for command in user.user_commands(): + print(command.name) + + Flattening into a list :: + + commands = await user.user_commands().flatten() + # commands is now a list of UserCommand... + + All parameters are optional. + + Parameters + ---------- + query: Optional[:class:`str`] + The query to search for. + limit: Optional[:class:`int`] + The maximum number of commands to send back. Defaults to ``None`` to iterate over all results. Must be at least 1. + command_ids: Optional[List[:class:`int`]] + List of command IDs to search for. If the command doesn't exist it won't be returned. + + Raises + ------ + :exc:`.InvalidArgument` + The user is not a bot. + The limit was not > 0. + Both query and command_ids were passed. + :exc:`.HTTPException` + Getting the commands failed. + + Yields + ------- + :class:`.UserCommand` + A user command. + """ + if query and command_ids: + raise InvalidArgument('Cannot specify both query and command_ids') + if limit is not None and limit <= 0: + raise InvalidArgument('limit must be > 0') + + return FakeCommandIterator(self, CommandType.user, query, limit, command_ids) + def is_friend(self) -> bool: """:class:`bool`: Checks if the user is your friend.""" r = self.relationship @@ -1201,7 +1263,10 @@ class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable): data = await state.http.get_user_profile(user_id, with_mutual_guilds=with_mutuals) if with_mutuals: - data['mutual_friends'] = await self.http.get_mutual_friends(user_id) + if not data['user'].get('bot', False): + data['mutual_friends'] = await self.http.get_mutual_friends(user_id) + else: + data['mutual_friends'] = [] profile = Profile(state, data)