diff --git a/discord/client.py b/discord/client.py index fcb48566c..e0c28dac1 100644 --- a/discord/client.py +++ b/discord/client.py @@ -39,7 +39,7 @@ from .template import Template from .widget import Widget from .guild import Guild from .emoji import Emoji -from .channel import _threaded_channel_factory, PartialMessageable +from .channel import _private_channel_factory, _threaded_channel_factory, GroupChannel, PartialMessageable from .enums import ChannelType, Status, VoiceRegion, try_enum from .mentions import AllowedMentions from .errors import * @@ -55,7 +55,6 @@ from .backoff import ExponentialBackoff from .webhook import Webhook from .iterators import GuildIterator from .appinfo import AppInfo -from .ui.view import View from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory @@ -323,7 +322,6 @@ class Client: def _schedule_event(self, coro: Callable[..., Coroutine[Any, Any, Any]], event_name: str, *args: Any, **kwargs: Any) -> asyncio.Task: wrapped = self._run_event(coro, event_name, *args, **kwargs) - # Schedules the task return asyncio.create_task(wrapped, name=f'discord.py: {event_name}') def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None: @@ -402,7 +400,6 @@ class Client: initial: :class:`bool` Whether this IDENTIFY is the first initial IDENTIFY. """ - pass # Login state management @@ -1607,15 +1604,28 @@ class Client: """ data = await self.http.get_sticker(sticker_id) cls, _ = _sticker_factory(data['type']) # type: ignore - return cls(state=self._connection, data=data) # type: ignore + return cls(state=self._connection, data=data) # type: ignore - async def fetch_premium_sticker_packs(self) -> List[StickerPack]: + async def fetch_sticker_packs( + self, *, country='US', locale='en-US', *, payment_source_id: int = MISSING + ) -> List[StickerPack]: """|coro| - Retrieves all available premium sticker packs. + Retrieves all available default sticker packs. .. versionadded:: 2.0 + Parameters + ----------- + country: :class:`str` + ISO 3166 country code to fetch the sticker packs for. + Defaults to ``US``. + locale: :class:`str` + ISO 639 language code the name and description should be in. + Defaults to ``en-US``. + payment_source_id: :class:`int` + Unknown. + Raises ------- :exc:`.HTTPException` @@ -1626,7 +1636,7 @@ class Client: List[:class:`.StickerPack`] All available premium sticker packs. """ - data = await self.http.list_premium_sticker_packs() + data = await self.http.list_premium_sticker_packs(country, locale, payment_source_id) return [StickerPack(state=self._connection, data=pack) for pack in data['sticker_packs']] async def fetch_notes(self) -> List[Note]: @@ -1645,7 +1655,7 @@ class Client: All your notes. """ state = self._connection - data = await self.http.get_notes() + data = await state.http.get_notes() return [Note(state, int(id), note=note) for id, note in data.items()] async def fetch_note(self, user_id: int) -> Note: @@ -1668,11 +1678,28 @@ class Client: :class:`Note` The note you requested. """ - try: - data = await self.http.get_note(user_id) - except NotFound: - data = {'note': 0} - return Note(self._connection, int(user_id), note=data['note']) + note = Note(self._connection, int(user_id)) + await note.fetch() + return note + + async def fetch_private_channels(self) -> List[PrivateChannel]: + """|coro + + Retrieves all your private channels. + + Raises + ------- + :exc:`.HTTPException` + Retreiving your private channels failed. + + Returns + -------- + List[:class:`PrivateChannel`] + All your private channels. + """ + channels = await self._state.http.get_private_channels() + state = self._connection + return [_private_channel_factory(data['type'])(me=self.user, data=data, state=state) for data in channels] async def create_dm(self, user: Snowflake) -> DMChannel: """|coro| @@ -1701,3 +1728,31 @@ class Client: data = await state.http.start_private_message(user.id) return state.add_dm_channel(data) + + async def create_group(self, *recipients) -> GroupChannel: + r"""|coro| + + Creates a group direct message with the recipients + provided. These recipients must be have a relationship + of type :attr:`RelationshipType.friend`. + + Parameters + ----------- + \*recipients: :class:`User` + An argument :class:`list` of :class:`User` to have in + your group. + + Raises + ------- + HTTPException + Failed to create the group direct message. + + Returns + ------- + :class:`GroupChannel` + The new group channel. + """ + users = [str(u.id) for u in recipients] + state = self._connection + data = await state.http.start_group(users) + return GroupChannel(me=self.user, data=data, state=state) diff --git a/discord/user.py b/discord/user.py index 57c032ac5..b1db6d003 100644 --- a/discord/user.py +++ b/discord/user.py @@ -24,20 +24,28 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import Any, Dict, List, Optional, Type, TypeVar, TYPE_CHECKING +from copy import copy +from typing import Any, Dict, List, Optional, Type, TypeVar, TYPE_CHECKING, Union import discord.abc +from types.snowflake import Snowflake from .asset import Asset from .colour import Colour -from .enums import DefaultAvatar +from .enums import DefaultAvatar, HypeSquadHouse, PremiumType, RelationshipAction, RelationshipType, try_enum, UserFlags +from .errors import ClientException, NotFound from .flags import PublicUserFlags -from .utils import snowflake_time, _bytes_to_base64_data, MISSING +from .object import Object +from .relationship import Relationship +from .settings import Settings +from .utils import _bytes_to_base64_data, cached_slot_property, parse_time, snowflake_time, MISSING if TYPE_CHECKING: from datetime import datetime + from .call import Call from .channel import DMChannel from .guild import Guild + from .member import VoiceState from .message import Message from .state import ConnectionState from .types.channel import DMChannel as DMChannelPayload @@ -52,6 +60,251 @@ __all__ = ( BU = TypeVar('BU', bound='BaseUser') +class Note: + """Represents a Discord note.""" + __slots__ = ('_state', '_note', '_user_id', '_user') + + def __init__( + self, state: ConnectionState, user_id: int, *, user: BaseUser = MISSING, note: Optional[str] = MISSING + ) -> None: + self._state = state + self._user_id = user_id + self._note = note + if user is not MISSING: + self._user: Union[User, Object] = user + + @property + def note(self) -> Optional[str]: + """Returns the note. + + Raises + ------- + ClientException + Attempted to access note without fetching it. + """ + if self._note is MISSING: + raise ClientException('Note is not fetched') + return self._note + + @cached_slot_property('_user') + def user(self) -> Union[User, Object]: + """Returns the :class:`User` the note belongs to. + + If the user isn't in the cache, it returns a + :class:`Object` instead. + """ + user_id = self._user_id + + user = self._state.get_user(user_id) + if user is None: + user = Object(user_id) + return user + + async def fetch(self) -> Optional[str]: + """|coro| + + Retrieves the note. + + Raises + ------- + HTTPException + Fetching the note failed. + + Returns + -------- + Optional[:class:`str`] + The note or ``None`` if it doesn't exist. + """ + try: + data = await self._state.http.get_note(self.user.id) + self._note = data['note'] + return data['note'] + except NotFound: # 404 = no note :( + self._note = None + return None + + async def edit(self, note: Optional[str]) -> None: + """|coro| + + Changes the note. + + Raises + ------- + HTTPException + Changing the note failed. + """ + await self._state.http.set_note(self._user_id, note=note) + self._note = note + + async def delete(self) -> None: + """|coro| + + A shortcut to :meth:`.edit` that deletes the note. + + Raises + ------- + HTTPException + Deleting the note failed. + """ + await self.edit(None) + + def __str__(self) -> str: + note = self._note + if note is MISSING: + raise ClientException('Note is not fetched') + elif note is None: + return '' + else: + return note + + def __repr__(self) -> str: + base = f'' + else: + base += '>' + return base + + def __len__(self) -> int: + try: + return len(self._note) + except TypeError: + return 0 + + def __eq__(self, other) -> bool: + try: + return isinstance(other, Note) and self._note == other._note + except TypeError: + return False + + def __ne__(self, other) -> bool: + return not self.__eq__(other) + + def __bool__(self) -> bool: + try: + return bool(self._note) + except TypeError: + return False + + +class Profile: + """Represents a Discord profile. + + Attributes + ---------- + flags: :class:`int` + The user's flags. Will be its own class (like public_flags) in the future. + bio: Optional[:class:`str`] + The user's "about me" field. Could be ``None``. + user: :class:`User` + The user the profile represents (with banner/accent_colour). + premium_since: Optional[:class:`datetime.datetime`] + A datetime object denoting how long a user has been premium (had Nitro). + Could be ``None``. + connected_accounts: Optional[List[:class:`dict`]] + The connected accounts that show up on the profile. + These are currently just the raw json, but this will change in the future. + note: :class:`Note` + Represents the note on the profile. + """ + + __slots__ = ( + '_state', + 'user', + 'flags', + 'bio', + 'premium_since', + 'connected_accounts', + 'note', + 'mutual_guilds', + 'mutual_friends', + ) + + def __init__(self, state: ConnectionState, data) -> None: # Type data + self._state = state + + user = data['user'] + self.flags: int = user.pop('flags', 0) # TODO: figure them all out and parse them + self.bio: Optional[str] = user.pop('bio') or None + self.user: User = User(data=user, state=state) + + self.premium_since: datetime = parse_time(data['premium_since']) + self.connected_accounts: List[dict] = data['connected_accounts'] # TODO: parse these + + self.note: Note = Note(state, self.user.id, user=self.user) + + if 'mutual_guilds' in data: + self.mutual_guilds: List[Guild] = self._parse_mutual_guilds(data['mutual_guilds']) + if 'mutual_friends' in data: # TODO: maybe make Relationships + self.mutual_friends: List[User] = self._parse_mutual_friends(data['mutual_friends']) + + def __str__(self) -> str: + return '{0.name}#{0.discriminator}'.format(self.user) + + def __repr__(self) -> str: + return ''.format(self) + + def _parse_mutual_guilds(self, mutual_guilds) -> List[Guild]: + state = self._state + + def get_guild(guild) -> Optional[Guild]: + return state._get_guild(int(guild['id'])) + + return list(filter(None, map(get_guild, mutual_guilds))) + + def _parse_mutual_friends(self, mutual_friends) -> List[User]: + state = self._state + return [state.store_user(friend) for friend in mutual_friends] + + @property + def nitro(self) -> bool: + return self.premium_since is not None + + premium = nitro + + def _has_flag(self, o) -> bool: + v = o.value + return (self.flags & v) == v + + @property + def staff(self) -> bool: + return self._has_flag(UserFlags.staff) + + @property + def partner(self) -> bool: + return self._has_flag(UserFlags.partner) + + @property + def bug_hunter(self) -> bool: + return self._has_flag(UserFlags.bug_hunter) + + @property + def early_supporter(self) -> bool: + return self._has_flag(UserFlags.early_supporter) + + @property + def hypesquad(self) -> bool: + return self._has_flag(UserFlags.hypesquad) + + @property + def hypesquad_house(self) -> HypeSquadHouse: + return self.hypesquad_houses[0] + + @property + def hypesquad_houses(self) -> List[HypeSquadHouse]: + flags = (UserFlags.hypesquad_bravery, UserFlags.hypesquad_brilliance, UserFlags.hypesquad_balance) + return [house for house, flag in zip(HypeSquadHouse, flags) if self._has_flag(flag)] + + @property + def team_user(self) -> bool: + return self._has_flag(UserFlags.team_user) + + @property + def system(self) -> bool: + return self._has_flag(UserFlags.system) + + class _UserTag: __slots__ = () id: int @@ -139,8 +392,14 @@ class BaseUser(_UserTag): 'avatar': self._avatar, 'discriminator': self.discriminator, 'bot': self.bot, + 'system': self.system, } + @property + def voice(self) -> Optional[VoiceState]: + """Optional[:class:`VoiceState`]: Returns the user's current voice state.""" + return self._state._voice_state_for(self.id) + @property def public_flags(self) -> PublicUserFlags: """:class:`PublicUserFlags`: The publicly available flags the user has.""" @@ -305,7 +564,11 @@ class ClientUser(BaseUser): id: :class:`int` The user's unique ID. discriminator: :class:`str` - The user's discriminator. This is given when the username has conflicts. + The user's discriminator. + bio: Optional[:class:`str`] + The user's "about me" field. Could be ``None``. + avatar: Optional[:class:`str`] + The avatar hash the user has. Could be ``None``. bot: :class:`bool` Specifies if the user is a bot account. system: :class:`bool` @@ -315,38 +578,112 @@ class ClientUser(BaseUser): verified: :class:`bool` Specifies if the user's email is verified. + email: Optional[:class:`str`] + The email of the user. + phone: Optional[:class:`int`] + The phone number of the user. + + .. versionadded:: 1.9 + locale: Optional[:class:`str`] The IETF language tag used to identify the language the user is using. mfa_enabled: :class:`bool` Specifies if the user has MFA turned on and working. + premium: :class:`bool` + Specifies if the user is a premium user (i.e. has Discord Nitro). + premium_type: Optional[:class:`PremiumType`] + Specifies the type of premium a user has (i.e. Nitro or Nitro Classic). Could be None if the user is not premium. + note: :class:`Note` + The user's note. Not pre-fetched. """ __slots__ = ('locale', '_flags', 'verified', 'mfa_enabled', '__weakref__') if TYPE_CHECKING: verified: bool + email: Optional[str] + phone: Optional[int] locale: Optional[str] - mfa_enabled: bool _flags: int + mfa_enabled: bool + premium: bool + premium_type: Optional[PremiumType] + bio: Optional[str] def __init__(self, *, state: ConnectionState, data: UserPayload) -> None: super().__init__(state=state, data=data) + self.note: Note = Note(state, self.id) def __repr__(self) -> str: return ( f'' + f' bot={self.bot} verified={self.verified} mfa_enabled={self.mfa_enabled} premium={self.premium}>' ) def _update(self, data: UserPayload) -> None: super()._update(data) # There's actually an Optional[str] phone field as well but I won't use it self.verified = data.get('verified', False) + self.email = data.get('email') + self.phone = data.get('phone') self.locale = data.get('locale') self._flags = data.get('flags', 0) self.mfa_enabled = data.get('mfa_enabled', False) + self.premium = data.get('premium', False) + self.premium_type = try_enum(PremiumType, data.get('premium_type', None)) + self.bio = data.get('bio') or None + + def get_relationship(self, user_id: int) -> Relationship: + """Retrieves the :class:`Relationship` if applicable. - async def edit(self, *, username: str = MISSING, avatar: bytes = MISSING) -> ClientUser: + Parameters + ----------- + user_id: :class:`int` + The user ID to check if we have a relationship with them. + + Returns + -------- + Optional[:class:`Relationship`] + The relationship if available or ``None``. + """ + return self._state._relationships.get(user_id) + + @property + def relationships(self) -> List[Relationship]: + """List[:class:`User`]: Returns all the relationships that the user has.""" + return list(self._state._relationships.values()) + + @property + def friends(self) -> List[Relationship]: + r"""List[:class:`User`]: Returns all the users that the user is friends with.""" + return [r.user for r in self._state._relationships.values() if r.type is RelationshipType.friend] + + @property + def blocked(self) -> List[Relationship]: + r"""List[:class:`User`]: Returns all the users that the user has blocked.""" + return [r.user for r in self._state._relationships.values() if r.type is RelationshipType.blocked] + + @property + def settings(self) -> Optional[Settings]: + """Optional[:class:`Settings`]: Returns the user's settings.""" + return self._state.settings + + async def edit( + self, + *, + username: str = MISSING, + avatar: Optional[bytes] = MISSING, + password: str = MISSING, + new_password: str = MISSING, + email: str = MISSING, + house: Optional[HypeSquadHouse] = MISSING, + discriminator: Snowflake = MISSING, + banner: Optional[bytes] = MISSING, + accent_colour: Colour = MISSING, + accent_color: Colour = MISSING, + bio: Optional[str] = MISSING, + date_of_birth: datetime = MISSING, + ) -> ClientUser: """|coro| Edits the current profile of the client. @@ -358,43 +695,265 @@ class ClientUser(BaseUser): then the file must be opened via ``open('some_filename', 'rb')`` and the :term:`py:bytes-like object` is given through the use of ``fp.read()``. - The only image formats supported for uploading is JPEG and PNG. - .. versionchanged:: 2.0 The edit is no longer in-place, instead the newly edited client user is returned. Parameters ----------- + password: :class:`str` + The current password for the client's account. + Required for everything except avatar, banner, accent_colour, date_of_birth, and bio. + new_password: :class:`str` + The new password you wish to change to. + email: :class:`str` + The new email you wish to change to. + house: Optional[:class:`HypeSquadHouse`] + The hypesquad house you wish to change to. + Could be ``None`` to leave the current house. username: :class:`str` The new username you wish to change to. - avatar: :class:`bytes` + discriminator: :class:`int` + The new discriminator you wish to change to. + Can only be used if you have Nitro. + avatar: Optional[:class:`bytes`] A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no avatar. + banner: :class:`bytes` + A :term:`py:bytes-like object` representing the image to upload. + Could be ``None`` to denote no banner. + accent_colour/_color: :class:`Colour` + A :class:`Colour` object of the colour you want to set your profile to. + bio: :class:`str` + Your 'about me' section. + Could be ``None`` to represent no 'about me'. + date_of_birth: :class:`datetime.datetime` + Your date of birth. Can only ever be set once. Raises ------ HTTPException Editing your profile failed. - InvalidArgument - Wrong image format passed for ``avatar``. + ClientException + Password was not passed when it was required. + `house` field was not a :class:`HypeSquadHouse`. + `date_of_birth` field was not a :class:`datetime.datetime`. + `accent_colo(u)r` parameter was not a :class:`Colour`. Returns --------- :class:`ClientUser` The newly edited client user. """ - payload: Dict[str, Any] = {} - if username is not MISSING: - payload['username'] = username + args: Dict[str, any] = {} + + if any(x is not MISSING for x in ('new_password', 'email', 'username', 'discriminator')): + if password is MISSING: + raise ClientException('Password is required') + args['password'] = password if avatar is not MISSING: - payload['avatar'] = _bytes_to_base64_data(avatar) + if avatar is not None: + args['avatar'] = _bytes_to_base64_data(avatar) + else: + args['avatar'] = None + + if banner is not MISSING: + if banner is not None: + args['banner'] = _bytes_to_base64_data(banner) + else: + args['banner'] = None + + if accent_color is not MISSING or accent_colour is not MISSING: + colour = accent_colour if accent_colour is not MISSING else accent_color + if colour is None: + args['accent_color'] = colour + elif not isinstance(colour, Colour): + raise ClientException('`accent_colo(u)r` parameter was not a Colour') + else: + args['accent_color'] = accent_color.value + + if email is not MISSING: + args['email'] = email + + if username is not MISSING: + args['username'] = username + + if discriminator is not MISSING: + args['discriminator'] = discriminator + + if new_password is not MISSING: + args['new_password'] = new_password + + if bio is not MISSING: + args['bio'] = bio or '' + + if date_of_birth is not MISSING: + if not isinstance(date_of_birth, datetime): + raise ClientException('`date_of_birth` parameter was not a datetime') + args['date_of_birth'] = date_of_birth.strftime('%F') + + http = self._state.http + + if house is not MISSING: + if house is None: + await http.leave_hypesquad_house() + elif not isinstance(house, HypeSquadHouse): + raise ClientException('`house` parameter was not a HypeSquadHouse') + else: + await http.change_hypesquad_house(house.value) + + data = await http.edit_profile(**args) + try: + http._token(data['token']) + except KeyError: + pass - data: UserPayload = await self._state.http.edit_profile(payload) return ClientUser(state=self._state, data=data) + async def fetch_settings(self) -> Settings: + """|coro| + + Retrieves your settings. + + .. note:: + + This method is an API call. For general usage, consider :attr:`settings` instead. + + Raises + ------- + HTTPException + Retrieving your settings failed. + + Returns + -------- + :class:`Settings` + The current settings for your account. + """ + data = await self._state.http.get_settings() + return Settings(data=data, state=self._state) + + async def edit_settings(self, **kwargs) -> Settings: # TODO: I really wish I didn't have to do this... + """|coro| + + Edits the client user's settings. + + Parameters + ---------- + afk_timeout: :class:`int` + How long (in seconds) the user needs to be AFK until Discord + sends push notifications to your mobile device. + allow_accessibility_detection: :class:`bool` + Whether or not to allow Discord to track screen reader usage. + animate_emojis: :class:`bool` + Whether or not to animate emojis in the chat. + animate_stickers: :class:`StickerAnimationOptions` + Whether or not to animate stickers in the chat. + contact_sync_enabled: :class:`bool` + Whether or not to enable the contact sync on Discord mobile. + convert_emoticons: :class:`bool` + Whether or not to automatically convert emoticons into emojis. + e.g. :-) -> 😃 + default_guilds_restricted: :class:`bool` + Whether or not to automatically disable DMs between you and + members of new guilds you join. + detect_platform_accounts: :class:`bool` + Whether or not to automatically detect accounts from services + like Steam and Blizzard when you open the Discord client. + developer_mode: :class:`bool` + Whether or not to enable developer mode. + disable_games_tab: :class:`bool` + Whether or not to disable the showing of the Games tab. + enable_tts_command: :class:`bool` + Whether or not to allow tts messages to be played/sent. + explicit_content_filter: :class:`UserContentFilter` + The filter for explicit content in all messages. + friend_source_flags: :class:`FriendFlags` + Who can add you as a friend. + gif_auto_play: :class:`bool` + Whether or not to automatically play gifs that are in the chat. + guild_positions: List[:class:`abc.Snowflake`] + A list of guilds in order of the guild/guild icons that are on + the left hand side of the UI. + inline_attachment_media: :class:`bool` + Whether or not to display attachments when they are uploaded in chat. + inline_embed_media: :class:`bool` + Whether or not to display videos and images from links posted in chat. + locale: :class:`str` + The :rfc:`3066` language identifier of the locale to use for the language + of the Discord client. + message_display_compact: :class:`bool` + Whether or not to use the compact Discord display mode. + native_phone_integration_enabled: :class:`bool` + Whether or not to enable the new Discord mobile phone number friend + requesting features. + render_embeds: :class:`bool` + Whether or not to render embeds that are sent in the chat. + render_reactions: :class:`bool` + Whether or not to render reactions that are added to messages. + restricted_guilds: List[:class:`abc.Snowflake`] + A list of guilds that you will not receive DMs from. + show_current_game: :class:`bool` + Whether or not to display the game that you are currently playing. + stream_notifications_enabled: :class:`bool` + Unknown. + theme: :class:`Theme` + The theme of the Discord UI. + timezone_offset: :class:`int` + The timezone offset to use. + view_nsfw_guilds: :class:`bool` + Whether or not to show NSFW guilds on iOS. + + Raises + ------- + HTTPException + Editing the settings failed. + + Returns + ------- + :class:`.Settings` + The client user's updated settings. + """ + payload = {} + + content_filter = kwargs.pop('explicit_content_filter', None) + if content_filter: + payload['explicit_content_filter'] = content_filter.value + + animate_stickers = kwargs.pop('animate_stickers', None) + if animate_stickers: + payload['animate_stickers'] = animate_stickers.value + + friend_flags = kwargs.pop('friend_source_flags', None) + if friend_flags: + payload['friend_source_flags'] = friend_flags.to_dict() + + guild_positions = kwargs.pop('guild_positions', None) + if guild_positions: + guild_positions = [str(x.id) for x in guild_positions] + payload['guild_positions'] = guild_positions + + restricted_guilds = kwargs.pop('restricted_guilds', None) + if restricted_guilds: + restricted_guilds = [str(x.id) for x in restricted_guilds] + payload['restricted_guilds'] = restricted_guilds + + status = kwargs.pop('status', None) + if status: + payload['status'] = status.value + + theme = kwargs.pop('theme', None) + if theme: + payload['theme'] = theme.value + + payload.update(kwargs) + + state = self._state + data = await state.http.edit_settings(**payload) + state.settings = settings = Settings(data=data, state=self._state) + return settings + -class User(BaseUser, discord.abc.Messageable): +class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable): """Represents a Discord user. .. container:: operations @@ -422,7 +981,7 @@ class User(BaseUser, discord.abc.Messageable): id: :class:`int` The user's unique ID. discriminator: :class:`str` - The user's discriminator. This is given when the username has conflicts. + The user's discriminator. bot: :class:`bool` Specifies if the user is a bot account. system: :class:`bool` @@ -451,6 +1010,12 @@ class User(BaseUser, discord.abc.Messageable): self._stored = False return self + def _get_voice_client_key(self) -> Union[int, str]: + return self._state.user.id, 'self_id' + + def _get_voice_state_pair(self) -> Union[int, int]: + return self._state.user.id, self.dm_channel.id + async def _get_channel(self) -> DMChannel: ch = await self.create_dm() return ch @@ -464,6 +1029,15 @@ class User(BaseUser, discord.abc.Messageable): """ return self._state._get_private_channel_by_user(self.id) + @property + def call(self) -> Optional[Call]: + return getattr(self.dm_channel, 'call', None) + + @property + def relationship(self): + """Optional[:class:`Relationship`]: Returns the :class:`Relationship` with this user if applicable, ``None`` otherwise.""" + return self._state.user.get_relationship(self.id) + @property def mutual_guilds(self) -> List[Guild]: """List[:class:`Guild`]: The guilds that the user shares with the client. @@ -476,6 +1050,15 @@ class User(BaseUser, discord.abc.Messageable): """ return [guild for guild in self._state._guilds.values() if guild.get_member(self.id)] + async def connect(self, *, ring=True, **kwargs): + channel = await self._get_channel() + call = self.call + if call is not None: + ring = False + await super().connect(_channel=channel, **kwargs) + if ring: + await channel._initial_ring() + async def create_dm(self) -> DMChannel: """|coro| @@ -496,3 +1079,114 @@ class User(BaseUser, discord.abc.Messageable): state = self._state data: DMChannelPayload = await state.http.start_private_message(self.id) return state.add_dm_channel(data) + + def is_friend(self) -> bool: + """:class:`bool`: Checks if the user is your friend.""" + r = self.relationship + if r is None: + return False + return r.type is RelationshipType.friend + + def is_blocked(self) -> bool: + """:class:`bool`: Checks if the user is blocked.""" + r = self.relationship + if r is None: + return False + return r.type is RelationshipType.blocked + + async def block(self) -> None: # TODO: maybe return relationship + """|coro| + + Blocks the user. + + Raises + ------- + Forbidden + Not allowed to block this user. + HTTPException + Blocking the user failed. + """ + await self._state.http.add_relationship(self.id, type=RelationshipType.blocked.value, action=RelationshipAction.block) + + async def unblock(self) -> None: + """|coro| + + Unblocks the user. + + Raises + ------- + Forbidden + Not allowed to unblock this user. + HTTPException + Unblocking the user failed. + """ + await self._state.http.remove_relationship(self.id, action=RelationshipAction.unblock) + + async def remove_friend(self) -> bool: + """|coro| + + Removes the user as a friend. + + Raises + ------- + Forbidden + Not allowed to remove this user as a friend. + HTTPException + Removing the user as a friend failed. + """ + await self._state.http.remove_relationship(self.id, action=RelationshipAction.unfriend) + + async def send_friend_request(self) -> None: # TODO: maybe return relationship + """|coro| + + Sends the user a friend request. + + Raises + ------- + Forbidden + Not allowed to send a friend request to the user. + HTTPException + Sending the friend request failed. + """ + await self._state.http.send_friend_request(self.name, self.discriminator) + + async def profile( + self, *, with_mutuals: bool = True, fetch_note: bool = True + ) -> Profile: + """|coro| + + Gets the user's profile. + + Parameters + ------------ + with_mutuals: :class:`bool` + Whether to fetch mutual guilds and friends. + This fills in :attr:`mutual_guilds` & :attr:`mutual_friends`. + fetch_note: :class:`bool` + Whether to pre-fetch the user's note. + + Raises + ------- + Forbidden + Not allowed to fetch this profile. + HTTPException + Fetching the profile failed. + + Returns + -------- + :class:`Profile` + The profile of the user. + """ + user_id = self.id + state = self._state + 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) + + profile = Profile(state, data) + + if fetch_note: + await profile.note.fetch() + + return profile