diff --git a/discord/abc.py b/discord/abc.py index 535cfc815..7227708b7 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -582,11 +582,14 @@ class GuildChannel: def notification_settings(self) -> ChannelSettings: """:class:`~discord.ChannelSettings`: Returns the notification settings for this channel. + If not found, an instance is created with defaults applied. This follows Discord behaviour. + .. versionadded:: 2.0 """ 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 + return guild.notification_settings._channel_overrides.get( + self.id, self._state.default_channel_settings(guild.id, self.id) + ) @property def changed_roles(self) -> List[Role]: diff --git a/discord/channel.py b/discord/channel.py index b13e03719..9439684dc 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -83,6 +83,7 @@ if TYPE_CHECKING: from .file import File from .user import ClientUser, User from .guild import Guild, GuildChannel as GuildChannelType + from .settings import ChannelSettings from .types.channel import ( TextChannel as TextChannelPayload, VoiceChannel as VoiceChannelPayload, @@ -2269,6 +2270,19 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): def __repr__(self) -> str: return f'' + @property + def notification_settings(self) -> ChannelSettings: + """:class:`~discord.ChannelSettings`: Returns the notification settings for this channel. + + If not found, an instance is created with defaults applied. This follows Discord behaviour. + + .. versionadded:: 2.0 + """ + state = self._state + return state.client.notification_settings._channel_overrides.get( + self.id, state.default_channel_settings(None, self.id) + ) + @property def call(self) -> Optional[PrivateCall]: """Optional[:class:`PrivateCall`]: The channel's currently active call.""" @@ -2526,6 +2540,19 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): def __repr__(self) -> str: return f'' + @property + def notification_settings(self) -> ChannelSettings: + """:class:`~discord.ChannelSettings`: Returns the notification settings for this channel. + + If not found, an instance is created with defaults applied. This follows Discord behaviour. + + .. versionadded:: 2.0 + """ + state = self._state + return state.client.notification_settings._channel_overrides.get( + self.id, state.default_channel_settings(None, self.id) + ) + @property def owner(self) -> User: """:class:`User`: The owner that owns the group channel.""" diff --git a/discord/client.py b/discord/client.py index 072a99a4d..07cac8654 100644 --- a/discord/client.py +++ b/discord/client.py @@ -88,6 +88,7 @@ if TYPE_CHECKING: from .message import Message from .member import Member from .voice_client import VoiceProtocol + from .settings import GuildSettings from .types.snowflake import Snowflake as _Snowflake # fmt: off @@ -756,6 +757,18 @@ class Client: """Optional[:class:`.VoiceProtocol`]: Returns the :class:`.VoiceProtocol` associated with private calls, if any.""" return self._connection._get_voice_client(self._connection.self_id) + @property + def notification_settings(self) -> GuildSettings: + """:class:`GuildSettings`: Returns the notification settings for private channels. + + If not found, an instance is created with defaults applied. This follows Discord behaviour. + + .. versionadded:: 2.0 + """ + # The private channel pseudo-guild settings have a guild ID of null + state = self._connection + return state.guild_settings.get(None, state.default_guild_settings(None)) + @property def initial_activity(self) -> Optional[ActivityTypes]: """Optional[:class:`.BaseActivity`]: The primary activity set upon logging in. diff --git a/discord/enums.py b/discord/enums.py index 3edee1fe1..d954f790a 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -41,6 +41,7 @@ __all__ = ( 'UserFlags', 'ActivityType', 'NotificationLevel', + 'HighlightLevel', 'TeamMembershipState', 'WebhookType', 'ExpireBehaviour', @@ -386,6 +387,12 @@ class NotificationLevel(Enum, comparable=True): return self.value +class HighlightLevel(Enum): + default = 0 + disabled = 1 + enabled = 2 + + class AuditLogActionCategory(Enum): create = 1 delete = 2 diff --git a/discord/guild.py b/discord/guild.py index eb7da48e8..17163f2a9 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -86,7 +86,6 @@ from .sticker import GuildSticker from .file import File from .audit_logs import AuditLogEntry from .object import OLDEST_OBJECT, Object -from .settings import GuildSettings from .profile import MemberProfile from .partial_emoji import PartialEmoji from .welcome_screen import * @@ -119,6 +118,7 @@ if TYPE_CHECKING: from .webhook import Webhook from .state import ConnectionState from .voice_client import VoiceProtocol + from .settings import GuildSettings from .types.channel import ( GuildChannel as GuildChannelPayload, TextChannel as TextChannelPayload, @@ -259,10 +259,6 @@ class Guild(Hashable): premium_progress_bar_enabled: :class:`bool` Indicates if the guild has premium AKA server boost level progress bar enabled. - .. versionadded:: 2.0 - notification_settings: :class:`GuildSettings` - The notification settings for the guild. - .. versionadded:: 2.0 keywords: Optional[:class:`str`] Discovery search keywords for the guild. @@ -295,7 +291,6 @@ class Guild(Hashable): 'mfa_level', 'vanity_url_code', 'owner_application_id', - 'notification_settings', 'command_counts', '_members', '_channels', @@ -352,7 +347,6 @@ class Guild(Hashable): self._stage_instances: Dict[int, StageInstance] = {} self._scheduled_events: Dict[int, ScheduledEvent] = {} self._state: ConnectionState = state - self.notification_settings: Optional[GuildSettings] = None self.command_counts: Optional[CommandCounts] = None self._member_count: int = 0 self._presence_count: Optional[int] = None @@ -531,9 +525,6 @@ class Guild(Hashable): large = None if self._member_count == 0 else self._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) - if (counts := guild.get('application_command_counts')) is not None: self.command_counts = CommandCounts(counts.get(0, 0), counts.get(1, 0), counts.get(2, 0)) @@ -644,6 +635,17 @@ class Guild(Hashable): """Optional[:class:`VoiceProtocol`]: Returns the :class:`VoiceProtocol` associated with this guild, if any.""" return self._state._get_voice_client(self.id) + @property + def notification_settings(self) -> GuildSettings: + """:class:`GuildSettings`: Returns the notification settings for the guild. + + If not found, an instance is created with defaults applied. This follows Discord behaviour. + + .. versionadded:: 2.0 + """ + state = self._state + return state.guild_settings.get(self.id, state.default_guild_settings(self.id)) + @property def text_channels(self) -> List[TextChannel]: """List[:class:`TextChannel`]: A list of text channels that belongs to this guild. diff --git a/discord/http.py b/discord/http.py index 616fb213e..41696e1fe 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2379,6 +2379,12 @@ class HTTPClient: def edit_tracking(self, payload): return self.request(Route('POST', '/users/@me/consent'), json=payload) + def get_email_settings(self): + return self.request(Route('GET', '/users/@me/email-settings')) + + def edit_email_settings(self, **payload): + return self.request(Route('PATCH', '/users/@me/email-settings'), json={'settings': payload}) + def mobile_report( # Report v1 self, guild_id: Snowflake, channel_id: Snowflake, message_id: Snowflake, reason: str ): # TODO: return type diff --git a/discord/settings.py b/discord/settings.py index f99c76979..93a735749 100644 --- a/discord/settings.py +++ b/discord/settings.py @@ -24,12 +24,13 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, overload from .activity import create_settings_activity from .enums import ( FriendFlags, + HighlightLevel, Locale, NotificationLevel, Status, @@ -39,19 +40,22 @@ from .enums import ( try_enum, ) from .guild_folder import GuildFolder -from .utils import MISSING, parse_time, utcnow +from .object import Object +from .utils import MISSING, _get_as_snowflake, parse_time, utcnow, find if TYPE_CHECKING: - from .abc import GuildChannel + from .abc import GuildChannel, PrivateChannel from .activity import CustomActivity from .guild import Guild from .state import ConnectionState - from .tracking import Tracking + from .user import ClientUser __all__ = ( 'ChannelSettings', 'GuildSettings', 'UserSettings', + 'TrackingSettings', + 'EmailSettings', 'MuteConfig', ) @@ -105,9 +109,13 @@ class UserSettings: show_current_game: :class:`bool` Whether or not to display the game that you are currently playing. stream_notifications_enabled: :class:`bool` - Unknown. + Whether stream notifications for friends will be received. timezone_offset: :class:`int` The timezone offset to use. + view_nsfw_commands: :class:`bool` + Whether or not to show NSFW application commands. + + .. versionadded:: 2.0 view_nsfw_guilds: :class:`bool` Whether or not to show NSFW guilds on iOS. """ @@ -133,6 +141,7 @@ class UserSettings: show_current_game: bool stream_notifications_enabled: bool timezone_offset: int + view_nsfw_commands: bool view_nsfw_guilds: bool def __init__(self, *, data, state: ConnectionState) -> None: @@ -142,8 +151,8 @@ class UserSettings: def __repr__(self) -> str: return '' - def _get_guild(self, id: int) -> Optional[Guild]: - return self._state._get_guild(int(id)) + def _get_guild(self, id: int) -> Guild: + return self._state._get_guild(int(id)) or Object(id=int(id)) # type: ignore # Lying for better developer UX def _update(self, data: Dict[str, Any]) -> None: RAW_VALUES = { @@ -167,6 +176,7 @@ class UserSettings: 'show_current_game', 'stream_notifications_enabled', 'timezone_offset', + 'view_nsfw_commands', 'view_nsfw_guilds', } @@ -186,6 +196,14 @@ class UserSettings: Parameters ---------- + activity_restricted_guilds: List[:class:`abc.Snowflake`] + A list of guilds that your current activity will not be shown in. + + .. versionadded:: 2.0 + activity_joining_restricted_guilds: List[:class:`abc.Snowflake`] + A list of guilds that will not be able to join your current activity. + + .. versionadded:: 2.0 afk_timeout: :class:`int` How long (in seconds) the user needs to be AFK until Discord sends push notifications to your mobile device. @@ -234,7 +252,7 @@ class UserSettings: Whether or not to enable the new Discord mobile phone number friend requesting features. passwordless: :class:`bool` - Unknown. + Whether the account is passwordless. render_embeds: :class:`bool` Whether or not to render embeds that are sent in the chat. render_reactions: :class:`bool` @@ -244,11 +262,15 @@ class UserSettings: show_current_game: :class:`bool` Whether or not to display the game that you are currently playing. stream_notifications_enabled: :class:`bool` - Unknown. + Whether stream notifications for friends will be received. theme: :class:`Theme` The theme of the Discord UI. timezone_offset: :class:`int` The timezone offset to use. + view_nsfw_commands: :class:`bool` + Whether or not to show NSFW application commands. + + .. versionadded:: 2.0 view_nsfw_guilds: :class:`bool` Whether or not to show NSFW guilds on iOS. @@ -264,10 +286,32 @@ class UserSettings: """ return await self._state.user.edit_settings(**kwargs) # type: ignore - async def fetch_tracking(self) -> Tracking: + async def email_settings(self) -> EmailSettings: """|coro| - Retrieves your :class:`Tracking` settings. + Retrieves your :class:`EmailSettings`. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Getting the email settings failed. + + Returns + ------- + :class:`.EmailSettings` + The email settings. + """ + data = await self._state.http.get_email_settings() + return EmailSettings(data=data, state=self._state) + + async def fetch_tracking_settings(self) -> TrackingSettings: + """|coro| + + Retrieves your :class:`TrackingSettings`. + + .. versionadded:: 2.0 Raises ------ @@ -280,13 +324,32 @@ class UserSettings: The tracking settings. """ data = await self._state.http.get_tracking() - return Tracking(state=self._state, data=data) + return TrackingSettings(state=self._state, data=data) @property - def tracking(self) -> Optional[Tracking]: - """Optional[:class:`Tracking`]: Returns your tracking settings if available.""" + def tracking_settings(self) -> Optional[TrackingSettings]: + """Optional[:class:`TrackingSettings`]: Returns your tracking settings if available. + + .. versionadded:: 2.0 + """ return self._state.consents + @property + def activity_restricted_guilds(self) -> List[Guild]: + """List[:class:`abc.Snowflake`]: A list of guilds that your current activity will not be shown in. + + .. versionadded:: 2.0 + """ + return list(map(self._get_guild, getattr(self, '_activity_restricted_guild_ids', []))) + + @property + def activity_joining_restricted_guilds(self) -> List[Guild]: + """List[:class:`abc.Snowflake`]: A list of guilds that will not be able to join your current activity. + + .. versionadded:: 2.0 + """ + return list(map(self._get_guild, getattr(self, '_activity_joining_restricted_guild_ids', []))) + @property def animate_stickers(self) -> StickerAnimationOptions: """:class:`StickerAnimationOptions`: Whether or not to animate stickers in the chat.""" @@ -316,17 +379,21 @@ class UserSettings: @property def guild_positions(self) -> List[Guild]: """List[:class:`Guild`]: A list of guilds in order of the guild/guild icons that are on the left hand side of the UI.""" - return list(filter(None, map(self._get_guild, getattr(self, '_guild_positions', [])))) + return list(map(self._get_guild, getattr(self, '_guild_positions', []))) @property def locale(self) -> Locale: """:class:`Locale`: The :rfc:`3066` language identifier - of the locale to use for the language of the Discord client.""" + of the locale to use for the language of the Discord client. + + .. versionchanged:: 2.0 + This now returns a :class:`Locale` object instead of a string. + """ return try_enum(Locale, getattr(self, '_locale', 'en-US')) @property def passwordless(self) -> bool: - """:class:`bool`: Unknown.""" + """:class:`bool`: Whether the account is passwordless.""" return getattr(self, '_passwordless', False) @property @@ -386,13 +453,7 @@ class MuteConfig: 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 __str__(self) -> str: return str(self.muted) def __int__(self) -> int: @@ -411,6 +472,8 @@ class MuteConfig: class ChannelSettings: """Represents a channel's notification settings. + .. versionadded:: 2.0 + Attributes ---------- level: :class:`NotificationLevel` @@ -418,7 +481,8 @@ class ChannelSettings: muted: :class:`MuteConfig` The mute configuration for the channel. collapsed: :class:`bool` - Unknown. + Whether the channel is collapsed. + Only applicable to channels of type :attr:`ChannelType.category`. """ if TYPE_CHECKING: @@ -427,12 +491,17 @@ class ChannelSettings: muted: MuteConfig collapsed: bool - def __init__(self, guild_id, *, data: Dict[str, Any] = {}, state: ConnectionState) -> None: - self._guild_id: int = guild_id + def __init__(self, guild_id: Optional[int] = None, *, data: Dict[str, Any], state: ConnectionState) -> None: + self._guild_id = guild_id self._state = state self._update(data) + def __repr__(self) -> str: + return f'' + def _update(self, data: Dict[str, Any]) -> None: + # We consider everything optional because this class can be constructed with no data + # to represent the default settings self._channel_id = int(data['channel_id']) self.collapsed = data.get('collapsed', False) @@ -440,19 +509,24 @@ class ChannelSettings: self.muted = MuteConfig(data.get('muted', False), data.get('mute_config') or {}) @property - def channel(self) -> Optional[GuildChannel]: - """Optional[:class:`.abc.GuildChannel`]: Returns the channel these settings are for.""" + def channel(self) -> Union[GuildChannel, PrivateChannel]: + """Union[:class:`.abc.GuildChannel`, :class:`.abc.PrivateChannel`]: Returns the channel these settings are for.""" guild = self._state._get_guild(self._guild_id) - return guild and guild.get_channel(self._channel_id) + if guild: + channel = guild.get_channel(self._channel_id) + else: + channel = self._state._get_private_channel(self._channel_id) + if not channel: + channel = Object(id=self._channel_id) + return channel # type: ignore # Lying for better developer UX async def edit( self, *, - muted: bool = MISSING, - duration: Optional[int] = MISSING, + muted_until: Optional[Union[bool, datetime]] = MISSING, collapsed: bool = MISSING, level: NotificationLevel = MISSING, - ) -> Optional[ChannelSettings]: + ) -> ChannelSettings: """|coro| Edits the channel's notification settings. @@ -461,13 +535,14 @@ class ChannelSettings: 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. + muted_until: Optional[Union[:class:`datetime.datetime`, :class:`bool`]] + The date this channel's mute should expire. + This can be ``True`` to mute indefinitely, or ``False``/``None`` to unmute. + + This must be a timezone-aware datetime object. Consider using :func:`utils.utcnow`. collapsed: :class:`bool` - Unknown. + Indicates if the channel should be collapsed or not. + Only applicable to channels of type :attr:`ChannelType.category`. level: :class:`NotificationLevel` Determines what level of notifications you receive for the channel. @@ -481,21 +556,29 @@ class ChannelSettings: :class:`ChannelSettings` The new notification settings. """ + state = self._state + guild_id = self._guild_id + channel_id = self._channel_id payload = {} - if muted is not MISSING: - payload['muted'] = muted - - if duration is not MISSING: - if muted is MISSING: + if muted_until is not MISSING: + if not muted_until: + payload['muted'] = False + else: 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 muted_until is True: + payload['mute_config'] = {'selected_time_window': -1, 'end_time': None} + else: + if muted_until.tzinfo is None: + raise TypeError( + 'muted_until must be an aware datetime. Consider using discord.utils.utcnow() or datetime.datetime.now().astimezone() for local time.' + ) + + mute_config = { + 'selected_time_window': (muted_until - utcnow()).total_seconds(), + 'end_time': muted_until.isoformat(), + } + payload['mute_config'] = mute_config if collapsed is not MISSING: payload['collapsed'] = collapsed @@ -503,15 +586,20 @@ class ChannelSettings: if level is not MISSING: payload['message_notifications'] = level.value - fields = {'channel_overrides': {str(self._channel_id): payload}} - data = await self._state.http.edit_guild_settings(self._guild_id, fields) + fields = {'channel_overrides': {str(channel_id): payload}} + data = await state.http.edit_guild_settings(guild_id or '@me', fields) - return ChannelSettings(self._guild_id, data=data['channel_overrides'][str(self._channel_id)], state=self._state) + override = find(lambda x: x.get('channel_id') == str(channel_id), data['channel_overrides']) or { + 'channel_id': channel_id + } + return ChannelSettings(guild_id, data=override, state=state) class GuildSettings: """Represents a guild's notification settings. + .. versionadded:: 2.0 + Attributes ---------- level: :class:`NotificationLevel` @@ -524,36 +612,49 @@ class GuildSettings: Whether to suppress role notifications. hide_muted_channels: :class:`bool` Whether to hide muted channels. - mobile_push_notifications: :class:`bool` + mobile_push: :class:`bool` Whether to enable mobile push notifications. + mute_scheduled_events: :class:`bool` + Whether to mute scheduled events. + notify_highlights: :class:`HighlightLevel` + Whether to include highlights in notifications. version: :class:`int` The version of the guild's settings. """ if TYPE_CHECKING: _channel_overrides: Dict[int, ChannelSettings] - _guild_id: int - version: int + _guild_id: Optional[int] + level: NotificationLevel muted: MuteConfig suppress_everyone: bool suppress_roles: bool hide_muted_channels: bool - mobile_push_notifications: bool - level: NotificationLevel + mobile_push: bool + mute_scheduled_events: bool + notify_highlights: HighlightLevel + version: int def __init__(self, *, data: Dict[str, Any], state: ConnectionState) -> None: self._state = state self._update(data) + def __repr__(self) -> str: + return f'' + 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 + # We consider everything optional because this class can be constructed with no data + # to represent the default settings + self._guild_id = guild_id = _get_as_snowflake(data, 'guild_id') + self.level = try_enum(NotificationLevel, data.get('message_notifications', 3)) 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.mobile_push = data.get('mobile_push', True) + self.mute_scheduled_events = data.get('mute_scheduled_events', False) + self.notify_highlights = try_enum(HighlightLevel, data.get('notify_highlights', 0)) + self.version = data.get('version', -1) # Overriden by real data - 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 @@ -562,9 +663,14 @@ class GuildSettings: 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) + def guild(self) -> Union[Guild, ClientUser]: + """Union[:class:`Guild`, :class:`ClientUser`]: Returns the guild that these settings are for. + + If the returned value is a :class:`ClientUser` then the settings are for the user's private channels. + """ + if self._guild_id: + return self._state._get_guild(self._guild_id) or Object(id=self._guild_id) # type: ignore # Lying for better developer UX + return self._state.user # type: ignore # Should always be present here @property def channel_overrides(self) -> List[ChannelSettings]: @@ -573,13 +679,14 @@ class GuildSettings: async def edit( self, - muted: bool = MISSING, - duration: Optional[int] = MISSING, + muted_until: Optional[Union[bool, datetime]] = MISSING, level: NotificationLevel = MISSING, suppress_everyone: bool = MISSING, suppress_roles: bool = MISSING, - mobile_push_notifications: bool = MISSING, + mobile_push: bool = MISSING, hide_muted_channels: bool = MISSING, + mute_scheduled_events: bool = MISSING, + notify_highlights: HighlightLevel = MISSING, ) -> Optional[GuildSettings]: """|coro| @@ -589,21 +696,25 @@ class GuildSettings: 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. + muted_until: Optional[Union[:class:`datetime.datetime`, :class:`bool`]] + The date this guild's mute should expire. + This can be ``True`` to mute indefinitely, or ``False``/``None`` to unmute. + + This must be a timezone-aware datetime object. Consider using :func:`utils.utcnow`. 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` + mobile_push: :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. + mute_scheduled_events: :class:`bool` + Indicates if scheduled events should be muted. + notify_highlights: :class:`HighlightLevel` + Indicates if highlights should be included in notifications. Raises ------- @@ -617,19 +728,24 @@ class GuildSettings: """ payload = {} - if muted is not MISSING: - payload['muted'] = muted - - if duration is not MISSING: - if muted is MISSING: + if muted_until is not MISSING: + if not muted_until: + payload['muted'] = False + else: 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 muted_until is True: + payload['mute_config'] = {'selected_time_window': -1, 'end_time': None} + else: + if muted_until.tzinfo is None: + raise TypeError( + 'muted_until must be an aware datetime. Consider using discord.utils.utcnow() or datetime.datetime.now().astimezone() for local time.' + ) + + mute_config = { + 'selected_time_window': (muted_until - utcnow()).total_seconds(), + 'end_time': muted_until.isoformat(), + } + payload['mute_config'] = mute_config if level is not MISSING: payload['message_notifications'] = level.value @@ -640,12 +756,187 @@ class GuildSettings: 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 mobile_push is not MISSING: + payload['mobile_push'] = mobile_push if hide_muted_channels is not MISSING: payload['hide_muted_channels'] = hide_muted_channels - data = await self._state.http.edit_guild_settings(self._guild_id, payload) + if mute_scheduled_events is not MISSING: + payload['mute_scheduled_events'] = mute_scheduled_events + + if notify_highlights is not MISSING: + payload['notify_highlights'] = notify_highlights.value + data = await self._state.http.edit_guild_settings(self._guild_id or '@me', payload) return GuildSettings(data=data, state=self._state) + + +class TrackingSettings: + """Represents your Discord tracking settings. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: bool(x) + + Checks if any tracking settings are enabled. + + Attributes + ---------- + personalization: :class:`bool` + Whether you have consented to your data being used for personalization. + usage_statistics: :class:`bool` + Whether you have consented to your data being used for usage statistics. + """ + + __slots__ = ('_state', 'personalization', 'usage_statistics') + + def __init__(self, *, data: Dict[str, Dict[str, bool]], state: ConnectionState) -> None: + self._state = state + self._update(data) + + def __repr__(self) -> str: + return f'' + + def __bool__(self) -> bool: + return any({self.personalization, self.usage_statistics}) + + def _update(self, data: Dict[str, Dict[str, bool]]): + self.personalization = data.get('personalization', {}).get('consented', False) + self.usage_statistics = data.get('usage_statistics', {}).get('consented', False) + + @overload + async def edit(self) -> None: + ... + + @overload + async def edit( + self, + *, + personalization: bool = ..., + usage_statistics: bool = ..., + ) -> None: + ... + + async def edit(self, **kwargs) -> None: + """|coro| + + Edits your tracking settings. + + Parameters + ---------- + personalization: :class:`bool` + Whether you have consented to your data being used for personalization. + usage_statistics: :class:`bool` + Whether you have consented to your data being used for usage statistics. + """ + payload = { + 'grant': [k for k, v in kwargs.items() if v is True], + 'revoke': [k for k, v in kwargs.items() if v is False], + } + data = await self._state.http.edit_tracking(payload) + self._update(data) + + +class EmailSettings: + """Represents email communication preferences. + + .. versionadded:: 2.0 + + Attributes + ---------- + initialized: :class:`bool` + Whether the email communication preferences have been initialized. + communication: :class:`bool` + Whether you want to receive emails for missed calls/messages. + social: :class:`bool` + Whether you want to receive emails for friend requests/suggestions or events. + recommendations_and_events: :class:`bool` + Whether you want to receive emails for recommended servers and events. + tips: :class:`bool` + Whether you want to receive emails for advice and tricks. + updates_and_announcements: :class:`bool` + Whether you want to receive emails for updates and new features. + """ + + __slots__ = ( + '_state', + 'initialized', + 'communication', + 'social', + 'recommendations_and_events', + 'tips', + 'updates_and_announcements', + ) + + def __init__(self, *, data: dict, state: ConnectionState): + self._state = state + self._update(data) + + def __repr__(self) -> str: + return f'' + + def _update(self, data: dict): + self.initialized = data.get('initialized', False) + categories = data.get('categories', {}) + self.communication = categories.get('communication', False) + self.social = categories.get('social', False) + self.recommendations_and_events = categories.get('recommendations_and_events', False) + self.tips = categories.get('tips', False) + self.updates_and_announcements = categories.get('updates_and_announcements', False) + + @overload + async def edit(self) -> None: + ... + + @overload + async def edit( + self, + *, + communication: bool = MISSING, + social: bool = MISSING, + recommendations_and_events: bool = MISSING, + tips: bool = MISSING, + updates_and_announcements: bool = MISSING, + ) -> None: + ... + + async def edit(self, **kwargs) -> None: + """|coro| + + Edits the email settings. + + All parameters are optional. + + Parameters + ----------- + communication: :class:`bool` + Indicates if you want to receive communication emails. + social: :class:`bool` + Indicates if you want to receive social emails. + recommendations_and_events: :class:`bool` + Indicates if you want to receive recommendations and events emails. + tips: :class:`bool` + Indicates if you want to receive tips emails. + updates_and_announcements: :class:`bool` + Indicates if you want to receive updates and announcements emails. + + Raises + ------- + HTTPException + Editing the settings failed. + """ + payload = {} + + # It seems that initialized is settable, but it doesn't do anything + # So we support just in case but leave it undocumented + initialized = kwargs.pop('initialized', None) + if initialized is not None: + payload['initialized'] = initialized + if kwargs: + payload['categories'] = kwargs + + data = await self._state.http.edit_email_settings(**payload) + self._update(data) diff --git a/discord/state.py b/discord/state.py index baada58a0..add5f6a5a 100644 --- a/discord/state.py +++ b/discord/state.py @@ -71,8 +71,7 @@ from .scheduled_event import ScheduledEvent from .stage_instance import StageInstance from .threads import Thread, ThreadMember from .sticker import GuildSticker -from .settings import UserSettings, GuildSettings -from .tracking import Tracking +from .settings import UserSettings, GuildSettings, ChannelSettings, TrackingSettings from .interactions import Interaction from .permissions import Permissions, PermissionOverwrite from .member import _ClientStatus @@ -457,7 +456,8 @@ class ConnectionState: self.user: Optional[ClientUser] = None self._users: weakref.WeakValueDictionary[int, User] = weakref.WeakValueDictionary() self.settings: Optional[UserSettings] = None - self.consents: Optional[Tracking] = None + self.guild_settings: Dict[Optional[int], GuildSettings] = {} + self.consents: Optional[TrackingSettings] = None self.connections: Dict[str, Connection] = {} self.analytics_token: Optional[str] = None self.preferred_regions: List[str] = [] @@ -817,7 +817,6 @@ class ConnectionState: self.clear() data = self._ready_data - guild_settings = data.get('user_guild_settings', {}).get('entries', []) # Temp user parsing temp_users: Dict[int, UserPayload] = {int(data['user']['id']): data['user']} @@ -833,11 +832,6 @@ class ConnectionState: data.get('merged_members', []), extra_data['merged_presences'].get('guilds', []), ): - guild_data['settings'] = utils.find( # type: ignore # This key does not actually exist in the payload - lambda i: i['guild_id'] == guild_data['id'], - guild_settings, - ) or {'guild_id': guild_data['id']} - for presence in merged_presences: presence['user'] = {'id': presence['user_id']} # type: ignore # :( @@ -892,7 +886,11 @@ class ConnectionState: self.analytics_token = data.get('analytics_token') self.preferred_regions = data.get('geo_ordered_rtc_regions', ['us-central']) self.settings = UserSettings(data=data.get('user_settings', {}), state=self) - self.consents = Tracking(data=data.get('consents', {}), state=self) + self.guild_settings = { + utils._get_as_snowflake(entry, 'guild_id'): GuildSettings(data=entry, state=self) + for entry in data.get('user_guild_settings', {}).get('entries', []) + } + self.consents = TrackingSettings(data=data.get('consents', {}), state=self) self.country_code = data.get('country_code', 'US') self.session_type = data.get('session_type', 'normal') self.connections = {c['id']: Connection(state=self, data=c) for c in data.get('connected_accounts', [])} @@ -1079,13 +1077,9 @@ class ConnectionState: self.dispatch('internal_settings_update', old_settings, new_settings) def parse_user_guild_settings_update(self, data) -> None: - guild_id = int(data['guild_id']) - guild = self._get_guild(guild_id) - if guild is None: - _log.debug('USER_GUILD_SETTINGS_UPDATE referencing an unknown guild ID: %s. Discarding.', guild_id) - return + guild_id = utils._get_as_snowflake(data, 'guild_id') - settings = guild.notification_settings + settings = self.guild_settings.get(guild_id) if settings is not None: old_settings = copy.copy(settings) settings._update(data) @@ -2316,3 +2310,9 @@ class ConnectionState: def create_interaction_application(self, data: dict) -> InteractionApplication: return InteractionApplication(state=self, data=data) + + def default_guild_settings(self, guild_id: Optional[int]) -> GuildSettings: + return GuildSettings(data={'guild_id': guild_id}, state=self) + + def default_channel_settings(self, guild_id: Optional[int], channel_id: int) -> ChannelSettings: + return ChannelSettings(guild_id, data={'channel_id': channel_id}, state=self) diff --git a/discord/tracking.py b/discord/tracking.py index 98bfca5ae..fb58d06b3 100644 --- a/discord/tracking.py +++ b/discord/tracking.py @@ -28,7 +28,7 @@ from base64 import b64encode import json from random import choice -from typing import Dict, overload, Optional, TYPE_CHECKING +from typing import Dict, Optional, TYPE_CHECKING from .utils import MISSING @@ -37,12 +37,12 @@ if TYPE_CHECKING: from .enums import ChannelType from .types.snowflake import Snowflake - from .state import ConnectionState +# fmt: off __all__ = ( 'ContextProperties', - 'Tracking', ) +# fmt: on class ContextProperties: # Thank you Discord-S.C.U.M @@ -281,60 +281,3 @@ class ContextProperties: # Thank you Discord-S.C.U.M def __hash__(self) -> int: return hash(self.value) - - -class Tracking: - """Represents your Discord tracking settings. - - Attributes - ---------- - personalization: :class:`bool` - Whether you have consented to your data being used for personalization. - usage_statistics: :class:`bool` - Whether you have consented to your data being used for usage statistics. - """ - - __slots__ = ('_state', 'personalization', 'usage_statistics') - - def __init__(self, *, data: Dict[str, Dict[str, bool]], state: ConnectionState) -> None: - self._state = state - self._update(data) - - def __bool__(self) -> bool: - return any({self.personalization, self.usage_statistics}) - - def _update(self, data: Dict[str, Dict[str, bool]]): - self.personalization = data.get('personalization', {}).get('consented', False) - self.usage_statistics = data.get('usage_statistics', {}).get('consented', False) - - @overload - async def edit(self) -> None: - ... - - @overload - async def edit( - self, - *, - personalization: bool = ..., - usage_statistics: bool = ..., - ) -> None: - ... - - async def edit(self, **kwargs) -> None: - """|coro| - - Edits your tracking settings. - - Parameters - ---------- - personalization: :class:`bool` - Whether you have consented to your data being used for personalization. - usage_statistics: :class:`bool` - Whether you have consented to your data being used for usage statistics. - """ - payload = { - 'grant': [k for k, v in kwargs.items() if v is True], - 'revoke': [k for k, v in kwargs.items() if v is False], - } - data = await self._state.http.edit_tracking(payload) - self._update(data) diff --git a/discord/user.py b/discord/user.py index bd8339fa7..54260bd25 100644 --- a/discord/user.py +++ b/discord/user.py @@ -856,6 +856,16 @@ class ClientUser(BaseUser): restricted_guilds = [str(x.id) for x in restricted_guilds] payload['restricted_guilds'] = restricted_guilds + activity_restricted_guilds = kwargs.pop('activity_restricted_guilds', None) + if activity_restricted_guilds: + activity_restricted_guilds = [str(x.id) for x in activity_restricted_guilds] + payload['activity_restricted_guild_ids'] = activity_restricted_guilds + + activity_joining_restricted_guilds = kwargs.pop('activity_joining_restricted_guilds', None) + if activity_joining_restricted_guilds: + activity_joining_restricted_guilds = [str(x.id) for x in activity_joining_restricted_guilds] + payload['activity_joining_restricted_guild_ids'] = activity_joining_restricted_guilds + status = kwargs.pop('status', None) if status: payload['status'] = status.value diff --git a/docs/api.rst b/docs/api.rst index ebabe49b5..a739a826a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1623,6 +1623,24 @@ of :class:`enum.Enum`. Members receive notifications for messages they are mentioned in. + +.. class:: HighlightLevel + + Specifies whether a :class:`Guild` has highlights included in notifications. + + .. versionadded:: 2.0 + + .. attribute:: default + + The highlight level is set to Discord default. + This seems to always be enabled, which makes the purpose of this enum unclear. + .. attribute:: disabled + + Members do not receive additional notifications for highlights. + .. attribute:: enabled + + Members receive additional notifications for highlights. + .. class:: ContentFilter Specifies a :class:`Guild`\'s explicit content filter, which is the machine @@ -4303,9 +4321,14 @@ Settings .. autoclass:: ChannelSettings() :members: -.. attributetable:: Tracking +.. attributetable:: TrackingSettings + +.. autoclass:: TrackingSettings() + :members: + +.. attributetable:: EmailSettings -.. autoclass:: Tracking() +.. autoclass:: EmailSettings() :members: .. attributetable:: GuildFolder