diff --git a/discord/enums.py b/discord/enums.py index eaf8aef5e058..6a88e6867b21 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -74,6 +74,8 @@ 'EntitlementType', 'EntitlementOwnerType', 'PollLayoutType', + 'MemberJoinType', + 'MemberSearchSortType', ) @@ -835,6 +837,29 @@ class ReactionType(Enum): burst = 1 +class MemberJoinType(Enum): + unknown = 0 + bot = 1 + integration = 2 + discovery = 3 + hub = 4 + invite = 5 + vanity = 6 + manual_verification = 7 + + # Aliases + app = 1 + student_hub = 4 + user_invite = 5 + + +class MemberSearchSortType(Enum): + new_guild_members = 1 + old_guild_members = 2 + new_discord_users = 3 + old_discord_users = 4 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/guild.py b/discord/guild.py index f34818b63503..dda2dd028286 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -51,7 +51,7 @@ from . import utils, abc from .role import Role -from .member import Member, VoiceState +from .member import Member, VoiceState, MemberSearch from .emoji import Emoji from .errors import InvalidData from .permissions import PermissionOverwrite @@ -76,6 +76,8 @@ AutoModRuleEventType, ForumOrderType, ForumLayoutType, + MemberJoinType, + MemberSearchSortType, ) from .mixins import Hashable from .user import User @@ -136,6 +138,7 @@ from .types.widget import EditWidgetSettings from .types.audit_log import AuditLogEvent from .message import EmojiInputType + from .invite import Invite VocalGuildChannel = Union[VoiceChannel, StageChannel] GuildChannel = Union[VocalGuildChannel, ForumChannel, TextChannel, CategoryChannel] @@ -4380,6 +4383,283 @@ async def create_automod_rule( return AutoModRule(data=data, guild=self, state=self._state) + async def fetch_safety_information( + self, + *, + limit: int = MISSING, + sort_type: MemberSearchSortType = MISSING, + before: Snowflake = MISSING, + after: Snowflake = MISSING, + user_ids: List[Snowflake] = MISSING, + usernames: List[str] = MISSING, + roles: List[Snowflake] = MISSING, + joined_guild_before: Union[Snowflake, datetime.datetime] = MISSING, + joined_guild_after: Union[Snowflake, datetime.datetime] = MISSING, + unusual_dms_until: datetime.datetime = MISSING, + timed_out_until: datetime.datetime = MISSING, + unusual_activity: bool = MISSING, + automod_quarantined: bool = MISSING, + joined_discord_before: Union[Snowflake, datetime.datetime] = MISSING, + joined_discord_after: Union[Snowflake, datetime.datetime] = MISSING, + is_pending: bool = MISSING, + did_rejoin: bool = MISSING, + join_type: MemberJoinType = MISSING, + invite_code: str = MISSING, + ) -> AsyncIterator[MemberSearch]: + """Returns a :term:`asynchronous iterator` representing the members that were obtained after + the search. + + The ``after`` and ``before`` parameters must represent a member and meet the + :class:`abc.Snowflake` abc. + + You must have :attr:`Permissions.manage_guild` to do this. + + .. versionadded:: 2.5 + + Parameters + ---------- + limit: :class:`int` + The maximum amount of :class:`MemberSearch` objects to return. Can be up to ``1000``. + Defaults to ``250``. + sort_type: :class:`.MemberSearchSortType` + How the results will be sorted. Defaults to :attr:`MemberSearchSortType.new_guild_members`. + before: :class:`abc.Snowflake` + Return members before this object. + after: :class:`abc.Snowflake` + Return members after this object. + user_ids: List[:class:`abc.Snowflake`] + Returns members which IDs match with the ones provided. + usernames: List[:class:`str`] + Returns members which :attr:`Member.display_name`, :attr:`Member.name`, or + :attr:`Member.global_name` match with any of the items. + roles: List[:class:`abc.Snowflake`] + Returns members with any of these roles. + joined_guild_before: Union[:class:`abc.Snowflake`, :class:`datetime.datetime`] + Returns members that joined this guild before this object. If a `datetime.datetime` object is provided + it returns members that joined before that date. + joined_guild_after: Union[:class:`abc.Snowflake`, :class:`datetime.datetime`] + Returns members that joined this guild after this object. If a `datetime.datetime` object is provided + it returns member that joined after that date. + unusual_dms_until: :class:`datetime.datetime` + Returns members which `Member.unusual_dms_until` attribute is less or equal to this. + timed_out_until: :class:`datetime.datetime` + Returns members which `Member.timed_out_until` attribute is less or equal to this. + unusual_activity: :class:`bool` + Returns members flagged with unusual account activity. + automod_quarantined: :class:`bool` + Returns members that have been indefinitely quarantined by an AutoMod Rule because of their name. + joined_discord_before: Union[:class.`abc.Snowflake`, :class:`datetime.datetime`] + Returns members that joined Discord before this object. If a `datetime.datetime` object is provided + it returns members that joined before that date. + joined_discord_after: Union[:class:`abc.Snowflake`, :class:`datetime.datetime`] + Returns members that joined Discord after this object. If a `datetime.datetime` object is provided + it returns members that joined after that date. + is_pending: :class:`bool` + Returns members that have not yet passed the guild member verification. + did_rejoin: :class:`bool` + Returns members that have rejoined the guild. + join_type: :class:`MemberJoinType` + Returns members that have joined like the value provided. + invite_code: :class:`str` + Returns members that have joined using this invite code, or vanity code. + + Raises + ------ + Forbidden + You do not have enough permissions to + fetch this information. + HTTPException + Fetching the information failed. + + Yields + ------ + :class:`MemberSearch` + The safety information of the members. + """ + + def construct_range( + gte: Union[Snowflake, datetime.datetime], + lte: Union[Snowflake, datetime.datetime], + ) -> Dict[str, Any]: + r = {} + if gte is not MISSING: + rgte = int(gte.timestamp()) if isinstance(gte, datetime.datetime) else gte.id + r['gte'] = rgte + if lte is not MISSING: + rlte = int(lte.timestamp()) if isinstance(lte, datetime.datetime) else lte.id + r['lte'] = rlte + return r + + def construct_safety_signals( + unusual_dm_activity_until: datetime.datetime, + communication_disabled_until: datetime.datetime, + unusual_account_activity: bool, + automod_quarantined_username: bool, + ) -> Dict[str, Any]: + r = {} + if unusual_dm_activity_until is not MISSING: + r['unusual_dm_activity_until'] = construct_range(unusual_dm_activity_until, MISSING) + if communication_disabled_until is not MISSING: + r['communication_disabled_until'] = construct_range( + communication_disabled_until, + MISSING, + ) + if unusual_account_activity is not MISSING: + r['unusual_account_activity'] = unusual_account_activity + if automod_quarantined_username is not MISSING: + r['automod_quarantined_username'] = automod_quarantined_username + return r + + def construct_or_query( + value: List[Any], + ) -> Dict[str, List[Any]]: + return {'or_query': value} + + def construct_and_query( + value: List[Any], + ) -> Dict[str, List[Any]]: + return {'and_query': value} + + def set_or_update_query( + payload: Dict[str, Any], + query: Literal['or_query', 'and_query'], + key: str, + value: Any, + ) -> Dict[str, Any]: + query_data = payload[query] + if key in query_data: + query_value = query_data[key] + + if isinstance(query_value, list): + query_value.append(value) + elif isinstance(query_value, dict): + query_value.update(value) + else: + query_value += value + + query_data[key] = query_value + payload[query] = query_data + else: + payload[query][key] = value + return payload + + if limit is MISSING: + limit = self.member_count or self.approximate_member_count or 250 + + while limit > 0: + retrieve = min(limit, 1000) + + state = self._state + after_id = after.id if after is not MISSING else MISSING + before_id = before.id if before is not MISSING else MISSING + + payload = { + 'limit': retrieve, + 'sort': sort_type.value, + 'and_query': {}, + 'or_query': {}, + } + + if after_id is not MISSING: + payload['after'] = after_id + if before_id is not MISSING: + payload['before'] = before_id + if any( + signal is not MISSING + for signal in ( + timed_out_until, + unusual_activity, + unusual_dms_until, + automod_quarantined, + ) + ): + safety_signals_payload = construct_safety_signals( + unusual_dms_until, + timed_out_until, + unusual_activity, + automod_quarantined, + ) + set_or_update_query(payload, 'or_query', 'safety_signals', safety_signals_payload) + if user_ids is not MISSING: + user_ids_payload = construct_or_query([u.id for u in user_ids]) + set_or_update_query(payload, 'and_query', 'user_id', user_ids_payload) + if usernames is not MISSING: + usernames_payload = construct_or_query(usernames) + set_or_update_query(payload, 'and_query', 'usernames', usernames_payload) + if roles is not MISSING: + roles_payload = construct_and_query([r.id for r in roles]) + set_or_update_query(payload, 'and_query', 'role_ids', roles_payload) + if joined_guild_after is not MISSING: + set_or_update_query( + payload, + 'and_query', + 'guild_joined_at', + construct_range( + joined_guild_after, + MISSING, + ), + ) + if joined_guild_before is not MISSING: + set_or_update_query( + payload, + 'and_query', + 'guild_joined_at', + construct_range( + MISSING, + joined_guild_before, + ), + ) + if is_pending is not MISSING: + set_or_update_query( + payload, + 'and_query', + 'is_pending', + is_pending, + ) + if did_rejoin is not MISSING: + set_or_update_query( + payload, + 'and_query', + 'did_rejoin', + did_rejoin, + ) + if join_type is not MISSING: + set_or_update_query( + payload, + 'and_query', + 'join_source_type', + construct_or_query([join_type.value]), + ) + if invite_code is not MISSING: + set_or_update_query( + payload, + 'and_query', + 'source_invite_code', + construct_or_query([invite_code]), + ) + + data = await state.http.get_guild_members_safety_information( + self.id, + limit, + sort_type.value, + **payload, + ) + + results = data['members'] + + if not results: + # No more members, break + break + + limit -= len(results) + after = Object(id=int(results[-1]['member']['user']['id'])) + + for result in results: + yield MemberSearch(data=result, guild=self, state=state) + + if before_id is not MISSING and after.id > before_id: + break + @property def invites_paused_until(self) -> Optional[datetime.datetime]: """Optional[:class:`datetime.datetime`]: If invites are paused, returns when diff --git a/discord/http.py b/discord/http.py index 608595fe3b89..417485ef7f6f 100644 --- a/discord/http.py +++ b/discord/http.py @@ -69,7 +69,6 @@ from .message import Attachment from .flags import MessageFlags from .poll import Poll - from .types import ( appinfo, audit_log, @@ -1981,6 +1980,33 @@ def edit_stage_instance(self, channel_id: Snowflake, *, reason: Optional[str] = def delete_stage_instance(self, channel_id: Snowflake, *, reason: Optional[str] = None) -> Response[None]: return self.request(Route('DELETE', '/stage-instances/{channel_id}', channel_id=channel_id), reason=reason) + def get_guild_members_safety_information( + self, + guild_id: Snowflake, + limit: int, + sort: int, + **kwargs: Any, + ) -> Response[member.MemberSearchResults]: + # These are just the base keys, other "subqueries" as "safety_signals" are constructed + # in `Guild.fetch_safety_information` + valid_keys = ( + 'limit', + 'sort', + 'or_query', + 'and_query', + 'before', + 'after', + ) + payload = { + 'limit': limit, + 'sort': sort, + } + payload.update({k: v for k, v in kwargs.items() if k in valid_keys}) + return self.request( + Route('POST', '/guilds/{guild_id}/members-search', guild_id=guild_id), + json=payload, + ) + # Guild scheduled event management @overload diff --git a/discord/member.py b/discord/member.py index 2eadacd27331..79bb4dacdd99 100644 --- a/discord/member.py +++ b/discord/member.py @@ -38,7 +38,7 @@ from .user import BaseUser, ClientUser, User, _UserTag from .activity import create_activity, ActivityTypes from .permissions import Permissions -from .enums import Status, try_enum +from .enums import Status, MemberJoinType, try_enum from .errors import ClientException from .colour import Colour from .object import Object @@ -47,6 +47,7 @@ __all__ = ( 'VoiceState', 'Member', + 'MemberSearch', ) T = TypeVar('T', bound=type) @@ -65,6 +66,7 @@ MemberWithUser as MemberWithUserPayload, Member as MemberPayload, UserWithMember as UserWithMemberPayload, + MemberSearch as MemberSearchPayload, ) from .types.gateway import GuildMemberUpdateEvent from .types.user import User as UserPayload, AvatarDecorationData @@ -250,6 +252,60 @@ def general(self, *args, **kwargs): return cls +class MemberSearch: + """Represents a fetched member from member search. + + .. versionadded:: 2.5 + + Attributes + ---------- + resolved: :class:`Member` + The resolved member of this search result. + invite_code: Optional[:class:`str`] + The Invite Code this user joined with. + join_type: :class:`MemberJoinType` + The join type. + inviter_id: Optional[:class:`int`] + The ID of the user that invited this member to the guild, if available. + """ + + __slots__ = ( + 'resolved', + 'invite_code', + 'join_type', + 'inviter_id', + '_guild', + '_state', + ) + + def __init__(self, *, data: MemberSearchPayload, guild: Guild, state: ConnectionState) -> None: + self.resolved: Member = Member(data=data['member'], guild=guild, state=state) + self.invite_code: Optional[str] = data.get('source_invite_code') + self.join_type: MemberJoinType = try_enum(MemberJoinType, data['join_source_type']) + try: + self.inviter_id = int(data['inviter_id']) # type: ignore + except KeyError: + self.inviter_id = None + + self._guild = guild + self._state = state + + def __repr__(self) -> str: + return ( + f'' + ) + + @property + def inviter(self) -> Optional[Union[Member, User]]: + """Optional[Union[:class:`Member`, :class:`User`]]: Returns the resolved inviter, or ``None`` + if not cached. + """ + if not self.inviter_id: + return + return self._guild.get_member(self.inviter_id) or self._state.get_user(self.inviter_id) + + @flatten_user class Member(discord.abc.Messageable, _UserTag): """Represents a Discord member to a :class:`Guild`. @@ -317,6 +373,7 @@ class Member(discord.abc.Messageable, _UserTag): 'pending', 'nick', 'timed_out_until', + 'unusual_dms_until', '_permissions', '_client_status', '_user', @@ -369,6 +426,7 @@ def __init__(self, *, data: MemberWithUserPayload, guild: Guild, state: Connecti self._permissions = None self.timed_out_until: Optional[datetime.datetime] = utils.parse_time(data.get('communication_disabled_until')) + self.unusual_dms_until: Optional[datetime.datetime] = utils.parse_time(data.get('unusual_dm_activity_until')) def __str__(self) -> str: return str(self._user) @@ -1069,6 +1127,42 @@ async def timeout( await self.edit(timed_out_until=timed_out_until, reason=reason) + async def fetch_safety_information(self) -> Optional[MemberSearch]: + r"""|coro| + + Fetches the safety information for this member. + + You must have :attr:`Permissions.manage_guild` to do this. + + .. versionadded:: 2.5 + + Raises + ------ + Forbidden + You do not have permission to view member safety + information. + HTTPException + Fetching the information failed. + + Returns + ------- + Optional[:class:`MemberSearch`] + The member safety information, or `None` if there + isn't. + """ + + results = [ + m + async for m in self.guild.fetch_safety_information( + limit=1, + user_ids=[self], + ) + ] + + if not results: + return None + return results[0] + async def add_roles(self, *roles: Snowflake, reason: Optional[str] = None, atomic: bool = True) -> None: r"""|coro| diff --git a/discord/types/member.py b/discord/types/member.py index 88fb619fd398..96fd99f190d0 100644 --- a/discord/types/member.py +++ b/discord/types/member.py @@ -22,11 +22,14 @@ DEALINGS IN THE SOFTWARE. """ -from typing import Optional, TypedDict -from .snowflake import SnowflakeList +from typing import Optional, TypedDict, Literal, List + +from .snowflake import SnowflakeList, Snowflake from .user import User, AvatarDecorationData from typing_extensions import NotRequired +JoinType = Literal[0, 1, 2, 3, 4, 5, 6, 7] + class Nickname(TypedDict): nick: str @@ -49,7 +52,6 @@ class Member(PartialMember, total=False): permissions: str communication_disabled_until: str banner: NotRequired[Optional[str]] - avatar_decoration_data: NotRequired[AvatarDecorationData] class _OptionalMemberWithUser(PartialMember, total=False): @@ -68,3 +70,18 @@ class MemberWithUser(_OptionalMemberWithUser): class UserWithMember(User, total=False): member: _OptionalMemberWithUser + + +class MemberSearch(TypedDict): + member: MemberWithUser + join_source_type: JoinType + source_invite_code: Optional[str] + inviter_id: Optional[Snowflake] + integration_type: Optional[int] + + +class MemberSearchResults(TypedDict): + guild_id: Snowflake + members: List[MemberSearch] + page_result_count: int + total_result_count: int diff --git a/docs/api.rst b/docs/api.rst index 41cf6549d169..e9ff9a578e14 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3663,6 +3663,67 @@ of :class:`enum.Enum`. A burst reaction, also known as a "super reaction". +.. class:: MemberJoinType + + Represents how a member joined a guild. + + .. versionadded:: 2.5 + + .. attribute:: unknown + + Member joined the guild by an unknown source. + + .. attribute:: bot + .. attribute:: app + + Member joined by a bot invite. + + .. attribute:: integration + + Member joined by an integration. + + .. attribute:: discovery + + Member joined by guild discovery. + + .. attribute:: hub + .. attribute:: student_hub + + Member joined by the Student Hub. + + .. attribute:: invite + .. attribute:: user_invite + + Member joined by a user invite. + + .. attribute:: manual_verification + + Member joined by manual verification. + + +.. class:: MemberSearchSortType + + How to sort the results of :meth:`Guild.fetch_safety_information`. + + .. versionadded:: 2.5 + + .. attribute:: new_guild_members + + Sort by guild join date in descending order (newest first). + + .. attribute:: old_guild_members + + Sort by guild join date in ascending order (oldest first). + + .. attribute:: new_discord_users + + Sort by account creation date in descending order (newest first). + + .. attribute:: old_discord_users + + Sort by account creation date in ascending order (oldest first). + + .. _discord-api-audit-logs: Audit Log Data @@ -5125,6 +5186,15 @@ PollAnswer .. autoclass:: PollAnswer() :members: +MemberSearch +~~~~~~~~~~~~ + +.. attributetable:: MemberSearch + +.. autoclass:: MemberSearch() + :members: + + .. _discord_api_data: Data Classes