diff --git a/discord/abc.py b/discord/abc.py index a696de267..fa267d8b1 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -54,6 +54,7 @@ from .invite import Invite from .file import File from .voice_client import VoiceClient, VoiceProtocol from .sticker import GuildSticker, StickerItem +from .settings import ChannelSettings from . import utils __all__ = ( @@ -82,7 +83,6 @@ if TYPE_CHECKING: from .channel import DMChannel, GroupChannel, PartialMessageable, PrivateChannel, TextChannel, VocalGuildChannel from .threads import Thread from .enums import InviteTarget - from .ui.view import View from .types.channel import ( PermissionOverwrite as PermissionOverwritePayload, Channel as ChannelPayload, @@ -414,6 +414,13 @@ class GuildChannel: if tmp: tmp[everyone_index], tmp[0] = tmp[0], tmp[everyone_index] + @property + def notification_settings(self) -> ChannelSettings: + """:class:`ChannelSettings`: Returns the notification settings for this channel""" + guild = self.guild + # guild.notification_settings will always be present at this point + return guild.notification_settings._channel_overrides.get(self.id) or ChannelSettings(guild.id, state=self._state) # type: ignore + @property def changed_roles(self) -> List[Role]: """List[:class:`~discord.Role`]: Returns a list of roles that have been overridden from diff --git a/discord/enums.py b/discord/enums.py index 1d91d1a26..d7bc06932 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -367,7 +367,7 @@ class DefaultAvatar(Enum): return self.name -class RelationshipType(Enum): +class RelationshipType(Enum, comparable=True): friend = 1 blocked = 2 incoming_request = 3 @@ -376,8 +376,15 @@ class RelationshipType(Enum): class NotificationLevel(Enum, comparable=True): all_messages = 0 + all = 0 only_mentions = 1 + nothing = 2 + none = 2 + server_default = 3 + default = 3 + def __int__(self): + return self.value class AuditLogActionCategory(Enum): create = 1 @@ -557,7 +564,7 @@ class HypeSquadHouse(Enum): balance = 3 -class PremiumType(Enum): +class PremiumType(Enum, comparable=True): nitro_classic = 1 nitro = 2 @@ -614,7 +621,7 @@ class ReportType(Enum): return self.value -class RelationshipAction(Enum): +class RelationshipAction(Enum, comparable=True): send_friend_request = 'request' unfriend = 'unfriend' accept_request = 'accept' @@ -624,12 +631,12 @@ class RelationshipAction(Enum): remove_pending_request = 'remove' -class UnavailableGuildType(Enum): +class UnavailableGuildType(Enum, comparable=True): existing = 'ready' joined = 'joined' -class RequiredActionType(Enum): +class RequiredActionType(Enum, comparable=True): verify_phone = 'REQUIRE_VERIFIED_PHONE' verify_email = 'REQUIRE_VERIFIED_EMAIL' captcha = 'REQUIRE_CAPTCHA' diff --git a/discord/guild.py b/discord/guild.py index a8d2aefb1..b88f0e382 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -77,6 +77,7 @@ from .stage_instance import StageInstance from .threads import Thread, ThreadMember from .sticker import GuildSticker from .file import File +from .settings import GuildSettings __all__ = ( @@ -237,6 +238,10 @@ class Guild(Hashable): premium_progress_bar_enabled: :class:`bool` Whether the guild has the premium progress bar enabled. + .. versionadded:: 2.0 + notification_settings: :class:`GuildSettings` + The notification settings for the guild. + .. versionadded:: 2.0 """ @@ -265,6 +270,7 @@ class Guild(Hashable): 'owner_application_id', 'vanity_code', 'premium_progress_bar_enabled', + 'notification_settings', '_members', '_channels', '_icon', @@ -304,6 +310,7 @@ class Guild(Hashable): self._threads: Dict[int, Thread] = {} self._stage_instances: Dict[int, StageInstance] = {} self._state: ConnectionState = state + self.notification_settings: Optional[GuildSettings] = None self._from_data(data) # Get it running @@ -476,6 +483,9 @@ class Guild(Hashable): large = None if member_count is None else member_count >= 250 self._large: Optional[bool] = guild.get('large', large) + if (settings := guild.get('settings')) is not None: + self.notification_settings = GuildSettings(state=state, data=settings) + for mdata in guild.get('merged_members', []): try: member = Member(data=mdata, guild=self, state=state) @@ -490,9 +500,6 @@ class Guild(Hashable): if member is not None: member._presence_update(presence, empty_tuple) - def _update_settings(self, data) -> None: - pass # TODO - @property def channels(self) -> List[GuildChannel]: """List[:class:`abc.GuildChannel`]: A list of channels that belongs to this guild.""" diff --git a/discord/http.py b/discord/http.py index 6305a4b72..27b51e21e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1194,7 +1194,7 @@ class HTTPClient: return self.request(Route('PATCH', '/guilds/{guild_id}', guild_id=guild_id), json=payload, reason=reason) - def edit_guild_settings(self, guild_id: Snowflake, **fields): # TODO: type and add more than just muting + def edit_guild_settings(self, guild_id: Snowflake, fields): # TODO: type return self.request(Route('PATCH', '/users/@me/guilds/{guild_id}/settings', guild_id=guild_id), json=fields) def get_template(self, code: str) -> Response[template.Template]: diff --git a/discord/settings.py b/discord/settings.py index 359a53eb1..0514e4531 100644 --- a/discord/settings.py +++ b/discord/settings.py @@ -24,16 +24,24 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union -from .enums import FriendFlags, StickerAnimationOptions, Theme, UserContentFilter, try_enum +from .enums import FriendFlags, NotificationLevel, StickerAnimationOptions, Theme, UserContentFilter, try_enum from .guild_folder import GuildFolder +from .utils import MISSING, parse_time, utcnow if TYPE_CHECKING: + from .abc import GuildChannel from .guild import Guild from .state import ConnectionState + from .tracking import Tracking -__all__ = ('UserSettings',) +__all__ = ( + 'ChannelSettings', + 'GuildSettings', + 'UserSettings', +) class UserSettings: @@ -161,6 +169,11 @@ class UserSettings: else: setattr(self, '_' + key, value) + @property + def tracking(self) -> Optional[Tracking]: + """Returns your tracking settings if available.""" + return self._state.consents + @property def animate_stickers(self) -> StickerAnimationOptions: """Whether or not to animate stickers in the chat.""" @@ -201,3 +214,253 @@ class UserSettings: def _get_guild(self, id: int) -> Optional[Guild]: return self._state._get_guild(int(id)) + + +class MuteConfig: + def __init__(self, muted: bool, config: Dict[str, Union[str, int]]) -> None: + until = parse_time(config.get('end_time')) + if until is not None: + if until <= utcnow(): + muted = False + until = None + + self.muted: bool = muted + self.until: Optional[datetime] = until + + for item in {'__bool__', '__eq__', '__float__', '__int__', '__str__'}: + setattr(self, item, getattr(muted, item)) + + def __repr__(self) -> str: + return f'' + + def __bool__(self) -> bool: + return bool(self.muted) + + def __eq__(self, other) -> bool: + return self.muted == other + + def __ne__(self, other) -> bool: + return not self.muted == other + + +class ChannelSettings: + """Represents a channel's notification settings""" + + if TYPE_CHECKING: + _channel_id: int + level: NotificationLevel + muted: MuteConfig + collapsed: bool + + def __init__(self, guild_id, *, data: Dict[str, any] = {}, state: ConnectionState) -> None: + self._guild_id: int = guild_id + self._state = state + self._update(data) + + def _update(self, data: Dict[str, Any]) -> None: + breakpoint() + self._channel_id = int(data['channel_id']) + self.collapsed = data.get('collapsed', False) + + self.level = try_enum(NotificationLevel, data.get('message_notifications', 3)) # type: ignore + self.muted = MuteConfig(data.get('muted', False), data.get('mute_config') or {}) + + @property + def channel(self) -> Optional[GuildChannel]: + """Optional[:class:`GuildChannel]: Returns the channel these settings are for.""" + guild = self._state._get_guild(self._guild_id) + return guild and guild.get_channel(self._channel_id) + + async def edit(self, + *, + muted: bool = MISSING, + duration: Optional[int] = MISSING, + collapsed: bool = MISSING, + level: NotificationLevel = MISSING, + ) -> Optional[ChannelSettings]: + """|coro| + + Edits the channel's notification settings. + + All parameters are optional. + + Parameters + ----------- + muted: :class:`bool` + Indicates if the channel should be muted or not. + duration: Optional[Union[:class:`int`, :class:`float`]] + The amount of time in hours that the channel should be muted for. + Defaults to indefinite. + collapsed: :class:`bool` + Unknown. + level: :class:`NotificationLevel` + Determines what level of notifications you receive for the channel. + + Raises + ------- + HTTPException + Editing the settings failed. + + Returns + -------- + Optional[:class:`ChannelSettings`] + The new notification settings. This is only returned if something is updated. + """ + payload = {} + + if muted is not MISSING: + payload['muted'] = muted + + if duration is not MISSING: + if muted is MISSING: + payload['muted'] = True + + if duration is not None: + mute_config = { + 'selected_time_window': duration * 3600, + 'end_time': (datetime.utcnow() + timedelta(hours=duration)).isoformat() + } + payload['mute_config'] = mute_config + + if collapsed is not MISSING: + payload['collapsed'] = collapsed + + if level is not MISSING: + payload['message_notifications'] = level.value + + if payload: + fields = {'channel_overrides': {str(self._channel_id): payload}} + data = self._state.http.edit_guild_settings(self._guild_id, fields) + + if data: + return ChannelSettings( + self._guild_id, + data=data['channel_overrides'][str(self._channel_id)], + state=self._state + ) + + +class GuildSettings: + """Represents a guild's notification settings.""" + + if TYPE_CHECKING: + _channel_overrides: Dict[int, ChannelSettings] + _guild_id: int + version: int + muted: MuteConfig + suppress_everyone: bool + suppress_roles: bool + hide_muted_channels: bool + mobile_push_notifications: bool + level: NotificationLevel + + def __init__(self, *, data: Dict[str, Any], state: ConnectionState) -> None: + self._state = state + self._update(data) + + def _update(self, data: Dict[str, Any]) -> None: + self._guild_id = guild_id = int(data['guild_id']) + self.version = data.get('version', -1) # Overriden by real data + self.suppress_everyone = data.get('suppress_everyone', False) + self.suppress_roles = data.get('suppress_roles', False) + self.hide_muted_channels = data.get('hide_muted_channels', False) + self.mobile_push_notifications = data.get('mobile_push', True) + + self.level = try_enum(NotificationLevel, data.get('message_notifications', 3)) + self.muted = MuteConfig(data.get('muted', False), data.get('mute_config') or {}) + self._channel_overrides = overrides = {} + state = self._state + for override in data.get('channel_overrides', []): + channel_id = int(override['channel_id']) + overrides[channel_id] = ChannelSettings(guild_id, data=override, state=state) + + @property + def guild(self) -> Optional[Guild]: + """Optional[:class:`Guild`]: Returns the guild that these settings are for.""" + return self._state._get_guild(self._guild_id) + + @property + def channel_overrides(self) -> List[ChannelSettings]: + """List[:class:`ChannelSettings`: Returns a list of all the overrided channel notification settings.""" + return list(self._channel_overrides.values()) + + async def edit( + self, + muted: bool = MISSING, + duration: Optional[int] = MISSING, + level: NotificationLevel = MISSING, + suppress_everyone: bool = MISSING, + suppress_roles: bool = MISSING, + mobile_push_notifications: bool = MISSING, + hide_muted_channels: bool = MISSING, + ) -> Optional[GuildSettings]: + """|coro| + + Edits the guild's notification settings. + + All parameters are optional. + + Parameters + ----------- + muted: :class:`bool` + Indicates if the guild should be muted or not. + duration: Optional[Union[:class:`int`, :class:`float`]] + The amount of time in hours that the guild should be muted for. + Defaults to indefinite. + level: :class:`NotificationLevel` + Determines what level of notifications you receive for the guild. + suppress_everyone: :class:`bool` + Indicates if @everyone mentions should be suppressed for the guild. + suppress_roles: :class:`bool` + Indicates if role mentions should be suppressed for the guild. + mobile_push_notifications: :class:`bool` + Indicates if push notifications should be sent to mobile devices for this guild. + hide_muted_channels: :class:`bool` + Indicates if channels that are muted should be hidden from the sidebar. + + Raises + ------- + HTTPException + Editing the settings failed. + + Returns + -------- + Optional[:class:`GuildSettings`] + The new notification settings. This is only returned if something is updated. + """ + payload = {} + + if muted is not MISSING: + payload['muted'] = muted + + if duration is not MISSING: + if muted is MISSING: + payload['muted'] = True + + if duration is not None: + mute_config = { + 'selected_time_window': duration * 3600, + 'end_time': (datetime.utcnow() + timedelta(hours=duration)).isoformat() + } + payload['mute_config'] = mute_config + + if level is not MISSING: + payload['message_notifications'] = level.value + + if suppress_everyone is not MISSING: + payload['suppress_everyone'] = suppress_everyone + + if suppress_roles is not MISSING: + payload['suppress_roles'] = suppress_roles + + if mobile_push_notifications is not MISSING: + payload['mobile_push'] = mobile_push_notifications + + if hide_muted_channels is not MISSING: + payload['hide_muted_channels'] = hide_muted_channels + + if payload: + data = self._state.http.edit_guild_settings(self._guild_id, payload) + + if data: + return GuildSettings(data=data, state=self._state) diff --git a/discord/state.py b/discord/state.py index ee7773eab..164dc3af5 100644 --- a/discord/state.py +++ b/discord/state.py @@ -58,6 +58,7 @@ from .stage_instance import StageInstance from .threads import Thread, ThreadMember from .sticker import GuildSticker from .settings import UserSettings +from .tracking import Tracking if TYPE_CHECKING: @@ -232,7 +233,11 @@ class ConnectionState: def clear(self) -> None: self.user: Optional[ClientUser] = None self.settings: Optional[UserSettings] = None + self.consents: Optional[Tracking] = None self.analytics_token: Optional[str] = None + self.session_id: Optional[str] = None + self.connected_accounts: Optional[List[dict]] = None + self.preferred_region: Optional[VoiceRegion] = None # Originally, this code used WeakValueDictionary to maintain references to the # global user mapping @@ -591,9 +596,8 @@ class ConnectionState: self.clear() - # Merge with READY data - extra_data = data - data = self._ready_data + extra_data, data = data, self._ready_data + guild_settings = data.get('user_guild_settings', {}).get('entries', []) # Discord bad for guild_data, guild_extra, merged_members, merged_me, merged_presences in zip( @@ -603,6 +607,11 @@ class ConnectionState: data.get('merged_members', []), extra_data['merged_presences'].get('guilds', []) ): + guild_data['settings'] = utils.find( + lambda i: i['guild_id'] == guild_data['id'], + guild_settings, + ) or {'guild_id': guild_data['id']} + guild_data['voice_states'] = guild_extra.get('voice_states', []) guild_data['merged_members'] = merged_me guild_data['merged_members'].extend(merged_members) @@ -646,9 +655,12 @@ class ConnectionState: self._add_private_channel(factory(me=user, data=pm, state=self)) # Extras + self.session_id = data.get('session_id') + self.analytics_token = data.get('analytics_token') region = data.get('geo_ordered_rtc_regions', ['us-west'])[0] self.preferred_region = try_enum(VoiceRegion, region) - self.settings = UserSettings(data=data.get('user_settings', {}), state=self) + self.settings = settings = UserSettings(data=data.get('user_settings', {}), state=self) + self.consents = Tracking(data.get('consents', {})) # We're done del self._ready_data @@ -656,7 +668,7 @@ class ConnectionState: self.dispatch('connect') self._ready_task = asyncio.create_task(self._delay_ready()) - def parse_resumed(self, data) -> None: + def parse_resumed(self, _) -> None: self.dispatch('resumed') def parse_message_create(self, data) -> None: @@ -818,6 +830,19 @@ class ConnectionState: if ref: ref._update(data) + def parse_user_settings_update(self, data) -> None: + new_settings = self.settings + old_settings = copy.copy(new_settings) + new_settings._update(data) + self.dispatch('settings_update', old_settings, new_settings) + + def parse_user_guild_settings_update(self, data) -> None: + guild = self.get_guild(int(data['guild_id'])) + new_settings = guild.notification_settings + old_settings = copy.copy(new_settings) + new_settings._update(data) + self.dispatch('guild_settings_update', old_settings, new_settings) + def parse_invite_create(self, data) -> None: invite = Invite.from_gateway(state=self, data=data) self.dispatch('invite_create', invite) @@ -841,7 +866,7 @@ class ConnectionState: if channel_type is ChannelType.group: channel = self._get_private_channel(channel_id) old_channel = copy.copy(channel) - # the channel is a GroupChannel + # The channel is a GroupChannel channel._update_group(data) # type: ignore self.dispatch('private_channel_update', old_channel, channel) return diff --git a/discord/tracking.py b/discord/tracking.py index 2e0297093..b67c89c04 100644 --- a/discord/tracking.py +++ b/discord/tracking.py @@ -31,7 +31,10 @@ from typing import Any, Dict, Optional from .types.snowflake import Snowflake -__all__ = ('ContextProperties',) +__all__ = ( + 'ContextProperties', + 'Tracking', +) class ContextProperties: # Thank you Discord-S.C.U.M @@ -250,3 +253,16 @@ class ContextProperties: # Thank you Discord-S.C.U.M def __ne__(self, other) -> bool: return not self.__eq__(other) + + +class Tracking: + """Represents your Discord tracking settings. + + Attributes + ---------- + personalization: :class:`bool` + Whether you have consented to your data being used for personalization. + """ + + def __init__(self, data: Dict[str, Any]): # TODO: rest of the values + self.personalization = data.get('personalization', {}).get('consented', False) diff --git a/discord/user.py b/discord/user.py index ed5f5a9b1..65843eeed 100644 --- a/discord/user.py +++ b/discord/user.py @@ -644,6 +644,11 @@ class ClientUser(BaseUser): self.premium_type = try_enum(PremiumType, data.get('premium_type', None)) self.bio = data.get('bio') or None + @property + def connected_accounts(self) -> Optional[List[dict]]: + """Optional[List[:class:`dict`]]: Returns a list of all linked accounts for this user if available.""" + return self._state._connected_accounts + def get_relationship(self, user_id: int) -> Relationship: """Retrieves the :class:`Relationship` if applicable. @@ -848,6 +853,9 @@ class ClientUser(BaseUser): Edits the client user's settings. + .. versionchanged:: 2.0 + The edit is no longer in-place, instead the newly edited settings are returned. + Parameters ---------- afk_timeout: :class:`int` @@ -960,8 +968,7 @@ class ClientUser(BaseUser): state = self._state data = await state.http.edit_settings(**payload) - state.settings = settings = UserSettings(data=data, state=self._state) - return settings + return UserSettings(data=data, state=self._state) class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable):