From 7a37aca70f80dfd67cb2b6c280763d57a33172e6 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:32:31 +0200 Subject: [PATCH 01/13] feat: guild join requests --- discord/__init__.py | 1 + discord/enums.py | 28 +++ discord/guild.py | 90 ++++++++++ discord/guild_join_request.py | 262 ++++++++++++++++++++++++++++ discord/http.py | 50 ++++++ discord/iterators.py | 102 +++++++++++ discord/types/guild.py | 1 + discord/types/guild_join_request.py | 97 ++++++++++ docs/api/enums.rst | 61 +++++++ docs/api/models.rst | 18 +- 10 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 discord/guild_join_request.py create mode 100644 discord/types/guild_join_request.py 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..f60218896a 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" + DENIED = "DENIED" + + +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..25d0de8e87 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -56,6 +56,7 @@ ChannelType, ContentFilter, EntitlementOwnerType, + JoinRequestStatus, NotificationLevel, NSFWLevel, OnboardingMode, @@ -79,6 +80,7 @@ BanIterator, EntitlementIterator, MemberIterator, + JoinRequestIterator, ) from .member import Member, VoiceState from .mixins import Hashable @@ -96,6 +98,7 @@ from .welcome_screen import WelcomeScreen, WelcomeScreenChannel from .widget import Widget + __all__ = ("BanEntry", "Guild", "GuildRoleCounts") MISSING = utils.MISSING @@ -124,6 +127,7 @@ from .types.voice import VoiceState as GuildVoiceState from .voice import VoiceClient from .webhook import Webhook + from .guild_join_request import JoinRequest VocalGuildChannel = Union[VoiceChannel, StageChannel] GuildChannel = Union[ @@ -4719,3 +4723,89 @@ def get_sound(self, sound_id: int) -> SoundboardSound | None: The sound or ``None`` if not found. """ return self._sounds.get(sound_id) + + def fetch_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. + + .. versionadded:: 2.9 + + Parameters + ---------- + status: Optional[:class:`JoinRequestStatus`] + The single status to which results are restricted. If ``None``, + fetches submitted join requests. The iterator's :attr:`total` + attribute is only available for 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.fetch_join_requests(limit=250): + print(request.user, request.status) + + Flattening into a list :: + + requests = await guild.fetch_join_requests(limit=None).flatten() + # requests is now a list of JoinRequest... + + # need the total number of submitted join requests? do this: + iterator = guild.fetch_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..d55da4cec8 --- /dev/null +++ b/discord/guild_join_request.py @@ -0,0 +1,262 @@ +""" +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 + +from .mixins import Hashable +from .enums import ( + JoinRequestStatus, + JoinRequestFormFieldType, + JoinRequestAction, + try_enum, +) +from . import utils + +__all__ = ("FormResponse", "JoinRequest") + +if TYPE_CHECKING: + from .state import ConnectionState + from .types.guild_join_request import ( + FormResponse as FormResponsePayload, + JoinRequest as JoinRequestPayload, + ) + from .user import User + from .guild import Guild + from .object import Object + + +class FormResponse: + """Represents a form response for a guild join request. + + .. versionadded:: 2.9 + + 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:`int` representing 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`. + """ + + 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.response: str | int | bool | None = data.get("response") + self.placeholder: str | None = data.get("placeholder") + self.choices: list[str] | None = data.get("choices") + + +class JoinRequest(Hashable): + __slots__ = ( + "_state", + "id", + "guild", + "guild_id", + "user", + "user_id", + "created_at", + "reviewed_at", + "status", + "rejection_status", + "form_responses", + "actioned_by_user", + ) + + """Represents a guild join request. + + .. versionadded:: 2.9 + + Attributes + ---------- + id: :class:`int` + The join request ID. + guild_id: :class:`int` + The guild ID. + user_id: :class:`int` + The user ID of the requester. + created_at: :class:`datetime.datetime` + When the join request was created. + reviewed_at: Optional[:class:`datetime.datetime`] + When the join request was reviewed, if applicable. + status: Optional[:class:`JoinRequestStatus`] + The status of the application, if applicable. + rejection_status: Optional[:class:`str`] + The rejection status of the join request, if applicable. + user: Optional[:class:`discord.User`] + The user who made the join request. This is only available if the join request was fetched with the ``with_user`` parameter set to ``True``. + form_responses: Optional[List[:class:`discord.FormResponse`]] + The form responses of the join request, if applicable. This is only available if the join request was fetched with the ``with_form_responses`` parameter set to ``True``. + actioned_by_user: Optional[:class:`discord.User`] + The user who actioned the join request, if applicable. This is only available if the join request was fetched with the ``with_actioned_by_user`` parameter set to ``True``. + """ + + def __init__( + self, *, guild: Guild, state: ConnectionState, data: JoinRequestPayload + ) -> None: + self._state: ConnectionState = state + + self.guild: Guild = guild + self.guild_id: int = int(data["guild_id"]) + + self.id: int = int(data["id"]) + self.created_at: datetime.datetime = utils.parse_time(data["created_at"]) + self.reviewed_at: datetime.datetime | None = utils.parse_time( + data.get("reviewed_at") + ) + + status = data.get("status") + self.status: JoinRequestStatus | None = ( + try_enum(JoinRequestStatus, status) if status is not None else None + ) + self.rejection_status: str | None = data.get("rejection_status") + + user = data.get("user") + self.user: User | None = 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] | None = ( + [FormResponse(r) for r in form_responses] + if form_responses is not None + else None + ) + + actioned_by_user = data.get("actioned_by_user") + self.actioned_by_user: User | None = ( + state.create_user(actioned_by_user) + if actioned_by_user is not None + else None + ) + + @classmethod + def partial(cls, *, guild: Guild, request_id: Object | int) -> JoinRequest: + """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 can be only be used to take action on the join request, and will not have any other values available. + + Parameters + ---------- + guild: :class:`discord.Guild` + The guild the join request belongs to. + request_id: :class:`discord.Object` | :class:`int` + The ID of the join request. + + if a :class:`discord.Object` is provided, the ID will be extracted from it. + + Returns + ------- + :class:`JoinRequest` + The partial join request object. + """ + data = { + "id": request_id.id if not isinstance(request_id, int) else request_id, + "guild_id": guild.id, + "user_id": 0, # Placeholder + "created_at": utils.utcnow().isoformat(), + "status": "SUBMITTED", + } + return cls( + guild=guild, + state=guild._state, + data=data, # type: ignore + ) + + async def take_action( + self, action: JoinRequestAction, rejection_reason: str | None = None + ) -> JoinRequest: + """|coro| + + Takes action on this join request application. + + You can only take action on a join request if the status is :attr:`JoinRequestStatus.SUBMITTED`. + If the join request has already been approved or denied, 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`. + + 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. + + Returns + ------- + :class:`JoinRequest` + The updated join request. + """ + if self.status is not JoinRequestStatus.SUBMITTED: + raise ValueError( + "You can only take action on a join request if the status is SUBMITTED." + ) + data = await self._state.http.action_guild_join_request( + self.guild_id, + self.id, + action=action.value, + rejection_reason=rejection_reason, + ) + return self.__class__(guild=self.guild, state=self._state, data=data) diff --git a/discord/http.py b/discord/http.py index 40ebcd7c1f..8d4dab20ac 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 @@ -88,6 +89,7 @@ webhook, welcome_screen, widget, + guild_join_request, ) from .types.invite import ( InviteTargetUsersJobStatus as InviteTargetUsersJobStatusPayload, @@ -1707,6 +1709,54 @@ def estimate_pruned_members( Route("GET", "/guilds/{guild_id}/prune", guild_id=guild_id), params=params ) + def get_guild_join_requets( + 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..30a8d2226b 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,6 +53,7 @@ "EntitlementIterator", "SubscriptionIterator", "MessagePinIterator", + "JoinRequestIterator", ) if TYPE_CHECKING: @@ -65,6 +67,10 @@ 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, + ListGuildJoinRequests as ListGuildJoinRequestsPayload, + ) from .types.message import Message as MessagePayload from .types.message import MessagePin as MessagePinPayload from .types.monetization import Entitlement as EntitlementPayload @@ -72,6 +78,7 @@ from .types.threads import Thread as ThreadPayload from .types.user import PartialUser as PartialUserPayload from .user import User + from .guild_join_request import JoinRequest T = TypeVar("T") OT = TypeVar("OT") @@ -1297,3 +1304,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_requets + 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: ListGuildJoinRequestsPayload) -> None: + if not self._has_retrieved: + self.total = response.get("total") + self._has_retrieved = True 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..2d625fb559 --- /dev/null +++ b/discord/types/guild_join_request.py @@ -0,0 +1,97 @@ +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", + "DENIED", +] +FormFieldType = Literal[ + "TERMS", + "TEXT_INPUT", + "PARAGRAPH", + "MULTIPLE_CHOICE", +] + + +class JoinRequest(TypedDict): + id: Snowflake + created_at: str # iso + reviewed_at: str | None # iso + application_status: ApplicationStatus | None + rejection_status: str | None + guild_id: Snowflake + 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 _BaseListGuildJoinRequests(TypedDict): + total: NotRequired[int] # only when status is "SUBMITTED" or omitted + + +# only returned with the kick_member permission +class ListGuildJoinRequestsWithPermissions(_BaseListGuildJoinRequests): + guild_join_requests: list[JoinRequest] + + +ListGuildJoinRequests = ( + ListGuildJoinRequestsWithPermissions | _BaseListGuildJoinRequests +) + + +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..275525d708 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:: DENIED + + The application was denied. + + +.. 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/models.rst b/docs/api/models.rst index 12094bc698..0d88d48dfc 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, fetch_join_requests .. automethod:: fetch_members :async-for: @@ -154,6 +154,22 @@ Guild .. automethod:: audit_logs :async-for: + .. automethod:: fetch_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`. From 57b20aaaa3e735c4f4742b24899e0b11ec5eff37 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:41:01 +0000 Subject: [PATCH 02/13] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/guild.py | 8 +++----- discord/guild_join_request.py | 29 ++++++++++++++--------------- discord/http.py | 2 +- discord/iterators.py | 4 ++-- discord/types/guild_join_request.py | 2 +- 5 files changed, 21 insertions(+), 24 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 25d0de8e87..e3af41b46c 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -79,8 +79,8 @@ AuditLogIterator, BanIterator, EntitlementIterator, - MemberIterator, JoinRequestIterator, + MemberIterator, ) from .member import Member, VoiceState from .mixins import Hashable @@ -98,7 +98,6 @@ from .welcome_screen import WelcomeScreen, WelcomeScreenChannel from .widget import Widget - __all__ = ("BanEntry", "Guild", "GuildRoleCounts") MISSING = utils.MISSING @@ -114,6 +113,7 @@ TextChannel, VoiceChannel, ) + from .guild_join_request import JoinRequest from .onboarding import OnboardingPrompt from .permissions import Permissions from .state import ConnectionState @@ -127,7 +127,6 @@ from .types.voice import VoiceState as GuildVoiceState from .voice import VoiceClient from .webhook import Webhook - from .guild_join_request import JoinRequest VocalGuildChannel = Union[VoiceChannel, StageChannel] GuildChannel = Union[ @@ -4773,7 +4772,7 @@ def fetch_join_requests( Defaults to :data:`None`. Yields - ------- + ------ :class:`JoinRequest` The join request. @@ -4800,7 +4799,6 @@ def fetch_join_requests( await iterator.next() print(iterator.total) # prints the total number of submitted join requests requests = await iterator.flatten() - """ return JoinRequestIterator( self, diff --git a/discord/guild_join_request.py b/discord/guild_join_request.py index d55da4cec8..68c3eb5a13 100644 --- a/discord/guild_join_request.py +++ b/discord/guild_join_request.py @@ -23,29 +23,28 @@ """ from __future__ import annotations + import datetime from typing import TYPE_CHECKING -from .mixins import Hashable +from . import utils from .enums import ( - JoinRequestStatus, - JoinRequestFormFieldType, JoinRequestAction, + JoinRequestFormFieldType, + JoinRequestStatus, try_enum, ) -from . import utils +from .mixins import Hashable __all__ = ("FormResponse", "JoinRequest") if TYPE_CHECKING: - from .state import ConnectionState - from .types.guild_join_request import ( - FormResponse as FormResponsePayload, - JoinRequest as JoinRequestPayload, - ) - from .user import User from .guild import Guild from .object import Object + 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 FormResponse: @@ -234,6 +233,11 @@ async def take_action( Only applicable if the `action` is :attr:`JoinRequestAction.REJECT`. + Returns + ------- + :class:`JoinRequest` + The updated join request. + Raises ------ ValueError @@ -243,11 +247,6 @@ async def take_action( Or the `status` is not :attr:`JoinRequestStatus.SUBMITTED`. HTTPException Taking action on the join request failed. - - Returns - ------- - :class:`JoinRequest` - The updated join request. """ if self.status is not JoinRequestStatus.SUBMITTED: raise ValueError( diff --git a/discord/http.py b/discord/http.py index 8d4dab20ac..f537c4d14a 100644 --- a/discord/http.py +++ b/discord/http.py @@ -72,6 +72,7 @@ embed, emoji, guild, + guild_join_request, integration, interactions, invite, @@ -89,7 +90,6 @@ webhook, welcome_screen, widget, - guild_join_request, ) from .types.invite import ( InviteTargetUsersJobStatus as InviteTargetUsersJobStatusPayload, diff --git a/discord/iterators.py b/discord/iterators.py index 30a8d2226b..de532fdd8d 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -59,6 +59,7 @@ 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 @@ -67,8 +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 ( - JoinRequest as JoinRequestPayload, ListGuildJoinRequests as ListGuildJoinRequestsPayload, ) from .types.message import Message as MessagePayload @@ -78,7 +79,6 @@ from .types.threads import Thread as ThreadPayload from .types.user import PartialUser as PartialUserPayload from .user import User - from .guild_join_request import JoinRequest T = TypeVar("T") OT = TypeVar("OT") diff --git a/discord/types/guild_join_request.py b/discord/types/guild_join_request.py index 2d625fb559..a3c61c1fdd 100644 --- a/discord/types/guild_join_request.py +++ b/discord/types/guild_join_request.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Literal, NotRequired, TypedDict + from .snowflake import Snowflake from .user import User @@ -64,7 +65,6 @@ class MultipleChoiceFormResponse(_BaseFormResponse): ) - class _BaseListGuildJoinRequests(TypedDict): total: NotRequired[int] # only when status is "SUBMITTED" or omitted From 8896587244631d4447b56c9becd560449715449d Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:03:51 +0200 Subject: [PATCH 03/13] chore: fix typo in http method --- discord/http.py | 2 +- discord/iterators.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/http.py b/discord/http.py index f537c4d14a..d80a574df2 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1709,7 +1709,7 @@ def estimate_pruned_members( Route("GET", "/guilds/{guild_id}/prune", guild_id=guild_id), params=params ) - def get_guild_join_requets( + def get_guild_join_requests( self, guild_id: Snowflake, *, diff --git a/discord/iterators.py b/discord/iterators.py index de532fdd8d..68b75a6b90 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -1329,7 +1329,7 @@ def __init__( self._has_retrieved = False self.state = self.guild._state - self.get_join_requests = self.state.http.get_guild_join_requets + self.get_join_requests = self.state.http.get_guild_join_requests self.join_requests = asyncio.Queue() if self.after: From c6d7506f54b75cc3c98dc4cf49a4547fa6001f05 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:40:06 +0200 Subject: [PATCH 04/13] chore: events --- discord/guild.py | 5 ++-- discord/raw_models.py | 29 ++++++++++++++++++++ discord/state.py | 42 +++++++++++++++++++++++++++++ discord/types/guild_join_request.py | 8 +++--- docs/api/events.rst | 39 +++++++++++++++++++++++++++ docs/api/models.rst | 9 +++++-- 6 files changed, 124 insertions(+), 8 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index e3af41b46c..fed0abd4b1 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4743,14 +4743,15 @@ def fetch_join_requests( 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. The iterator's :attr:`total` - attribute is only available for submitted join requests. + fetches submitted join requests. Defaults to :data:`None`. limit: Optional[:class:`int`] diff --git a/discord/raw_models.py b/discord/raw_models.py index 291a44fadc..0f46767a23 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -74,6 +74,7 @@ VoiceServerUpdateEvent, VoiceStateEvent, ) + from .types.guild_join_request import JoinRequestDelete as JoinRequestDeletePayload from .user import User @@ -99,6 +100,7 @@ "RawVoiceServerUpdateEvent", "RawVoiceStateUpdateEvent", "RawMemberUpdateEvent", + "RawGuildJoinRequestDeleteEvent", ) @@ -1030,3 +1032,30 @@ 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.8 + + 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", "user_id", "id") + + def __init__(self, data: JoinRequestDeletePayload) -> None: + self.data: JoinRequestDeletePayload = data + + self.guild_id: int = int(data["guild_id"]) + self.user_id: int = int(data["user_id"]) + self.id: int = int(data["id"]) diff --git a/discord/state.py b/discord/state.py index 83137e94bb..e8cc2c0e2a 100644 --- a/discord/state.py +++ b/discord/state.py @@ -71,6 +71,7 @@ from .ui.modal import BaseModal, ModalStore from .ui.view import BaseView, ViewStore from .user import ClientUser, User +from .guild_join_request import JoinRequest if TYPE_CHECKING: from .abc import PrivateChannel @@ -89,6 +90,11 @@ from .types.sticker import GuildSticker as GuildStickerPayload from .types.user import User as UserPayload from .voice import VoiceProtocol + from .types.guild_join_request import ( + JoinRequestCreate as JoinRequestCreatePayload, + JoinRequestDelete as JoinRequestDeletePayload, + JoinRequestUpdate as JoinRequestUpdatePayload, + ) T = TypeVar("T") CS = TypeVar("CS", bound="ConnectionState") @@ -1780,6 +1786,42 @@ def parse_guild_integrations_update(self, data) -> None: data["guild_id"], ) + def parse_guild_join_request_create(self, data: JoinRequestCreatePayload) -> None: + guild = self._get_guild(int(data["guild_id"])) + request = data["request"] + if guild is not None: + join_request = JoinRequest(guild=guild, state=self, data=request) + self.dispatch("guild_join_request_create", join_request) + else: + _log.debug( + ( + "GUILD_JOIN_REQUEST_CREATE referencing an unknown guild ID: %s." + " Discarding." + ), + data["guild_id"], + ) + + def parse_guild_join_request_delete(self, data: JoinRequestDeletePayload) -> None: + raw = RawGuildJoinRequestDeleteEvent(data) + self.dispatch("raw_guild_join_request_delete", raw) + + def parse_guild_join_request_update( + self, data: JoinRequestUpdatePayload + ) -> None: + guild = self._get_guild(int(data["guild_id"])) + request = data["request"] + if guild is not None: + join_request = JoinRequest(guild=guild, state=self, data=request) + self.dispatch("guild_join_request_update", join_request) + else: + _log.debug( + ( + "GUILD_JOIN_REQUEST_UPDATE referencing an unknown guild ID: %s." + " Discarding." + ), + data["guild_id"], + ) + 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_join_request.py b/discord/types/guild_join_request.py index a3c61c1fdd..276bbfed75 100644 --- a/discord/types/guild_join_request.py +++ b/discord/types/guild_join_request.py @@ -65,17 +65,17 @@ class MultipleChoiceFormResponse(_BaseFormResponse): ) -class _BaseListGuildJoinRequests(TypedDict): +class _BaseListJoinRequests(TypedDict): total: NotRequired[int] # only when status is "SUBMITTED" or omitted # only returned with the kick_member permission -class ListGuildJoinRequestsWithPermissions(_BaseListGuildJoinRequests): +class ListJoinRequestsWithPermissions(_BaseListJoinRequests): guild_join_requests: list[JoinRequest] -ListGuildJoinRequests = ( - ListGuildJoinRequestsWithPermissions | _BaseListGuildJoinRequests +ListJoinRequests = ( + ListJoinRequestsWithPermissions | _BaseListJoinRequests ) 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 0d88d48dfc..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, fetch_join_requests + :exclude-members: fetch_members, audit_logs, join_requests .. automethod:: fetch_members :async-for: @@ -154,7 +154,7 @@ Guild .. automethod:: audit_logs :async-for: - .. automethod:: fetch_join_requests + .. automethod:: join_requests :async-for: Guild Join Requests @@ -717,6 +717,11 @@ Events .. autoclass:: RawMemberRemoveEvent() :members: +.. attributetable:: RawGuildJoinRequestDeleteEvent + +.. autoclass:: RawGuildJoinRequestDeleteEvent() + :members: + .. attributetable:: RawThreadUpdateEvent .. autoclass:: RawThreadUpdateEvent() From 321c279997c4c44ed1e20c45c4f0475d439ed2c2 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:43:44 +0200 Subject: [PATCH 05/13] Guild.fetch_join_requests -> Guild.join_requests --- discord/guild.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index fed0abd4b1..8c0b8e293d 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4723,7 +4723,7 @@ def get_sound(self, sound_id: int) -> SoundboardSound | None: """ return self._sounds.get(sound_id) - def fetch_join_requests( + def join_requests( self, *, status: JoinRequestStatus | None = None, @@ -4787,16 +4787,16 @@ def fetch_join_requests( Usage :: - async for request in guild.fetch_join_requests(limit=250): + async for request in guild.join_requests(limit=250): print(request.user, request.status) - +s Flattening into a list :: - requests = await guild.fetch_join_requests(limit=None).flatten() + 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.fetch_join_requests(limit=None) + iterator = guild.join_requests(limit=None) await iterator.next() print(iterator.total) # prints the total number of submitted join requests requests = await iterator.flatten() From 8bba006e0f2c852fad02d220c8d61b973a2d7f12 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:44:52 +0000 Subject: [PATCH 06/13] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/guild.py | 136 ++++++++++++++-------------- discord/raw_models.py | 2 +- discord/state.py | 14 +-- discord/types/guild_join_request.py | 4 +- 4 files changed, 75 insertions(+), 81 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 8c0b8e293d..58666823b7 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4732,74 +4732,74 @@ def join_requests( 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) -s - 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() + 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) + s + 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, diff --git a/discord/raw_models.py b/discord/raw_models.py index 0f46767a23..0137112843 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, @@ -74,7 +75,6 @@ VoiceServerUpdateEvent, VoiceStateEvent, ) - from .types.guild_join_request import JoinRequestDelete as JoinRequestDeletePayload from .user import User diff --git a/discord/state.py b/discord/state.py index e8cc2c0e2a..f368362c90 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 @@ -71,7 +72,6 @@ from .ui.modal import BaseModal, ModalStore from .ui.view import BaseView, ViewStore from .user import ClientUser, User -from .guild_join_request import JoinRequest if TYPE_CHECKING: from .abc import PrivateChannel @@ -84,17 +84,15 @@ 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 from .types.sticker import GuildSticker as GuildStickerPayload from .types.user import User as UserPayload from .voice import VoiceProtocol - from .types.guild_join_request import ( - JoinRequestCreate as JoinRequestCreatePayload, - JoinRequestDelete as JoinRequestDeletePayload, - JoinRequestUpdate as JoinRequestUpdatePayload, - ) T = TypeVar("T") CS = TypeVar("CS", bound="ConnectionState") @@ -1805,9 +1803,7 @@ def parse_guild_join_request_delete(self, data: JoinRequestDeletePayload) -> Non raw = RawGuildJoinRequestDeleteEvent(data) self.dispatch("raw_guild_join_request_delete", raw) - def parse_guild_join_request_update( - self, data: JoinRequestUpdatePayload - ) -> None: + def parse_guild_join_request_update(self, data: JoinRequestUpdatePayload) -> None: guild = self._get_guild(int(data["guild_id"])) request = data["request"] if guild is not None: diff --git a/discord/types/guild_join_request.py b/discord/types/guild_join_request.py index 276bbfed75..c75a51ab67 100644 --- a/discord/types/guild_join_request.py +++ b/discord/types/guild_join_request.py @@ -74,9 +74,7 @@ class ListJoinRequestsWithPermissions(_BaseListJoinRequests): guild_join_requests: list[JoinRequest] -ListJoinRequests = ( - ListJoinRequestsWithPermissions | _BaseListJoinRequests -) +ListJoinRequests = ListJoinRequestsWithPermissions | _BaseListJoinRequests class JoinRequestCreate(TypedDict): From 4d8b034b1a207a1078b147016a92407623fa7f84 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:16:17 +0200 Subject: [PATCH 07/13] refactor: requested changes and (partial) refactor --- discord/enums.py | 2 +- discord/guild.py | 136 ++++++----- discord/guild_join_request.py | 339 ++++++++++++++++------------ discord/http.py | 2 +- discord/iterators.py | 4 +- discord/raw_models.py | 28 ++- discord/state.py | 50 ++-- discord/types/guild_join_request.py | 36 ++- docs/api/enums.rst | 4 +- 9 files changed, 343 insertions(+), 258 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index f60218896a..69fd6640f2 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1221,7 +1221,7 @@ class JoinRequestStatus(Enum): STARTED = "STARTED" SUBMITTED = "SUBMITTED" APPROVED = "APPROVED" - DENIED = "DENIED" + REJECTED = "REJECTED" class JoinRequestFormFieldType(Enum): diff --git a/discord/guild.py b/discord/guild.py index 58666823b7..32ef1755bf 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4731,75 +4731,73 @@ def join_requests( 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) - s - 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() + """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, diff --git a/discord/guild_join_request.py b/discord/guild_join_request.py index 68c3eb5a13..95f16617ca 100644 --- a/discord/guild_join_request.py +++ b/discord/guild_join_request.py @@ -25,7 +25,7 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, overload from . import utils from .enums import ( @@ -39,77 +39,143 @@ __all__ = ("FormResponse", "JoinRequest") if TYPE_CHECKING: + from .abc import Snowflake from .guild import Guild - from .object import Object from .state import ConnectionState from .types.guild_join_request import FormResponse as FormResponsePayload - from .types.guild_join_request import JoinRequest as JoinRequestPayload + from .types.guild_join_request import ( + JoinRequest as JoinRequestPayload, + ) from .user import User -class FormResponse: - """Represents a form response for a guild join request. +class PartialJoinRequest(Hashable): + """Represents a partial guild join request. .. versionadded:: 2.9 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. + 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. + """ - 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`: + __slots__ = ("_state", "application_status", "guild", "guild_id", "id") - - 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:`int` representing 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. + def __init__( + self, + *, + guild: Snowflake | Guild | int, + request: JoinRequest | Snowflake | int, + state: ConnectionState, + application_status: JoinRequestStatus | str | None = None, + ) -> None: + self._state: ConnectionState = state - Only set if the `field_type` is :attr:`JoinRequestFormFieldType.MULTIPLE_CHOICE`. - """ + 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 - def __init__(self, data: FormResponsePayload) -> None: - self.field_type: JoinRequestFormFieldType = try_enum( - JoinRequestFormFieldType, data["field_type"] + self.application_status: JoinRequestStatus | None = ( + try_enum(JoinRequestStatus, application_status) + if not isinstance(application_status, JoinRequestStatus) + else application_status ) - 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.response: str | int | bool | None = data.get("response") - self.placeholder: str | None = data.get("placeholder") - self.choices: list[str] | None = data.get("choices") + @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(Hashable): +class JoinRequest(PartialJoinRequest): __slots__ = ( - "_state", - "id", - "guild", - "guild_id", - "user", - "user_id", + "actioned_by_user", "created_at", + "form_responses", + "rejection_reason", "reviewed_at", "status", - "rejection_status", - "form_responses", - "actioned_by_user", + "user", + "user_id", ) """Represents a guild join request. @@ -121,141 +187,134 @@ class JoinRequest(Hashable): id: :class:`int` The join request ID. guild_id: :class:`int` - The guild ID. + 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 user ID of the requester. + 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: Optional[:class:`datetime.datetime`] + reviewed_at: :class:`datetime.datetime` | :data:`None` When the join request was reviewed, if applicable. - status: Optional[:class:`JoinRequestStatus`] + application_status: :class:`JoinRequestStatus` | :data:`None` The status of the application, if applicable. - rejection_status: Optional[:class:`str`] - The rejection status of the join request, if applicable. - user: Optional[:class:`discord.User`] - The user who made the join request. This is only available if the join request was fetched with the ``with_user`` parameter set to ``True``. - form_responses: Optional[List[:class:`discord.FormResponse`]] - The form responses of the join request, if applicable. This is only available if the join request was fetched with the ``with_form_responses`` parameter set to ``True``. - actioned_by_user: Optional[:class:`discord.User`] - The user who actioned the join request, if applicable. This is only available if the join request was fetched with the ``with_actioned_by_user`` parameter set to ``True``. + 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_user: :class:`discord.User` | :data:`None` + The user who actioned the join request, if applicable. """ - def __init__( - self, *, guild: Guild, state: ConnectionState, data: JoinRequestPayload - ) -> None: - self._state: ConnectionState = state - - self.guild: Guild = guild - self.guild_id: int = int(data["guild_id"]) + 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.id: int = int(data["id"]) self.created_at: datetime.datetime = utils.parse_time(data["created_at"]) self.reviewed_at: datetime.datetime | None = utils.parse_time( data.get("reviewed_at") ) - - status = data.get("status") - self.status: JoinRequestStatus | None = ( - try_enum(JoinRequestStatus, status) if status is not None else None - ) - self.rejection_status: str | None = data.get("rejection_status") + self.rejection_reason: str | None = data.get("rejection_reason") user = data.get("user") - self.user: User | None = state.create_user(user) if user is not None else None + 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] | None = ( - [FormResponse(r) for r in form_responses] - if form_responses is not None - else None - ) + 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: User | None = ( - state.create_user(actioned_by_user) + self._state.create_user(actioned_by_user) if actioned_by_user is not None else None ) @classmethod - def partial(cls, *, guild: Guild, request_id: Object | int) -> JoinRequest: + 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 can be only be used to take action on the join request, and will not have any other values available. + 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_id: :class:`discord.Object` | :class:`int` - The ID of the join request. + request: :class:`discord.Snowflake` | :class:`discord.JoinRequest` | :class:`int` + The join request to create a partial object for. - if a :class:`discord.Object` is provided, the ID will be extracted from it. + If a :class:`discord.abc.Snowflake` or :class:`discord.JoinRequest` is provided, + the ID will be extracted from it. Returns ------- - :class:`JoinRequest` + :class:`PartialJoinRequest` The partial join request object. """ - data = { - "id": request_id.id if not isinstance(request_id, int) else request_id, - "guild_id": guild.id, - "user_id": 0, # Placeholder - "created_at": utils.utcnow().isoformat(), - "status": "SUBMITTED", - } - return cls( - guild=guild, - state=guild._state, - data=data, # type: ignore - ) + return PartialJoinRequest(guild=guild, request=request, state=guild._state) - async def take_action( - self, action: JoinRequestAction, rejection_reason: str | None = None - ) -> JoinRequest: - """|coro| - Takes action on this join request application. +class FormResponse: + """Represents a form response for a guild join request. - You can only take action on a join request if the status is :attr:`JoinRequestStatus.SUBMITTED`. - If the join request has already been approved or denied, this will raise :exc:`ValueError`. + .. 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. - This requires the :attr:`~Permissions.kick_members` permission. + 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`: - 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. + - 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:`int` representing 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 applicable if the `action` is :attr:`JoinRequestAction.REJECT`. + 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. - Returns - ------- - :class:`JoinRequest` - The updated join request. + Only set if the `field_type` is :attr:`JoinRequestFormFieldType.MULTIPLE_CHOICE`. + """ - 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.status is not JoinRequestStatus.SUBMITTED: - raise ValueError( - "You can only take action on a join request if the status is SUBMITTED." - ) - data = await self._state.http.action_guild_join_request( - self.guild_id, - self.id, - action=action.value, - rejection_reason=rejection_reason, + def __init__(self, data: FormResponsePayload) -> None: + self.field_type: JoinRequestFormFieldType = try_enum( + JoinRequestFormFieldType, data["field_type"] ) - return self.__class__(guild=self.guild, state=self._state, data=data) + 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.response: str | int | bool | None = data.get("response") + self.placeholder: str | None = data.get("placeholder") + self.choices: list[str] | None = data.get("choices") diff --git a/discord/http.py b/discord/http.py index d80a574df2..b3227836d9 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1747,7 +1747,7 @@ def action_guild_join_request( ) -> Response[guild_join_request.JoinRequest]: return self.request( Route( - "PATCH ", + "PATCH", "/guilds/{guild_id}/requests/{request_id}", guild_id=guild_id, request_id=request_id, diff --git a/discord/iterators.py b/discord/iterators.py index 68b75a6b90..72a87aeabe 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -70,7 +70,7 @@ from .types.guild import Guild as GuildPayload from .types.guild_join_request import JoinRequest as JoinRequestPayload from .types.guild_join_request import ( - ListGuildJoinRequests as ListGuildJoinRequestsPayload, + ListJoinRequests as ListJoinRequestsPayload, ) from .types.message import Message as MessagePayload from .types.message import MessagePin as MessagePinPayload @@ -1395,7 +1395,7 @@ async def _retrieve_join_requests_after_strategy(self, retrieve: int): self.after = Object(id=int(data[0]["id"])) return data - def _set_total(self, response: ListGuildJoinRequestsPayload) -> None: + 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 0137112843..db7f711b4c 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -1037,7 +1037,7 @@ def __init__(self, data: MemberUpdateEvent, member: Member) -> None: class RawGuildJoinRequestDeleteEvent(_RawReprMixin): """Represents the payload for a :func:`on_raw_guild_join_request_delete` event. - .. versionadded:: 2.8 + .. versionadded:: 2.9 Attributes ---------- @@ -1051,11 +1051,31 @@ class RawGuildJoinRequestDeleteEvent(_RawReprMixin): The raw data sent by the `gateway `_. """ - __slots__ = ("data", "guild_id", "user_id", "id") + __slots__ = ("data", "guild", "guild_id", "id", "user", "user_id") - def __init__(self, data: JoinRequestDeletePayload) -> None: + 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"]) - self.id: int = int(data["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 f368362c90..a7c2a45efb 100644 --- a/discord/state.py +++ b/discord/state.py @@ -932,13 +932,11 @@ def parse_message_poll_vote_add(self, data) -> None: if answer.id in counts: counts[answer.id].count += 1 else: - counts[answer.id] = PollAnswerCount( - { - "id": answer.id, - "count": 1, - "me_voted": False, - } - ) + counts[answer.id] = PollAnswerCount({ + "id": answer.id, + "count": 1, + "me_voted": False, + }) if poll is not None and user is not None: answer = poll.get_answer(raw.answer_id) if answer is not None: @@ -1785,38 +1783,20 @@ def parse_guild_integrations_update(self, data) -> None: ) def parse_guild_join_request_create(self, data: JoinRequestCreatePayload) -> None: - guild = self._get_guild(int(data["guild_id"])) - request = data["request"] - if guild is not None: - join_request = JoinRequest(guild=guild, state=self, data=request) - self.dispatch("guild_join_request_create", join_request) - else: - _log.debug( - ( - "GUILD_JOIN_REQUEST_CREATE referencing an unknown guild ID: %s." - " Discarding." - ), - data["guild_id"], - ) + self.dispatch( + "guild_join_request_create", JoinRequest(state=self, data=data["request"]) + ) def parse_guild_join_request_delete(self, data: JoinRequestDeletePayload) -> None: - raw = RawGuildJoinRequestDeleteEvent(data) - self.dispatch("raw_guild_join_request_delete", raw) + self.dispatch( + "raw_guild_join_request_delete", + RawGuildJoinRequestDeleteEvent(state=self, data=data), + ) def parse_guild_join_request_update(self, data: JoinRequestUpdatePayload) -> None: - guild = self._get_guild(int(data["guild_id"])) - request = data["request"] - if guild is not None: - join_request = JoinRequest(guild=guild, state=self, data=request) - self.dispatch("guild_join_request_update", join_request) - else: - _log.debug( - ( - "GUILD_JOIN_REQUEST_UPDATE referencing an unknown guild ID: %s." - " Discarding." - ), - data["guild_id"], - ) + 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")) diff --git a/discord/types/guild_join_request.py b/discord/types/guild_join_request.py index c75a51ab67..491098a3b1 100644 --- a/discord/types/guild_join_request.py +++ b/discord/types/guild_join_request.py @@ -1,3 +1,27 @@ +""" +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 @@ -9,7 +33,7 @@ "STARTED", # started, but not yet submitted "SUBMITTED", # submitted, but not yet reviewed "APPROVED", - "DENIED", + "REJECTED", ] FormFieldType = Literal[ "TERMS", @@ -19,13 +43,17 @@ ] -class JoinRequest(TypedDict): +class BaseJoinRequest(TypedDict): id: Snowflake + guild_id: Snowflake + application_status: ApplicationStatus | None + + +class JoinRequest(BaseJoinRequest): created_at: str # iso reviewed_at: str | None # iso application_status: ApplicationStatus | None - rejection_status: str | None - guild_id: Snowflake + rejection_reason: str | None user_id: Snowflake user: NotRequired[User] form_responses: NotRequired[list[FormResponse]] diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 275525d708..941f06793e 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2722,9 +2722,9 @@ of :class:`enum.Enum`. The application was approved. - .. attribute:: DENIED + .. attribute:: REJECTED - The application was denied. + The application was rejected. .. class:: JoinRequestFormFieldType From ec908279fcf53af24703ba8d179868ae80cb51b0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:16:52 +0000 Subject: [PATCH 08/13] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/guild_join_request.py | 5 ++--- discord/iterators.py | 4 +--- discord/state.py | 12 +++++++----- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/discord/guild_join_request.py b/discord/guild_join_request.py index 95f16617ca..3038dbea2f 100644 --- a/discord/guild_join_request.py +++ b/discord/guild_join_request.py @@ -43,9 +43,7 @@ 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 .types.guild_join_request import JoinRequest as JoinRequestPayload from .user import User @@ -276,6 +274,7 @@ class FormResponse: .. versionadded:: 2.9 If the join request has already been approved or rejected, this will raise :exc:`ValueError`. + Attributes ---------- field_type: :class:`JoinRequestFormFieldType` diff --git a/discord/iterators.py b/discord/iterators.py index 72a87aeabe..e3c2b3fe6c 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -69,9 +69,7 @@ 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.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 diff --git a/discord/state.py b/discord/state.py index a7c2a45efb..d2d8db460f 100644 --- a/discord/state.py +++ b/discord/state.py @@ -932,11 +932,13 @@ def parse_message_poll_vote_add(self, data) -> None: if answer.id in counts: counts[answer.id].count += 1 else: - counts[answer.id] = PollAnswerCount({ - "id": answer.id, - "count": 1, - "me_voted": False, - }) + counts[answer.id] = PollAnswerCount( + { + "id": answer.id, + "count": 1, + "me_voted": False, + } + ) if poll is not None and user is not None: answer = poll.get_answer(raw.answer_id) if answer is not None: From d26dd703d23fcf935d44c019b67be25c6ea783a9 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:19:54 +0200 Subject: [PATCH 09/13] chore: revert BaseJoinRequest --- discord/types/guild_join_request.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/discord/types/guild_join_request.py b/discord/types/guild_join_request.py index 491098a3b1..1d5df00a6f 100644 --- a/discord/types/guild_join_request.py +++ b/discord/types/guild_join_request.py @@ -43,16 +43,13 @@ ] -class BaseJoinRequest(TypedDict): +class JoinRequest(TypedDict): id: Snowflake guild_id: Snowflake application_status: ApplicationStatus | None - - -class JoinRequest(BaseJoinRequest): created_at: str # iso reviewed_at: str | None # iso - application_status: ApplicationStatus | None + reviewed_by_user: NotRequired[User] rejection_reason: str | None user_id: Snowflake user: NotRequired[User] From d9701276552ecf9a1f6fbd3fccec8f992df100e8 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:27:55 +0200 Subject: [PATCH 10/13] chore: actioned_by_user -> actioned_by --- discord/guild_join_request.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/guild_join_request.py b/discord/guild_join_request.py index 3038dbea2f..787393fb87 100644 --- a/discord/guild_join_request.py +++ b/discord/guild_join_request.py @@ -166,7 +166,7 @@ async def take_action( class JoinRequest(PartialJoinRequest): __slots__ = ( - "actioned_by_user", + "actioned_by", "created_at", "form_responses", "rejection_reason", @@ -202,7 +202,7 @@ class JoinRequest(PartialJoinRequest): 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_user: :class:`discord.User` | :data:`None` + actioned_by: :class:`discord.User` | :data:`None` The user who actioned the join request, if applicable. """ @@ -232,7 +232,7 @@ def __init__(self, *, state: ConnectionState, data: JoinRequestPayload) -> None: ] actioned_by_user = data.get("actioned_by_user") - self.actioned_by_user: User | None = ( + self.actioned_by: User | None = ( self._state.create_user(actioned_by_user) if actioned_by_user is not None else None From a37897a50d964257130b791c12bbf8589480c048 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:54:27 +0200 Subject: [PATCH 11/13] chore: parse choices response --- discord/guild_join_request.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/discord/guild_join_request.py b/discord/guild_join_request.py index 787393fb87..9f7ba31fe6 100644 --- a/discord/guild_join_request.py +++ b/discord/guild_join_request.py @@ -293,7 +293,8 @@ class FormResponse: 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:`int` representing the index of the selected choice. + - 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. @@ -303,6 +304,9 @@ class FormResponse: 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: @@ -314,6 +318,19 @@ def __init__(self, data: FormResponsePayload) -> None: self.required: bool = data.get("required", False) self.values: list[str] | None = data.get("values") - self.response: str | int | bool | None = data.get("response") 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] From 31544401c265b92e4c8c11830e17a8635d9d056c Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:55:43 +0200 Subject: [PATCH 12/13] fix: duplicated guild attribute --- discord/guild_join_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/guild_join_request.py b/discord/guild_join_request.py index 9f7ba31fe6..f46c28df01 100644 --- a/discord/guild_join_request.py +++ b/discord/guild_join_request.py @@ -64,7 +64,7 @@ class PartialJoinRequest(Hashable): The status of the join request application. """ - __slots__ = ("_state", "application_status", "guild", "guild_id", "id") + __slots__ = ("_state", "application_status", "guild_id", "id") def __init__( self, From 07c9a79340107a8387218c3fc264499c7b8667e2 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:59:04 +0200 Subject: [PATCH 13/13] fix: duplicated guild and user attributes --- discord/raw_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/raw_models.py b/discord/raw_models.py index db7f711b4c..c51c506f3a 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -1051,7 +1051,7 @@ class RawGuildJoinRequestDeleteEvent(_RawReprMixin): The raw data sent by the `gateway `_. """ - __slots__ = ("data", "guild", "guild_id", "id", "user", "user_id") + __slots__ = ("data", "guild_id", "id", "user_id") def __init__( self,