diff --git a/discord/__init__.py b/discord/__init__.py index 120069b750..e45be1f3b0 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -47,6 +47,7 @@ from .file import * from .flags import * from .guild import * +from .guild_join_request import * from .http import * from .incidents import * from .integrations import * diff --git a/discord/enums.py b/discord/enums.py index 5011a338c2..69fd6640f2 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -88,6 +88,9 @@ "SelectDefaultValueType", "ApplicationEventWebhookStatus", "InviteTargetUsersJobStatusCode", + "JoinRequestStatus", + "JoinRequestFormFieldType", + "JoinRequestAction", ) @@ -1212,6 +1215,31 @@ class InviteTargetUsersJobStatusCode(Enum): failed = 3 +class JoinRequestStatus(Enum): + """Represents the status of a guild join request application.""" + + STARTED = "STARTED" + SUBMITTED = "SUBMITTED" + APPROVED = "APPROVED" + REJECTED = "REJECTED" + + +class JoinRequestFormFieldType(Enum): + """Represents the type of a guild join request form field.""" + + TERMS = "TERMS" + TEXT_INPUT = "TEXT_INPUT" + PARAGRAPH = "PARAGRAPH" + MULTIPLE_CHOICE = "MULTIPLE_CHOICE" + + +class JoinRequestAction(Enum): + """Represents the action of a guild join request application.""" + + APPROVE = "APPROVED" + REJECT = "REJECTED" + + T = TypeVar("T") diff --git a/discord/guild.py b/discord/guild.py index b485123b52..32ef1755bf 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -56,6 +56,7 @@ ChannelType, ContentFilter, EntitlementOwnerType, + JoinRequestStatus, NotificationLevel, NSFWLevel, OnboardingMode, @@ -78,6 +79,7 @@ AuditLogIterator, BanIterator, EntitlementIterator, + JoinRequestIterator, MemberIterator, ) from .member import Member, VoiceState @@ -111,6 +113,7 @@ TextChannel, VoiceChannel, ) + from .guild_join_request import JoinRequest from .onboarding import OnboardingPrompt from .permissions import Permissions from .state import ConnectionState @@ -4719,3 +4722,87 @@ def get_sound(self, sound_id: int) -> SoundboardSound | None: The sound or ``None`` if not found. """ return self._sounds.get(sound_id) + + def join_requests( + self, + *, + status: JoinRequestStatus | None = None, + limit: int | None = 100, + before: SnowflakeTime | None = None, + after: SnowflakeTime | None = None, + ) -> JoinRequestIterator: + """Retrieves an :class:`.AsyncIterator` that enables receiving the guild's join requests. + + This requires either the :attr:`~Permissions.kick_members` or :attr:`~Permissions.manage_guild` + permission. Apps with only :attr:`~Permissions.manage_guild` receive no join requests, but the + iterator's :attr:`~JoinRequestIterator.total` attribute is populated with the pending-request count + after its first request. + + The :attr:`~JoinRequestIterator.total` attribute is only populated when `status` is set to + either ``None`` or :attr:`JoinRequestStatus.SUBMITTED`. It's always ``None`` otherwise. + + Only have the request ID and want to take action? Consider using :meth:`JoinRequest.partial`. + + .. versionadded:: 2.9 + + Parameters + ---------- + status: Optional[:class:`JoinRequestStatus`] + The single status to which results are restricted. If ``None``, + fetches submitted join requests. + + Defaults to :data:`None`. + limit: Optional[:class:`int`] + The number of join requests to retrieve. + If ``None``, retrieves every join request, which may be slow. + + Defaults to ``100``. + before: :class:`.abc.Snowflake` | :class:`datetime.datetime` | None + Retrieves join requests before this date or object. + If a datetime is provided, it is recommended to use a UTC-aware datetime. + If the datetime is naive, it is assumed to be local time. + + Defaults to :data:`None`. + after: :class:`.abc.Snowflake` | :class:`datetime.datetime` | None + Retrieve join requests after this date or object. + If a datetime is provided, it is recommended to use a UTC-aware datetime. + If the datetime is naive, it is assumed to be local time. + + Defaults to :data:`None`. + + Yields + ------ + :class:`JoinRequest` + The join request. + + Raises + ------ + :exc:`HTTPException` + Retrieving the join requests failed. + + Examples + -------- + + Usage :: + + async for request in guild.join_requests(limit=250): + print(request.user, request.status) + + Flattening into a list :: + + requests = await guild.join_requests(limit=None).flatten() + # requests is now a list of JoinRequest... + + # need the total number of submitted join requests? do this: + iterator = guild.join_requests(limit=None) + await iterator.next() + print(iterator.total) # prints the total number of submitted join requests + requests = await iterator.flatten() + """ + return JoinRequestIterator( + self, + status=status, + limit=limit, + before=before, + after=after, + ) diff --git a/discord/guild_join_request.py b/discord/guild_join_request.py new file mode 100644 index 0000000000..f46c28df01 --- /dev/null +++ b/discord/guild_join_request.py @@ -0,0 +1,336 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Literal, overload + +from . import utils +from .enums import ( + JoinRequestAction, + JoinRequestFormFieldType, + JoinRequestStatus, + try_enum, +) +from .mixins import Hashable + +__all__ = ("FormResponse", "JoinRequest") + +if TYPE_CHECKING: + from .abc import Snowflake + from .guild import Guild + from .state import ConnectionState + from .types.guild_join_request import FormResponse as FormResponsePayload + from .types.guild_join_request import JoinRequest as JoinRequestPayload + from .user import User + + +class PartialJoinRequest(Hashable): + """Represents a partial guild join request. + + .. versionadded:: 2.9 + + Attributes + ---------- + id: :class:`int` + The ID of the join request application. + guild_id: :class:`int` + The ID of the guild the join request belongs to. + guild: :class:`discord.Guild` + The guild the join request belongs to. + application_status: :class:`JoinRequestStatus` + The status of the join request application. + """ + + __slots__ = ("_state", "application_status", "guild_id", "id") + + def __init__( + self, + *, + guild: Snowflake | Guild | int, + request: JoinRequest | Snowflake | int, + state: ConnectionState, + application_status: JoinRequestStatus | str | None = None, + ) -> None: + self._state: ConnectionState = state + + self.id: int = request.id if not isinstance(request, int) else request + self.guild_id: int = guild.id if not isinstance(guild, int) else guild + + self.application_status: JoinRequestStatus | None = ( + try_enum(JoinRequestStatus, application_status) + if not isinstance(application_status, JoinRequestStatus) + else application_status + ) + + @property + def guild(self) -> Guild | None: + """Optional[:class:`discord.Guild`]: The guild the join request belongs to. + + This will be :data:`None` if the guild is not found in the internal cache. + """ + return self._state._get_guild(self.guild_id) + + @overload + async def take_action( + self, action: Literal[JoinRequestAction.APPROVE] + ) -> JoinRequest: ... + + @overload + async def take_action( + self, + action: Literal[JoinRequestAction.REJECT], + *, + rejection_reason: str | None = ..., + ) -> JoinRequest: ... + + @overload + async def take_action( + self, action: JoinRequestAction, *, rejection_reason: str | None = ... + ) -> JoinRequest: ... + + async def take_action( + self, action: JoinRequestAction, *, rejection_reason: str | None = None + ) -> JoinRequest: + """|coro| + + Take action on this join request application. + + You can only take action on a join request if the status is :attr:`JoinRequestStatus.SUBMITTED`. + If :attr:`PartialJoinRequest.application_status` is available and not :attr:`JoinRequestStatus.SUBMITTED`, + this will raise :exc:`ValueError`. + + This requires the :attr:`~Permissions.kick_members` permission. + + Parameters + ---------- + action: :class:`JoinRequestAction` + The action to take on the join request. + rejection_reason: Optional[:class:`str`] + The reason for rejecting the join request. This is optional and can be used to provide feedback to the user. + + Only applicable if the `action` is :attr:`JoinRequestAction.REJECT`. + + Returns + ------- + :class:`JoinRequest` + The updated join request. + + Raises + ------ + ValueError + The join request status is not :attr:`JoinRequestStatus.SUBMITTED`. + Forbidden + You do not have permission to take action on the join request. + Or the `status` is not :attr:`JoinRequestStatus.SUBMITTED`. + HTTPException + Taking action on the join request failed. + """ + if ( + self.application_status + and self.application_status is not JoinRequestStatus.SUBMITTED + ): + raise ValueError( + f"Cannot take action on a join request with status {self.application_status}." + ) + data = await self._state.http.action_guild_join_request( + self.guild_id, + self.id, + action=action.value, + rejection_reason=rejection_reason, + ) + return JoinRequest(state=self._state, data=data) + + +class JoinRequest(PartialJoinRequest): + __slots__ = ( + "actioned_by", + "created_at", + "form_responses", + "rejection_reason", + "reviewed_at", + "status", + "user", + "user_id", + ) + + """Represents a guild join request. + + .. versionadded:: 2.9 + + Attributes + ---------- + id: :class:`int` + The join request ID. + guild_id: :class:`int` + The ID of the guild the join request belongs to. + guild: :class:`discord.Guild` + The guild the join request belongs to. + user_id: :class:`int` + The ID of the user who made the join request. + user: :class:`discord.User` + The user who made the join request. + created_at: :class:`datetime.datetime` + When the join request was created. + reviewed_at: :class:`datetime.datetime` | :data:`None` + When the join request was reviewed, if applicable. + application_status: :class:`JoinRequestStatus` | :data:`None` + The status of the application, if applicable. + rejection_reason: :class:`str` | :data:`None` + The reason the join request was rejected, if applicable. + form_responses: list[:class:`discord.FormResponse`] + The form responses of the join request, if applicable. + actioned_by: :class:`discord.User` | :data:`None` + The user who actioned the join request, if applicable. + """ + + def __init__(self, *, state: ConnectionState, data: JoinRequestPayload) -> None: + super().__init__( + guild=int(data["guild_id"]), + request=int(data["id"]), + application_status=data.get("application_status"), + state=state, + ) + + self.created_at: datetime.datetime = utils.parse_time(data["created_at"]) + self.reviewed_at: datetime.datetime | None = utils.parse_time( + data.get("reviewed_at") + ) + self.rejection_reason: str | None = data.get("rejection_reason") + + user = data.get("user") + self.user: User | None = ( + self._state.create_user(user) if user is not None else None + ) + self.user_id: int = int(data["user_id"]) + + form_responses = data.get("form_responses", []) + self.form_responses: list[FormResponse] = [ + FormResponse(r) for r in form_responses + ] + + actioned_by_user = data.get("actioned_by_user") + self.actioned_by: User | None = ( + self._state.create_user(actioned_by_user) + if actioned_by_user is not None + else None + ) + + @classmethod + def partial( + cls, + *, + guild: Guild, + request: JoinRequest | Snowflake | int, + ) -> PartialJoinRequest: + """Creates a partial join request object. + + This is useful for creating a join request object when you only have the guild and request ID. + This will return a :class:`PartialJoinRequest` object, which can only be used to take action on + the join request. + + Parameters + ---------- + guild: :class:`discord.Guild` + The guild the join request belongs to. + request: :class:`discord.Snowflake` | :class:`discord.JoinRequest` | :class:`int` + The join request to create a partial object for. + + If a :class:`discord.abc.Snowflake` or :class:`discord.JoinRequest` is provided, + the ID will be extracted from it. + + Returns + ------- + :class:`PartialJoinRequest` + The partial join request object. + """ + return PartialJoinRequest(guild=guild, request=request, state=guild._state) + + +class FormResponse: + """Represents a form response for a guild join request. + + .. versionadded:: 2.9 + If the join request has already been approved or rejected, this will raise :exc:`ValueError`. + + Attributes + ---------- + field_type: :class:`JoinRequestFormFieldType` + The type of the form field. + label: :class:`str` | :class:`None` + The label of the form field, shown above the field. + description: :class:`str` | :class:`None` + The description of the form field, shown below the label. + required: :class:`bool` + Whether the form field is required to be filled out. + values: Optional[List[:class:`str`]] + The terms the applicant must agree to. + + Only set if the `field_type` is :attr:`JoinRequestFormFieldType.TERMS`. + response: Optional[Union[:class:`str`, :class:`int`, :class:`bool`]] + The response to the form field, depending on the `field_type`: + + - If the `field_type` is :attr:`JoinRequestFormFieldType.TEXT_INPUT` or :attr:`JoinRequestFormFieldType.PARAGRAPH`, this will be a :class:`str`. + - If the `field_type` is :attr:`JoinRequestFormFieldType.MULTIPLE_CHOICE`, this will be an :class:`str` representing the selected choice by the applicant. + Also see :attr:`choice_index` for the index of the selected choice. + - If the `field_type` is :attr:`JoinRequestFormFieldType.TERMS`, this will be a :class:`bool` indicating whether the applicant agreed to the terms. + placeholder: Optional[:class:`str`] + The placeholder text for the form field shown in empty text boxes. + + Only set if the `field_type` is :attr:`JoinRequestFormFieldType.TEXT_INPUT` or :attr:`JoinRequestFormFieldType.PARAGRAPH`. + choices: Optional[List[:class:`str`]] + The choices the applicant can select from. + + Only set if the `field_type` is :attr:`JoinRequestFormFieldType.MULTIPLE_CHOICE`. + choice_index: :class:`int` | :data:`None`: + The index of the selected choice for multiple choice form fields. Only set if the `field_type` is + :attr:`JoinRequestFormFieldType.MULTIPLE_CHOICE`. + """ + + def __init__(self, data: FormResponsePayload) -> None: + self.field_type: JoinRequestFormFieldType = try_enum( + JoinRequestFormFieldType, data["field_type"] + ) + self.label: str | None = data.get("label") + self.description: str | None = data.get("description") + self.required: bool = data.get("required", False) + + self.values: list[str] | None = data.get("values") + self.placeholder: str | None = data.get("placeholder") + + self.choices: list[str] | None = data.get("choices") + self.choice_index: int | None = None + + self.response: str | bool | None = None + + response: str | int | bool | None = data.get("response") + if ( + self.field_type is JoinRequestFormFieldType.MULTIPLE_CHOICE + and isinstance(response, int) + and self.choices is not None + and 0 <= response < len(self.choices) + ): + self.choice_index = response + self.response = self.choices[self.choice_index] diff --git a/discord/http.py b/discord/http.py index 40ebcd7c1f..b3227836d9 100644 --- a/discord/http.py +++ b/discord/http.py @@ -33,6 +33,7 @@ from typing import ( TYPE_CHECKING, Any, + Literal, TypeVar, ) from urllib.parse import quote as _uriquote @@ -71,6 +72,7 @@ embed, emoji, guild, + guild_join_request, integration, interactions, invite, @@ -1707,6 +1709,54 @@ def estimate_pruned_members( Route("GET", "/guilds/{guild_id}/prune", guild_id=guild_id), params=params ) + def get_guild_join_requests( + self, + guild_id: Snowflake, + *, + status: guild_join_request.ApplicationStatus | None = None, + limit: int | None = 100, + before: Snowflake | None = None, + after: Snowflake | None = None, + ) -> Response[guild_join_request.ListGuildJoinRequests]: + params: dict[str, Any] = { + "limit": limit, + } + if status: + params["status"] = status + if before: + params["before"] = before + if after: + params["after"] = after + + return self.request( + Route( + "GET", + "/guilds/{guild_id}/requests", + guild_id=guild_id, + ), + params=params, + ) + + def action_guild_join_request( + self, + guild_id: Snowflake, + request_id: Snowflake, + *, + action: Literal["APPROVED", "REJECTED"] | None = None, + rejection_reason: str | None = None, + ) -> Response[guild_join_request.JoinRequest]: + return self.request( + Route( + "PATCH", + "/guilds/{guild_id}/requests/{request_id}", + guild_id=guild_id, + request_id=request_id, + ), + json={"action": action, "rejection_reason": rejection_reason}, + ) + + # Guild stickers & emojis Management + def get_sticker(self, sticker_id: Snowflake) -> Response[sticker.Sticker]: return self.request( Route("GET", "/stickers/{sticker_id}", sticker_id=sticker_id) diff --git a/discord/iterators.py b/discord/iterators.py index 50fdc6d2a3..e3c2b3fe6c 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -38,6 +38,7 @@ from typing_extensions import deprecated, override from .audit_logs import AuditLogEntry +from .enums import JoinRequestStatus from .errors import NoMoreItems from .object import Object from .utils import maybe_coroutine, snowflake_time, time_snowflake @@ -52,11 +53,13 @@ "EntitlementIterator", "SubscriptionIterator", "MessagePinIterator", + "JoinRequestIterator", ) if TYPE_CHECKING: from .abc import MessageableChannel, Snowflake from .guild import BanEntry, Guild + from .guild_join_request import JoinRequest from .http import HTTPClient from .member import Member from .message import Message, MessagePin @@ -65,6 +68,8 @@ from .threads import Thread from .types.audit_log import AuditLog as AuditLogPayload from .types.guild import Guild as GuildPayload + from .types.guild_join_request import JoinRequest as JoinRequestPayload + from .types.guild_join_request import ListJoinRequests as ListJoinRequestsPayload from .types.message import Message as MessagePayload from .types.message import MessagePin as MessagePinPayload from .types.monetization import Entitlement as EntitlementPayload @@ -1297,3 +1302,98 @@ async def retrieve_inner(self) -> list[Message]: ) def __await__(self) -> Generator[Any, Any, list[Message]]: return self.retrieve_inner().__await__() + + +class JoinRequestIterator(_AsyncIterator["JoinRequest"]): + def __init__( + self, + guild: Guild, + status: JoinRequestStatus | None = None, + limit: int | None = None, + before: Snowflake | datetime.datetime | None = None, + after: Snowflake | datetime.datetime | None = None, + ): + if isinstance(before, datetime.datetime): + before = Object(id=time_snowflake(before, high=False)) + if isinstance(after, datetime.datetime): + after = Object(id=time_snowflake(after, high=True)) + + self.guild = guild + self.status = status + self.limit = limit + self.before = before + self.after = after + self.total: int | None = None + self._has_retrieved = False + + self.state = self.guild._state + self.get_join_requests = self.state.http.get_guild_join_requests + self.join_requests = asyncio.Queue() + + if self.after: + self._retrieve_join_requests = self._retrieve_join_requests_after_strategy + else: + self._retrieve_join_requests = self._retrieve_join_requests_before_strategy + + async def next(self) -> JoinRequest: + if self.join_requests.empty(): + await self.fill_join_requests() + + try: + return self.join_requests.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems() + + def _get_retrieve(self) -> bool: + self.retrieve = 100 if self.limit is None else min(self.limit, 100) + return self.retrieve > 0 + + def create_join_request(self, data: JoinRequestPayload) -> JoinRequest: + from .guild_join_request import JoinRequest + + return JoinRequest(guild=self.guild, state=self.state, data=data) + + async def fill_join_requests(self) -> None: + if self._get_retrieve(): + data = await self._retrieve_join_requests(self.retrieve) + if len(data) < self.retrieve: + self.limit = 0 + + for element in data: + await self.join_requests.put(self.create_join_request(element)) + + async def _retrieve_join_requests(self, retrieve: int) -> list[JoinRequestPayload]: + raise NotImplementedError + + async def _retrieve_join_requests_before_strategy(self, retrieve: int): + before = self.before.id if self.before else None + params = {"limit": retrieve, "before": before} + if self.status: + params["status"] = self.status.value + response = await self.get_join_requests(self.guild.id, **params) + self._set_total(response) + data = response.get("guild_join_requests", []) + if data: + if self.limit is not None: + self.limit -= len(data) + self.before = Object(id=int(data[-1]["id"])) + return data + + async def _retrieve_join_requests_after_strategy(self, retrieve: int): + after = self.after.id if self.after else None + params = {"limit": retrieve, "after": after} + if self.status: + params["status"] = self.status.value + response = await self.get_join_requests(self.guild.id, **params) + self._set_total(response) + data = response.get("guild_join_requests", []) + if data: + if self.limit is not None: + self.limit -= len(data) + self.after = Object(id=int(data[0]["id"])) + return data + + def _set_total(self, response: ListJoinRequestsPayload) -> None: + if not self._has_retrieved: + self.total = response.get("total") + self._has_retrieved = True diff --git a/discord/raw_models.py b/discord/raw_models.py index 291a44fadc..c51c506f3a 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -50,6 +50,7 @@ from .state import ConnectionState from .threads import Thread from .types.channel import VoiceChannelEffectSendEvent as VoiceChannelEffectSend + from .types.guild_join_request import JoinRequestDelete as JoinRequestDeletePayload from .types.member import MemberUpdateEvent from .types.raw_models import ( AuditLogEntryEvent, @@ -99,6 +100,7 @@ "RawVoiceServerUpdateEvent", "RawVoiceStateUpdateEvent", "RawMemberUpdateEvent", + "RawGuildJoinRequestDeleteEvent", ) @@ -1030,3 +1032,50 @@ def __init__(self, data: MemberUpdateEvent, member: Member) -> None: self.data: MemberUpdateEvent = data self.cached_member: Member | None = None self.member: Member = member + + +class RawGuildJoinRequestDeleteEvent(_RawReprMixin): + """Represents the payload for a :func:`on_raw_guild_join_request_delete` event. + + .. versionadded:: 2.9 + + Attributes + ---------- + id: :class:`int` + The ID of the join request that was deleted / withdrawn. + guild_id: :class:`int` + The ID of the guild where the join request was deleted / withdrawn. + user_id: :class:`int` + The ID of the user whose join request was deleted / withdrawn. + data: :class:`dict` + The raw data sent by the `gateway `_. + """ + + __slots__ = ("data", "guild_id", "id", "user_id") + + def __init__( + self, + *, + data: JoinRequestDeletePayload, + state: ConnectionState, + ) -> None: + self._state: ConnectionState = state + self.data: JoinRequestDeletePayload = data + + self.id: int = int(data["id"]) + self.guild_id: int = int(data["guild_id"]) + self.user_id: int = int(data["user_id"]) + + @property + def guild(self) -> Guild | None: + """:class:`discord.Guild` | :data:`None`: The guild where the join request was deleted / withdrawn, + if found in the internal cache. + """ + return self._state._get_guild(self.guild_id) + + @property + def user(self) -> User | None: + """:class:`discord.User` | :data:`None`: The user whose join request was deleted / withdrawn, + if found in the internal cache. + """ + return self._state.get_user(self.user_id) diff --git a/discord/state.py b/discord/state.py index 83137e94bb..d2d8db460f 100644 --- a/discord/state.py +++ b/discord/state.py @@ -51,6 +51,7 @@ from .enums import ChannelType, InteractionType, ScheduledEventStatus, Status, try_enum from .flags import ApplicationFlags, GatewayCapabilities, Intents, MemberCacheFlags from .guild import Guild +from .guild_join_request import JoinRequest from .integrations import _integration_factory from .interactions import Interaction from .invite import Invite @@ -83,6 +84,9 @@ from .types.channel import DMChannel as DMChannelPayload from .types.emoji import Emoji as EmojiPayload from .types.guild import Guild as GuildPayload + from .types.guild_join_request import JoinRequestCreate as JoinRequestCreatePayload + from .types.guild_join_request import JoinRequestDelete as JoinRequestDeletePayload + from .types.guild_join_request import JoinRequestUpdate as JoinRequestUpdatePayload from .types.member import MemberUpdateEvent from .types.message import Message as MessagePayload from .types.poll import Poll as PollPayload @@ -1780,6 +1784,22 @@ def parse_guild_integrations_update(self, data) -> None: data["guild_id"], ) + def parse_guild_join_request_create(self, data: JoinRequestCreatePayload) -> None: + self.dispatch( + "guild_join_request_create", JoinRequest(state=self, data=data["request"]) + ) + + def parse_guild_join_request_delete(self, data: JoinRequestDeletePayload) -> None: + self.dispatch( + "raw_guild_join_request_delete", + RawGuildJoinRequestDeleteEvent(state=self, data=data), + ) + + def parse_guild_join_request_update(self, data: JoinRequestUpdatePayload) -> None: + self.dispatch( + "guild_join_request_update", JoinRequest(state=self, data=data["request"]) + ) + def parse_integration_create(self, data) -> None: guild_id = int(data.pop("guild_id")) guild = self._get_guild(guild_id) diff --git a/discord/types/guild.py b/discord/types/guild.py index 3c71c8647e..71b9ead978 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -104,6 +104,7 @@ class UnavailableGuild(TypedDict): "VIP_REGIONS", "WELCOME_SCREEN_ENABLED", "ENHANCED_ROLE_COLORS", + "MEMBER_VERIFICATION_MANUAL_APPROVAL", ] diff --git a/discord/types/guild_join_request.py b/discord/types/guild_join_request.py new file mode 100644 index 0000000000..1d5df00a6f --- /dev/null +++ b/discord/types/guild_join_request.py @@ -0,0 +1,120 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Literal, NotRequired, TypedDict + +from .snowflake import Snowflake +from .user import User + +ApplicationStatus = Literal[ + "STARTED", # started, but not yet submitted + "SUBMITTED", # submitted, but not yet reviewed + "APPROVED", + "REJECTED", +] +FormFieldType = Literal[ + "TERMS", + "TEXT_INPUT", + "PARAGRAPH", + "MULTIPLE_CHOICE", +] + + +class JoinRequest(TypedDict): + id: Snowflake + guild_id: Snowflake + application_status: ApplicationStatus | None + created_at: str # iso + reviewed_at: str | None # iso + reviewed_by_user: NotRequired[User] + rejection_reason: str | None + user_id: Snowflake + user: NotRequired[User] + form_responses: NotRequired[list[FormResponse]] + actioned_by_user: NotRequired[User] + + +class _BaseFormResponse(TypedDict): + field_type: FormFieldType + + +class TermsFormResponse(_BaseFormResponse): + field_type: Literal["TERMS"] + values: list[str] + response: NotRequired[bool] + + +class TextInputFormResponse(_BaseFormResponse): + field_type: Literal["TEXT_INPUT"] + placeholder: NotRequired[str] + response: NotRequired[str] + + +class ParagraphFormResponse(TextInputFormResponse): ... + + +class MultipleChoiceFormResponse(_BaseFormResponse): + field_type: Literal["MULTIPLE_CHOICE"] + choices: list[str] + response: NotRequired[int] + + +FormResponse = ( + TermsFormResponse + | TextInputFormResponse + | ParagraphFormResponse + | MultipleChoiceFormResponse +) + + +class _BaseListJoinRequests(TypedDict): + total: NotRequired[int] # only when status is "SUBMITTED" or omitted + + +# only returned with the kick_member permission +class ListJoinRequestsWithPermissions(_BaseListJoinRequests): + guild_join_requests: list[JoinRequest] + + +ListJoinRequests = ListJoinRequestsWithPermissions | _BaseListJoinRequests + + +class JoinRequestCreate(TypedDict): + guild_id: Snowflake + status: ApplicationStatus + request: JoinRequest + + +class JoinRequestUpdate(TypedDict): + guild_id: Snowflake + status: ApplicationStatus + request: JoinRequest + + +class JoinRequestDelete(TypedDict): + id: Snowflake + guild_id: Snowflake + user_id: Snowflake diff --git a/docs/api/enums.rst b/docs/api/enums.rst index efa16a4a5e..941f06793e 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2702,3 +2702,64 @@ of :class:`enum.Enum`. .. attribute:: read_only Represents the team read only role. + + +.. class:: JoinRequestStatus + + Represents the status of a guild join request. + + .. versionadded:: 2.9 + + .. attribute:: STARTED + + The application has been started but not submitted. + + .. attribute:: SUBMITTED + + The application is awaiting review. + + .. attribute:: APPROVED + + The application was approved. + + .. attribute:: REJECTED + + The application was rejected. + + +.. class:: JoinRequestFormFieldType + + Represents the type of a guild join-request form field. + + .. versionadded:: 2.9 + + .. attribute:: TERMS + + The applicant must agree to terms. + + .. attribute:: TEXT_INPUT + + The applicant supplies a short text response. + + .. attribute:: PARAGRAPH + + The applicant supplies a multi-line text response. + + .. attribute:: MULTIPLE_CHOICE + + The applicant selects one response from a set of choices. + + +.. class:: JoinRequestAction + + Represents an action to take on a submitted guild join request. + + .. versionadded:: 2.9 + + .. attribute:: APPROVE + + Approves the application. + + .. attribute:: REJECT + + Rejects the application. diff --git a/docs/api/events.rst b/docs/api/events.rst index 48a4542e61..0a2643d1c3 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -457,6 +457,45 @@ Guilds :param after: The guild after being updated. :type after: :class:`Guild` +.. function:: on_guild_join_request_create(join_request) + + Called when a user submits a new join request to a guild. + + This requires :attr:`Intents.moderation` to be enabled and is only sent to + bots with the :attr:`~Permissions.kick_members` permission. + + .. versionadded:: 2.9 + + :param join_request: The newly submitted join request. + :type join_request: :class:`JoinRequest` + +.. function:: on_guild_join_request_update(join_request) + + Called when a guild join request is updated, such as when an applicant + submits a request they had already started or when a request is approved + or rejected. + + This requires :attr:`Intents.moderation` to be enabled and is only sent to + bots with the :attr:`~Permissions.kick_members` permission. + + .. versionadded:: 2.9 + + :param join_request: The updated join request. + :type join_request: :class:`JoinRequest` + +.. function:: on_raw_guild_join_request_delete(payload) + + Called when a guild join request is deleted, such as when the applicant + withdraws it. + + This requires :attr:`Intents.moderation` to be enabled and is only sent to + bots with the :attr:`~Permissions.kick_members` permission. + + .. versionadded:: 2.9 + + :param payload: The raw join-request deletion payload. + :type payload: :class:`RawGuildJoinRequestDeleteEvent` + .. function:: on_guild_role_create(role) on_guild_role_delete(role) diff --git a/docs/api/models.rst b/docs/api/models.rst index 12094bc698..1b64f8088a 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -146,7 +146,7 @@ Guild .. autoclass:: Guild() :members: - :exclude-members: fetch_members, audit_logs + :exclude-members: fetch_members, audit_logs, join_requests .. automethod:: fetch_members :async-for: @@ -154,6 +154,22 @@ Guild .. automethod:: audit_logs :async-for: + .. automethod:: join_requests + :async-for: + +Guild Join Requests +------------------- + +.. attributetable:: JoinRequest + +.. autoclass:: JoinRequest() + :members: + +.. attributetable:: FormResponse + +.. autoclass:: FormResponse() + :members: + .. class:: BanEntry A namedtuple which represents a ban returned from :meth:`~Guild.bans`. @@ -701,6 +717,11 @@ Events .. autoclass:: RawMemberRemoveEvent() :members: +.. attributetable:: RawGuildJoinRequestDeleteEvent + +.. autoclass:: RawGuildJoinRequestDeleteEvent() + :members: + .. attributetable:: RawThreadUpdateEvent .. autoclass:: RawThreadUpdateEvent()