diff --git a/changelog/1230.feature.rst b/changelog/1230.feature.rst new file mode 100644 index 0000000000..c3afd3299d --- /dev/null +++ b/changelog/1230.feature.rst @@ -0,0 +1,3 @@ +Add support for guild incident actions. +- Add :class:`IncidentsData` and :attr:`Guild.incidents_data` attribute. +- New ``invites_disabled_until`` and ``dms_disabled_until`` parameters for :meth:`Guild.edit`. diff --git a/disnake/guild.py b/disnake/guild.py index 97d45ef19e..a09e34ae87 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -84,6 +84,7 @@ from .widget import Widget, WidgetSettings __all__ = ( + "IncidentsData", "Guild", "GuildBuilder", ) @@ -110,6 +111,7 @@ CreateGuildPlaceholderRole, Guild as GuildPayload, GuildFeature, + IncidentsData as IncidentsDataPayload, MFALevel, ) from .types.integration import Integration as IntegrationPayload, IntegrationType @@ -135,6 +137,94 @@ class _GuildLimit(NamedTuple): sounds: int +class IncidentsData: + """Represents data about various security incidents/actions in a guild. + + .. collapse:: operations + + .. describe:: x == y + + Checks if two ``IncidentsData`` instances are equal. + + .. describe:: x != y + + Checks if two ``IncidentsData`` instances are not equal. + + .. versionadded:: 2.11 + + Attributes + ---------- + dm_spam_detected_at: Optional[:class:`datetime.datetime`] + The time (in UTC) at which DM spam was last detected. + raid_detected_at: Optional[:class:`datetime.datetime`] + The time (in UTC) at which a raid was last detected. + """ + + __slots__ = ( + "_invites_disabled_until", + "_dms_disabled_until", + "dm_spam_detected_at", + "raid_detected_at", + ) + + def __init__(self, data: IncidentsDataPayload) -> None: + self._invites_disabled_until: Optional[datetime.datetime] = utils.parse_time( + data.get("invites_disabled_until") + ) + self._dms_disabled_until: Optional[datetime.datetime] = utils.parse_time( + data.get("dms_disabled_until") + ) + self.dm_spam_detected_at: Optional[datetime.datetime] = utils.parse_time( + data.get("dm_spam_detected_at") + ) + self.raid_detected_at: Optional[datetime.datetime] = utils.parse_time( + data.get("raid_detected_at") + ) + + @property + def invites_disabled_until(self) -> Optional[datetime.datetime]: + """Optional[:class:`datetime.datetime`]: Returns the time (in UTC) until + which users cannot join the server via invites, if any. + """ + if ( + self._invites_disabled_until is not None + and self._invites_disabled_until < utils.utcnow() + ): + self._invites_disabled_until = None + + return self._invites_disabled_until + + @property + def dms_disabled_until(self) -> Optional[datetime.datetime]: + """Optional[:class:`datetime.datetime`]: Returns the time (in UTC) until + which members cannot send DMs to each other, if any. + + This does not apply to moderators, bots, or members who are + already friends with each other. + """ + if self._dms_disabled_until is not None and self._dms_disabled_until < utils.utcnow(): + self._dms_disabled_until = None + + return self._dms_disabled_until + + def __eq__(self, other: Any) -> bool: + return ( + isinstance(other, IncidentsData) + and self.invites_disabled_until == other.invites_disabled_until + and self.dms_disabled_until == other.dms_disabled_until + and self.dm_spam_detected_at == other.dm_spam_detected_at + and self.raid_detected_at == other.raid_detected_at + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + class Guild(Hashable): """Represents a Discord guild. @@ -322,6 +412,11 @@ class Guild(Hashable): To get a full :class:`Invite` object, see :attr:`Guild.vanity_invite`. .. versionadded:: 2.5 + + incidents_data: Optional[:class:`IncidentsData`] + Data about various security incidents/actions in this guild, like disabled invites/DMs. + + .. versionadded:: 2.11 """ __slots__ = ( @@ -354,6 +449,7 @@ class Guild(Hashable): "widget_enabled", "widget_channel_id", "vanity_url_code", + "incidents_data", "_members", "_channels", "_icon", @@ -600,6 +696,11 @@ def _from_data(self, guild: GuildPayload) -> None: self._safety_alerts_channel_id: Optional[int] = utils._get_as_snowflake( guild, "safety_alerts_channel_id" ) + self.incidents_data: Optional[IncidentsData] = ( + IncidentsData(incidents_data) + if (incidents_data := guild.get("incidents_data")) + else None + ) stage_instances = guild.get("stage_instances") if stage_instances is not None: @@ -2040,6 +2141,8 @@ async def edit( discovery_splash: Optional[AssetBytes] = MISSING, community: bool = MISSING, invites_disabled: bool = MISSING, + invites_disabled_until: Optional[Union[datetime.datetime, datetime.timedelta]] = MISSING, + dms_disabled_until: Optional[Union[datetime.datetime, datetime.timedelta]] = MISSING, raid_alerts_disabled: bool = MISSING, afk_channel: Optional[VoiceChannel] = MISSING, owner: Snowflake = MISSING, @@ -2125,7 +2228,8 @@ async def edit( Whether the guild should be a Community guild. If set to ``True``\\, both ``rules_channel`` and ``public_updates_channel`` parameters are required. invites_disabled: :class:`bool` - Whether the guild has paused invites, preventing new users from joining. + Whether the guild has paused invites (indefinitely), preventing new users from joining. + See also the ``invites_disabled_until`` parameter. This is only available to guilds that contain ``COMMUNITY`` in :attr:`Guild.features`. @@ -2134,6 +2238,28 @@ async def edit( .. versionadded:: 2.6 + invites_disabled_until: Optional[Union[:class:`datetime.datetime`, :class:`datetime.timedelta`]] + The time until/for which invites are paused, up to 24 hours in the future. + See also the ``invites_disabled`` parameter. + Can be set to ``None`` to re-enable invites. + + This is only available to guilds that contain ``COMMUNITY`` + in :attr:`Guild.features`. + + .. versionadded:: 2.11 + + dms_disabled_until: Union[:class:`datetime.datetime`, :class:`datetime.timedelta`] + The time until/for which DMs between guild members are disabled, up to 24 hours in the future. + Can be set to ``None`` to re-enable DMs. + + This does not apply to moderators, bots, or members who are + already friends with each other. + + This is only available to guilds that contain ``COMMUNITY`` + in :attr:`Guild.features`. + + .. versionadded:: 2.11 + raid_alerts_disabled: :class:`bool` Whether the guild has disabled join raid alerts. @@ -2220,6 +2346,30 @@ async def edit( if vanity_code is not MISSING: await http.change_vanity_code(self.id, vanity_code, reason=reason) + if invites_disabled_until is not MISSING or dms_disabled_until is not MISSING: + payload: IncidentsDataPayload = {} + + # we need to include the old values, otherwise Discord will consider them set to `null` + # (which would e.g. re-enable DMs when disabling invites) + if self.incidents_data: + if invites_disabled_until is MISSING: + invites_disabled_until = self.incidents_data.invites_disabled_until + if dms_disabled_until is MISSING: + dms_disabled_until = self.incidents_data.dms_disabled_until + + if invites_disabled_until is not MISSING: + if isinstance(invites_disabled_until, datetime.timedelta): + invites_disabled_until = utils.utcnow() + invites_disabled_until + payload["invites_disabled_until"] = utils.isoformat_utc(invites_disabled_until) + + if dms_disabled_until is not MISSING: + if isinstance(dms_disabled_until, datetime.timedelta): + dms_disabled_until = utils.utcnow() + dms_disabled_until + payload["dms_disabled_until"] = utils.isoformat_utc(dms_disabled_until) + + if payload: + await http.edit_guild_incident_actions(self.id, payload) + fields: Dict[str, Any] = {} if name is not MISSING: fields["name"] = name diff --git a/disnake/http.py b/disnake/http.py index 1cc9aa701b..457d18454e 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -1482,6 +1482,12 @@ def edit_guild( Route("PATCH", "/guilds/{guild_id}", guild_id=guild_id), json=payload, reason=reason ) + def edit_guild_incident_actions( + self, guild_id: Snowflake, payload: guild.IncidentsData + ) -> Response[guild.IncidentsData]: + r = Route("PUT", "/guilds/{guild_id}/incident-actions", guild_id=guild_id) + return self.request(r, json=payload) + def get_template(self, code: str) -> Response[template.Template]: return self.request(Route("GET", "/guilds/templates/{code}", code=code)) diff --git a/disnake/member.py b/disnake/member.py index bd21f0ee01..864b4b27f8 100644 --- a/disnake/member.py +++ b/disnake/member.py @@ -756,12 +756,11 @@ def current_timeout(self) -> Optional[datetime.datetime]: .. versionadded:: 2.3 """ - if self._communication_disabled_until is None: - return None - - if self._communication_disabled_until < utils.utcnow(): + if ( + self._communication_disabled_until is not None + and self._communication_disabled_until < utils.utcnow() + ): self._communication_disabled_until = None - return None return self._communication_disabled_until diff --git a/disnake/types/guild.py b/disnake/types/guild.py index 462a0aa597..d76907940d 100644 --- a/disnake/types/guild.py +++ b/disnake/types/guild.py @@ -87,6 +87,13 @@ class UnavailableGuild(TypedDict): ] +class IncidentsData(TypedDict, total=False): + invites_disabled_until: Optional[str] + dms_disabled_until: Optional[str] + dm_spam_detected_at: Optional[str] + raid_detected_at: Optional[str] + + class _BaseGuildPreview(UnavailableGuild): name: str icon: Optional[str] @@ -138,6 +145,7 @@ class Guild(_BaseGuildPreview): stickers: NotRequired[List[GuildSticker]] premium_progress_bar_enabled: bool safety_alerts_channel_id: Optional[Snowflake] + incidents_data: Optional[IncidentsData] # specific to GUILD_CREATE event joined_at: NotRequired[Optional[str]] diff --git a/docs/api/guilds.rst b/docs/api/guilds.rst index 25aaaa6f6d..88b26ce835 100644 --- a/docs/api/guilds.rst +++ b/docs/api/guilds.rst @@ -113,6 +113,14 @@ OnboardingPromptOption .. autoclass:: OnboardingPromptOption() :members: +IncidentsData +~~~~~~~~~~~~~ + +.. attributetable:: IncidentsData + +.. autoclass:: IncidentsData() + :members: + Data Classes ------------