From c082d3e16dabda6bafd6e8496810b330f5de4ce7 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:50:42 +0100 Subject: [PATCH 01/27] Add support for community invites --- discord/abc.py | 16 +++++ discord/enums.py | 8 +++ discord/http.py | 65 ++++++++++++++++++ discord/invite.py | 146 +++++++++++++++++++++++++++++++++++++++- discord/types/invite.py | 11 +++ docs/api.rst | 22 ++++++ 6 files changed, 266 insertions(+), 2 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 95ccfd67b..54309ab85 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1283,6 +1283,8 @@ class GuildChannel: target_user: Optional[User] = None, target_application_id: Optional[int] = None, guest: bool = False, + roles: Optional[List[Snowflake]] = None, + users: Optional[List[Snowflake]] = None, ) -> Invite: """|coro| @@ -1325,6 +1327,18 @@ class GuildChannel: Whether the invite is a guest invite. .. versionadded:: 2.6 + roles: Optional[List[:class:`~discord.abc.Snowflake`]] + A list of roles that should be granted to the users joining via this invite. + + Requires :attr:`~discord.Permissions.manage_guild` permission. + + .. versionadded:: 2.7 + users: Optional[List[:class:`~discord.abc.Snowflake`]] + A list of user IDs that should be able to access this invite. + + Requires :attr:`~discord.Permissions.manage_guild` permission. + + .. versionadded:: 2.7 Raises ------- @@ -1358,6 +1372,8 @@ class GuildChannel: target_user_id=target_user.id if target_user else None, target_application_id=target_application_id, flags=flags.value if flags else None, + role_ids=[role.id for role in roles or []], + user_ids=[user.id for user in users or []], ) return Invite.from_incomplete(data=data, state=self._state) diff --git a/discord/enums.py b/discord/enums.py index 260222894..6f37af421 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -87,6 +87,7 @@ __all__ = ( 'MediaItemLoadingState', 'CollectibleType', 'NameplatePalette', + 'InviteTargetUsersJobErrorStatus' ) @@ -1002,6 +1003,13 @@ class NameplatePalette(Enum): white = 'white' +class InviteTargetUsersJobErrorStatus(Enum): + unspecified = 0 + pending = 1 + completed = 2 + failed = 3 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/http.py b/discord/http.py index 0219c1196..ab07b8146 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1837,6 +1837,24 @@ class HTTPClient: # Invite management + def _generate_invite_multipart( + self, + *, + payload: dict[str, Any], + user_ids: List[Snowflake], + ) -> MultipartParameters: + users ="Users\n" + "\n".join(str(user_id) for user_id in user_ids) + form = [ + {'name': 'payload_json', 'value': utils._to_json(payload)}, + {'name': 'target_users_file', 'value': users, 'filename': 'users.csv', 'content_type': 'text/csv'} + ] + + return MultipartParameters( + multipart=form, + payload={}, + files=None, + ) + def create_invite( self, channel_id: Snowflake, @@ -1850,6 +1868,8 @@ class HTTPClient: target_user_id: Optional[Snowflake] = None, target_application_id: Optional[Snowflake] = None, flags: Optional[int] = None, + role_ids: Optional[List[Snowflake]] = None, + user_ids: Optional[List[Snowflake]] = None, ) -> Response[invite.Invite]: r = Route('POST', '/channels/{channel_id}/invites', channel_id=channel_id) payload = { @@ -1871,6 +1891,17 @@ class HTTPClient: if flags: payload['flags'] = flags + if role_ids: + payload["role_ids"] = list(map(str, role_ids)) + + if user_ids: + multipart_params = self._generate_invite_multipart(payload=payload, user_ids=user_ids) + return self.request( + r, + form=multipart_params.multipart, + reason=reason, + ) + return self.request(r, reason=reason, json=payload) def get_invite( @@ -1898,6 +1929,40 @@ class HTTPClient: def delete_invite(self, invite_id: str, *, reason: Optional[str] = None) -> Response[invite.Invite]: return self.request(Route('DELETE', '/invites/{invite_id}', invite_id=invite_id), reason=reason) + def get_invite_target_users( + self, + invite_id: str, + ) -> Response[str]: + return self.request( + Route('GET', '/invites/{invite_id}/target-users', invite_id=invite_id) + ) + + def edit_invite_target_users( + self, + invite_id: str, + user_ids: List[Snowflake], + ) -> Response[None]: + multipart_params = self._generate_invite_multipart( + payload={}, + user_ids=user_ids, + ) + return self.request( + Route('PUT', '/invites/{invite_id}/target-users', invite_id=invite_id), + form=multipart_params.multipart, + ) + + def get_invite_target_users_job_status( + self, + invite_id: str, + ) -> Response[invite.InviteTargetUsersJobStatus]: + return self.request( + Route( + "GET", + '/invites/{invite_id}/target-users/job-status', + invite_id=invite_id, + ) + ) + # Role management def get_roles(self, guild_id: Snowflake) -> Response[List[role.Role]]: diff --git a/discord/invite.py b/discord/invite.py index 8b5088a89..95a67673e 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -26,13 +26,22 @@ from __future__ import annotations from typing import List, Optional, Union, TYPE_CHECKING from .asset import Asset -from .utils import parse_time, snowflake_time, _get_as_snowflake +from .utils import parse_time, snowflake_time, _get_as_snowflake, MISSING from .object import Object from .mixins import Hashable -from .enums import ChannelType, NSFWLevel, VerificationLevel, InviteTarget, InviteType, try_enum +from .enums import ( + ChannelType, + NSFWLevel, + VerificationLevel, + InviteTarget, + InviteType, + InviteTargetUsersJobErrorStatus, + try_enum, +) from .appinfo import PartialAppInfo from .scheduled_event import ScheduledEvent from .flags import InviteFlags +from .role import Role __all__ = ( 'PartialInviteChannel', @@ -47,6 +56,7 @@ if TYPE_CHECKING: Invite as InvitePayload, InviteGuild as InviteGuildPayload, GatewayInvite as GatewayInvitePayload, + InviteTargetUsersJobStatus as InviteTargetUsersJobStatusPayload, ) from .types.guild import GuildFeature from .types.channel import ( @@ -64,6 +74,47 @@ if TYPE_CHECKING: import datetime +class InviteTargetUsersJobStatus: + """Represents the status of an invite's target users job. + + .. versionadded:: 2.7 + + Attributes + ----------- + invite: :class:`Invite` + The invite this job status is for. + status: :class:`InviteTargetUsersJobStatus` + The status of the job. + total_users: :class:`int` + The total number of users in the job. + processed_users: :class:`int` + The number of users that have been processed so far. + created_at: :class:`datetime.datetime` + The time the job was created. + error_message: :class:`str` + The error message. + completed_at: Optional[:class:`datetime.datetime`] + The time the job was completed, if applicable. + """ + + def __init__(self, *, invite: Invite, data: InviteTargetUsersJobStatusPayload) -> None: + self.invite: Invite = invite + self.status: InviteTargetUsersJobErrorStatus = try_enum(InviteTargetUsersJobErrorStatus, data['status']) + self.total_users: int = data['total_users'] + self.processed_users: int = data['processed_users'] + self.created_at: datetime.datetime = parse_time(data['created_at']) + self.error_message: str = data['error_message'] + self.completed_at: Optional[datetime.datetime] = ( + parse_time(data['completed_at']) if data.get('completed_at') else None + ) + + def __repr__(self) -> str: + return ( + f'' + ) + + class PartialInviteChannel: """Represents a "partial" invite channel. @@ -358,6 +409,9 @@ class Invite(Hashable): The ID of the scheduled event associated with this invite, if any. .. versionadded:: 2.0 + roles: List[:class:`Role`] + A list of roles the invite grants access to. Only available if the + invite's guild is cached. """ __slots__ = ( @@ -382,6 +436,7 @@ class Invite(Hashable): 'scheduled_event_id', 'type', '_flags', + 'roles', ) BASE = 'https://discord.gg' @@ -437,6 +492,13 @@ class Invite(Hashable): self.scheduled_event_id: Optional[int] = self.scheduled_event.id if self.scheduled_event else None self._flags: int = data.get('flags', 0) + if self.guild is not None and not isinstance(self.guild, (PartialInviteGuild, Object)): + self.roles: List[Role] = [ + Role(state=self._state, guild=self.guild, data=role_data) for role_data in data.get('roles', []) + ] + else: + self.roles: List[Role] = [] + @classmethod def from_incomplete(cls, *, state: ConnectionState, data: InvitePayload) -> Self: guild: Optional[Union[Guild, PartialInviteGuild]] @@ -582,3 +644,83 @@ class Invite(Hashable): data = await self._state.http.delete_invite(self.code, reason=reason) return self.from_incomplete(state=self._state, data=data) + + async def fetch_target_users(self) -> list[int]: + """|coro| + + Fetches the target users CSV file for this invite. + + Requires the :attr:`~Permissions.manage_guild` permission. + + Returns + -------- + List[:class:`int`] + A list of user IDs representing the target users. + + Raises + ------- + Forbidden + You do not have permissions to fetch target users. + NotFound + The invite is invalid or expired or the invite does not have target users. + HTTPException + Fetching the target users failed. + """ + + string = await self._state.http.get_invite_target_users(self.code) + users = string.lstrip('Users\n').split('\n') + return [int(user_id) for user_id in users if user_id] + + async def fetch_target_users_job_status(self) -> InviteTargetUsersJobStatus: + """|coro| + + Fetches the status of the target users job for this invite. + + Requires the :attr:`~Permissions.manage_guild` permission. + + Returns + -------- + :class:`InviteTargetUsersJobStatus` + A dictionary containing the status of the target users job. + + Raises + ------- + Forbidden + You do not have permissions to fetch target users job status. + NotFound + The invite is invalid or expired or there is no ongoing target users job. + HTTPException + Fetching the target users job status failed. + """ + + data = await self._state.http.get_invite_target_users_job_status(self.code) + return InviteTargetUsersJobStatus(invite=self, data=data) + + async def edit( + self, + *, + users: List[Snowflake] = MISSING, + ) -> None: + """|coro| + + Edits the invite. + + Requires the :attr:`~Permissions.manage_guild` permission. + + Parameters + ----------- + users: List[:class:`~discord.abc.Snowflake`] + A list of users that should be able to use this invite. + + Raises + ------- + Forbidden + You do not have permissions to edit invites. + NotFound + The invite is invalid or expired. + HTTPException + Editing the invite failed. + """ + + if users is not MISSING: + await self._state.http.edit_invite_target_users(self.code, user_ids=[user.id for user in users]) diff --git a/discord/types/invite.py b/discord/types/invite.py index 38e28f959..76302d34a 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -33,6 +33,7 @@ from .guild import InviteGuild, _GuildPreviewUnique from .channel import PartialChannel from .user import PartialUser from .appinfo import PartialAppInfo +from .role import Role InviteTargetType = Literal[1, 2] InviteType = Literal[0, 1, 2] @@ -66,6 +67,7 @@ class Invite(IncompleteInvite, total=False): type: InviteType flags: NotRequired[int] expires_at: Optional[str] + roles: NotRequired[list[Role]] class InviteWithCounts(Invite, _GuildPreviewUnique): ... @@ -95,3 +97,12 @@ class GatewayInviteDelete(TypedDict): GatewayInvite = Union[GatewayInviteCreate, GatewayInviteDelete] + + +class InviteTargetUsersJobStatus(TypedDict): + status: int + total_users: int + processed_users: int + created_at: str + error_message: str + completed_at: Optional[str] diff --git a/docs/api.rst b/docs/api.rst index 495636b12..ae7a0860b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4154,6 +4154,28 @@ of :class:`enum.Enum`. The collectible nameplate palette is white. +.. class:: InviteTargetUsersJobErrorStatus + + Represents the error status of an invite target users job. + + .. versionadded:: 2.7 + + .. attribute:: unspecified + + The default value. + + .. attribute:: processing + + The job is still being processed. + + .. attribute:: completed + + The job has been completed successfully. + + .. attribute:: failed + + The job has failed, see `error_message` field for more details. + .. _discord-api-audit-logs: Audit Log Data From f163dbf2a58cf718a03dfc67da0bea2e82b8c8a6 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:00:39 +0100 Subject: [PATCH 02/27] parse_time can take None --- discord/invite.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index 95a67673e..60906f0a7 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -104,9 +104,7 @@ class InviteTargetUsersJobStatus: self.processed_users: int = data['processed_users'] self.created_at: datetime.datetime = parse_time(data['created_at']) self.error_message: str = data['error_message'] - self.completed_at: Optional[datetime.datetime] = ( - parse_time(data['completed_at']) if data.get('completed_at') else None - ) + self.completed_at: Optional[datetime.datetime] = parse_time(data.get('completed_at')) def __repr__(self) -> str: return ( From 8c5a8a024b7634e81b3050268cbb042c273a2293 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:03:56 +0100 Subject: [PATCH 03/27] Ruff wants to reformat 172 files? --- discord/enums.py | 2 +- discord/http.py | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index 6f37af421..774dea750 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -87,7 +87,7 @@ __all__ = ( 'MediaItemLoadingState', 'CollectibleType', 'NameplatePalette', - 'InviteTargetUsersJobErrorStatus' + 'InviteTargetUsersJobErrorStatus', ) diff --git a/discord/http.py b/discord/http.py index ab07b8146..f6e8b6f73 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1843,10 +1843,10 @@ class HTTPClient: payload: dict[str, Any], user_ids: List[Snowflake], ) -> MultipartParameters: - users ="Users\n" + "\n".join(str(user_id) for user_id in user_ids) + users = 'Users\n' + '\n'.join(str(user_id) for user_id in user_ids) form = [ {'name': 'payload_json', 'value': utils._to_json(payload)}, - {'name': 'target_users_file', 'value': users, 'filename': 'users.csv', 'content_type': 'text/csv'} + {'name': 'target_users_file', 'value': users, 'filename': 'users.csv', 'content_type': 'text/csv'}, ] return MultipartParameters( @@ -1892,7 +1892,7 @@ class HTTPClient: payload['flags'] = flags if role_ids: - payload["role_ids"] = list(map(str, role_ids)) + payload['role_ids'] = list(map(str, role_ids)) if user_ids: multipart_params = self._generate_invite_multipart(payload=payload, user_ids=user_ids) @@ -1933,9 +1933,7 @@ class HTTPClient: self, invite_id: str, ) -> Response[str]: - return self.request( - Route('GET', '/invites/{invite_id}/target-users', invite_id=invite_id) - ) + return self.request(Route('GET', '/invites/{invite_id}/target-users', invite_id=invite_id)) def edit_invite_target_users( self, @@ -1957,7 +1955,7 @@ class HTTPClient: ) -> Response[invite.InviteTargetUsersJobStatus]: return self.request( Route( - "GET", + 'GET', '/invites/{invite_id}/target-users/job-status', invite_id=invite_id, ) From a374276310264ff0f5da3a2701ed74e941784718 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:10:39 +0100 Subject: [PATCH 04/27] Better docs --- discord/abc.py | 5 +++-- discord/invite.py | 15 +++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 54309ab85..7a2e17c25 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1330,11 +1330,12 @@ class GuildChannel: roles: Optional[List[:class:`~discord.abc.Snowflake`]] A list of roles that should be granted to the users joining via this invite. - Requires :attr:`~discord.Permissions.manage_guild` permission. + Requires :attr:`~discord.Permissions.manage_guild` permission and cannot + assign roles with higher permissions than the bot. .. versionadded:: 2.7 users: Optional[List[:class:`~discord.abc.Snowflake`]] - A list of user IDs that should be able to access this invite. + A list of users that are allowed to join via this invite. Requires :attr:`~discord.Permissions.manage_guild` permission. diff --git a/discord/invite.py b/discord/invite.py index 60906f0a7..72280659a 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -408,8 +408,11 @@ class Invite(Hashable): .. versionadded:: 2.0 roles: List[:class:`Role`] - A list of roles the invite grants access to. Only available if the - invite's guild is cached. + A list of roles that are granted to users joining via this invite. + + This is only filled if the bot is in the guild where the invite belongs. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -646,14 +649,14 @@ class Invite(Hashable): async def fetch_target_users(self) -> list[int]: """|coro| - Fetches the target users CSV file for this invite. + Fetches the users that are allowed to join via this invite. Requires the :attr:`~Permissions.manage_guild` permission. Returns -------- List[:class:`int`] - A list of user IDs representing the target users. + A list of user IDs. Raises ------- @@ -678,8 +681,8 @@ class Invite(Hashable): Returns -------- - :class:`InviteTargetUsersJobStatus` - A dictionary containing the status of the target users job. + :class:`InviteTargetUsersJobErrorStatus` + The status of the target users job. Raises ------- From b7082063d46306c24ce5926e5bfd2dc058f2b38b Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:05:40 +0100 Subject: [PATCH 05/27] InviteTargetUsersJobErrorStatus -> InviteJobStatus --- discord/enums.py | 4 ++-- discord/invite.py | 6 +++--- docs/api.rst | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index 774dea750..46b3b61ba 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -87,7 +87,7 @@ __all__ = ( 'MediaItemLoadingState', 'CollectibleType', 'NameplatePalette', - 'InviteTargetUsersJobErrorStatus', + 'InviteJobStatus', ) @@ -1003,7 +1003,7 @@ class NameplatePalette(Enum): white = 'white' -class InviteTargetUsersJobErrorStatus(Enum): +class InviteJobStatus(Enum): unspecified = 0 pending = 1 completed = 2 diff --git a/discord/invite.py b/discord/invite.py index 72280659a..13207a475 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -35,7 +35,7 @@ from .enums import ( VerificationLevel, InviteTarget, InviteType, - InviteTargetUsersJobErrorStatus, + InviteJobStatus, try_enum, ) from .appinfo import PartialAppInfo @@ -99,7 +99,7 @@ class InviteTargetUsersJobStatus: def __init__(self, *, invite: Invite, data: InviteTargetUsersJobStatusPayload) -> None: self.invite: Invite = invite - self.status: InviteTargetUsersJobErrorStatus = try_enum(InviteTargetUsersJobErrorStatus, data['status']) + self.status: InviteJobStatus = try_enum(InviteJobStatus, data['status']) self.total_users: int = data['total_users'] self.processed_users: int = data['processed_users'] self.created_at: datetime.datetime = parse_time(data['created_at']) @@ -681,7 +681,7 @@ class Invite(Hashable): Returns -------- - :class:`InviteTargetUsersJobErrorStatus` + :class:`InviteJobStatus` The status of the target users job. Raises diff --git a/docs/api.rst b/docs/api.rst index ae7a0860b..cbcc50565 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4154,7 +4154,7 @@ of :class:`enum.Enum`. The collectible nameplate palette is white. -.. class:: InviteTargetUsersJobErrorStatus +.. class:: InviteJobStatus Represents the error status of an invite target users job. From b758dd72ad4332786d14bb79679cff8dde49ed8f Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:18:53 +0100 Subject: [PATCH 06/27] List -> Sequence --- discord/abc.py | 8 ++++---- discord/invite.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 7a2e17c25..a828f366b 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1283,8 +1283,8 @@ class GuildChannel: target_user: Optional[User] = None, target_application_id: Optional[int] = None, guest: bool = False, - roles: Optional[List[Snowflake]] = None, - users: Optional[List[Snowflake]] = None, + roles: Optional[Sequence[Snowflake]] = None, + users: Optional[Sequence[Snowflake]] = None, ) -> Invite: """|coro| @@ -1327,14 +1327,14 @@ class GuildChannel: Whether the invite is a guest invite. .. versionadded:: 2.6 - roles: Optional[List[:class:`~discord.abc.Snowflake`]] + roles: Optional[Sequence[:class:`~discord.abc.Snowflake`]] A list of roles that should be granted to the users joining via this invite. Requires :attr:`~discord.Permissions.manage_guild` permission and cannot assign roles with higher permissions than the bot. .. versionadded:: 2.7 - users: Optional[List[:class:`~discord.abc.Snowflake`]] + users: Optional[Sequence[:class:`~discord.abc.Snowflake`]] A list of users that are allowed to join via this invite. Requires :attr:`~discord.Permissions.manage_guild` permission. diff --git a/discord/invite.py b/discord/invite.py index 13207a475..f08969c3b 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -24,7 +24,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import List, Optional, Union, TYPE_CHECKING +from typing import List, Optional, Sequence, Union, TYPE_CHECKING from .asset import Asset from .utils import parse_time, snowflake_time, _get_as_snowflake, MISSING from .object import Object @@ -700,7 +700,7 @@ class Invite(Hashable): async def edit( self, *, - users: List[Snowflake] = MISSING, + users: Sequence[Snowflake] = MISSING, ) -> None: """|coro| From 2ec68d4322b8829e467d036ddded36c9d7366a1c Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:19:53 +0100 Subject: [PATCH 07/27] Cast ids to str beforehand --- discord/abc.py | 4 ++-- discord/http.py | 6 +++--- discord/invite.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index a828f366b..e71f551d4 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1373,8 +1373,8 @@ class GuildChannel: target_user_id=target_user.id if target_user else None, target_application_id=target_application_id, flags=flags.value if flags else None, - role_ids=[role.id for role in roles or []], - user_ids=[user.id for user in users or []], + role_ids=[str(role.id) for role in roles or []], + user_ids=[str(user.id) for user in users or []], ) return Invite.from_incomplete(data=data, state=self._state) diff --git a/discord/http.py b/discord/http.py index f6e8b6f73..82b3025d8 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1841,9 +1841,9 @@ class HTTPClient: self, *, payload: dict[str, Any], - user_ids: List[Snowflake], + user_ids: List[str], ) -> MultipartParameters: - users = 'Users\n' + '\n'.join(str(user_id) for user_id in user_ids) + users = "Users\n" + "\n".join(user_ids) form = [ {'name': 'payload_json', 'value': utils._to_json(payload)}, {'name': 'target_users_file', 'value': users, 'filename': 'users.csv', 'content_type': 'text/csv'}, @@ -1938,7 +1938,7 @@ class HTTPClient: def edit_invite_target_users( self, invite_id: str, - user_ids: List[Snowflake], + user_ids: List[str], ) -> Response[None]: multipart_params = self._generate_invite_multipart( payload={}, diff --git a/discord/invite.py b/discord/invite.py index f08969c3b..8d318bc11 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -724,4 +724,4 @@ class Invite(Hashable): """ if users is not MISSING: - await self._state.http.edit_invite_target_users(self.code, user_ids=[user.id for user in users]) + await self._state.http.edit_invite_target_users(self.code, user_ids=[str(user.id) for user in users]) From fa6aab903fae77dd9b25984e5c882adc97fa25c1 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:22:08 +0100 Subject: [PATCH 08/27] InviteTargetUsersJobStatus -> InviteUsersJob --- discord/invite.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index 8d318bc11..9a3531d7b 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -74,7 +74,7 @@ if TYPE_CHECKING: import datetime -class InviteTargetUsersJobStatus: +class InviteUsersJob: """Represents the status of an invite's target users job. .. versionadded:: 2.7 @@ -83,7 +83,7 @@ class InviteTargetUsersJobStatus: ----------- invite: :class:`Invite` The invite this job status is for. - status: :class:`InviteTargetUsersJobStatus` + status: :class:`InviteTargetUsersJob` The status of the job. total_users: :class:`int` The total number of users in the job. @@ -108,7 +108,7 @@ class InviteTargetUsersJobStatus: def __repr__(self) -> str: return ( - f'' ) @@ -672,7 +672,7 @@ class Invite(Hashable): users = string.lstrip('Users\n').split('\n') return [int(user_id) for user_id in users if user_id] - async def fetch_target_users_job_status(self) -> InviteTargetUsersJobStatus: + async def fetch_target_users_job_status(self) -> InviteUsersJob: """|coro| Fetches the status of the target users job for this invite. @@ -695,7 +695,7 @@ class Invite(Hashable): """ data = await self._state.http.get_invite_target_users_job_status(self.code) - return InviteTargetUsersJobStatus(invite=self, data=data) + return InviteUsersJob(invite=self, data=data) async def edit( self, From e64d8b71bda0e2b573e0daba2beb484e34f07cee Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:23:17 +0100 Subject: [PATCH 09/27] InviteJobStatus -> InviteUsersJobStatus --- discord/enums.py | 4 ++-- discord/invite.py | 4 ++-- docs/api.rst | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index 46b3b61ba..5521df92c 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -87,7 +87,7 @@ __all__ = ( 'MediaItemLoadingState', 'CollectibleType', 'NameplatePalette', - 'InviteJobStatus', + 'InviteUsersJobStatus', ) @@ -1003,7 +1003,7 @@ class NameplatePalette(Enum): white = 'white' -class InviteJobStatus(Enum): +class InviteUsersJobStatus(Enum): unspecified = 0 pending = 1 completed = 2 diff --git a/discord/invite.py b/discord/invite.py index 9a3531d7b..53c7d8be6 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -35,7 +35,7 @@ from .enums import ( VerificationLevel, InviteTarget, InviteType, - InviteJobStatus, + InviteUsersJobStatus, try_enum, ) from .appinfo import PartialAppInfo @@ -99,7 +99,7 @@ class InviteUsersJob: def __init__(self, *, invite: Invite, data: InviteTargetUsersJobStatusPayload) -> None: self.invite: Invite = invite - self.status: InviteJobStatus = try_enum(InviteJobStatus, data['status']) + self.status: InviteUsersJobStatus = try_enum(InviteUsersJobStatus, data['status']) self.total_users: int = data['total_users'] self.processed_users: int = data['processed_users'] self.created_at: datetime.datetime = parse_time(data['created_at']) diff --git a/docs/api.rst b/docs/api.rst index cbcc50565..b4bf0601d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4154,7 +4154,7 @@ of :class:`enum.Enum`. The collectible nameplate palette is white. -.. class:: InviteJobStatus +.. class:: InviteUsersJobStatus Represents the error status of an invite target users job. From d524c5764306a00a26be47dcbba6f0a4d957676c Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:29:00 +0100 Subject: [PATCH 10/27] Almost everything is optional for InviteTargetUsersJobStatus --- discord/invite.py | 8 ++++---- discord/types/invite.py | 6 +++--- docs/api.rst | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index 53c7d8be6..064f04175 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -91,7 +91,7 @@ class InviteUsersJob: The number of users that have been processed so far. created_at: :class:`datetime.datetime` The time the job was created. - error_message: :class:`str` + error_message: Optional[:class:`str`] The error message. completed_at: Optional[:class:`datetime.datetime`] The time the job was completed, if applicable. @@ -102,13 +102,13 @@ class InviteUsersJob: self.status: InviteUsersJobStatus = try_enum(InviteUsersJobStatus, data['status']) self.total_users: int = data['total_users'] self.processed_users: int = data['processed_users'] - self.created_at: datetime.datetime = parse_time(data['created_at']) - self.error_message: str = data['error_message'] + self.error_message: Optional[str] = data.get('error_message') + self.created_at: Optional[datetime.datetime] = parse_time(data.get('created_at')) self.completed_at: Optional[datetime.datetime] = parse_time(data.get('completed_at')) def __repr__(self) -> str: return ( - f'' ) diff --git a/discord/types/invite.py b/discord/types/invite.py index 76302d34a..40842d1ea 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -103,6 +103,6 @@ class InviteTargetUsersJobStatus(TypedDict): status: int total_users: int processed_users: int - created_at: str - error_message: str - completed_at: Optional[str] + created_at: NotRequired[Optional[str]] + error_message: NotRequired[Optional[str]] + completed_at: NotRequired[Optional[str]] diff --git a/docs/api.rst b/docs/api.rst index b4bf0601d..afec60ea7 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4156,7 +4156,7 @@ of :class:`enum.Enum`. .. class:: InviteUsersJobStatus - Represents the error status of an invite target users job. + Represents the status of an invite target users job. .. versionadded:: 2.7 From 8dbb49d65248a1b6ca80ba6d0ca8a9ab6e4e3a9f Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:35:20 +0100 Subject: [PATCH 11/27] Revert "Cast ids to str beforehand" --- discord/abc.py | 4 ++-- discord/http.py | 6 +++--- discord/invite.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index e71f551d4..a828f366b 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1373,8 +1373,8 @@ class GuildChannel: target_user_id=target_user.id if target_user else None, target_application_id=target_application_id, flags=flags.value if flags else None, - role_ids=[str(role.id) for role in roles or []], - user_ids=[str(user.id) for user in users or []], + role_ids=[role.id for role in roles or []], + user_ids=[user.id for user in users or []], ) return Invite.from_incomplete(data=data, state=self._state) diff --git a/discord/http.py b/discord/http.py index 82b3025d8..73aed7833 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1841,9 +1841,9 @@ class HTTPClient: self, *, payload: dict[str, Any], - user_ids: List[str], + user_ids: List[Snowflake], ) -> MultipartParameters: - users = "Users\n" + "\n".join(user_ids) + users = "Users\n" + "\n".join(map(str, user_ids)) form = [ {'name': 'payload_json', 'value': utils._to_json(payload)}, {'name': 'target_users_file', 'value': users, 'filename': 'users.csv', 'content_type': 'text/csv'}, @@ -1938,7 +1938,7 @@ class HTTPClient: def edit_invite_target_users( self, invite_id: str, - user_ids: List[str], + user_ids: List[Snowflake], ) -> Response[None]: multipart_params = self._generate_invite_multipart( payload={}, diff --git a/discord/invite.py b/discord/invite.py index 064f04175..4fbd434c1 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -724,4 +724,4 @@ class Invite(Hashable): """ if users is not MISSING: - await self._state.http.edit_invite_target_users(self.code, user_ids=[str(user.id) for user in users]) + await self._state.http.edit_invite_target_users(self.code, user_ids=[user.id for user in users]) From ae34c15925191bfc383a4df4c2e5f3e626fbe8a5 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:36:57 +0100 Subject: [PATCH 12/27] uv run ruff format discord --- discord/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/http.py b/discord/http.py index 73aed7833..ee02fe052 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1843,7 +1843,7 @@ class HTTPClient: payload: dict[str, Any], user_ids: List[Snowflake], ) -> MultipartParameters: - users = "Users\n" + "\n".join(map(str, user_ids)) + users = 'Users\n' + '\n'.join(map(str, user_ids)) form = [ {'name': 'payload_json', 'value': utils._to_json(payload)}, {'name': 'target_users_file', 'value': users, 'filename': 'users.csv', 'content_type': 'text/csv'}, From 4ca79f62e17458093f5a69053f96e82cba66e479 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:57:39 +0100 Subject: [PATCH 13/27] roles require manage_roles permission --- discord/abc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index a828f366b..bb7d1faba 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1330,14 +1330,14 @@ class GuildChannel: roles: Optional[Sequence[:class:`~discord.abc.Snowflake`]] A list of roles that should be granted to the users joining via this invite. - Requires :attr:`~discord.Permissions.manage_guild` permission and cannot + Requires the :attr:`~discord.Permissions.manage_roles` permission and cannot assign roles with higher permissions than the bot. .. versionadded:: 2.7 users: Optional[Sequence[:class:`~discord.abc.Snowflake`]] A list of users that are allowed to join via this invite. - Requires :attr:`~discord.Permissions.manage_guild` permission. + Requires the :attr:`~discord.Permissions.manage_guild` permission. .. versionadded:: 2.7 From beeffdce966fb515328447ce2fe4e477c176ff1f Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:05:56 +0100 Subject: [PATCH 14/27] Handle role ids from gateway --- discord/invite.py | 22 ++++++++++++++++------ discord/types/invite.py | 1 + 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index 4fbd434c1..63955d21c 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -407,10 +407,11 @@ class Invite(Hashable): The ID of the scheduled event associated with this invite, if any. .. versionadded:: 2.0 - roles: List[:class:`Role`] + roles: List[Union[:class:`Role`, :class:`Object`]] A list of roles that are granted to users joining via this invite. This is only filled if the bot is in the guild where the invite belongs. + This may contain :class:`Object` instances if the role is not cached. .. versionadded:: 2.7 """ @@ -493,12 +494,14 @@ class Invite(Hashable): self.scheduled_event_id: Optional[int] = self.scheduled_event.id if self.scheduled_event else None self._flags: int = data.get('flags', 0) - if self.guild is not None and not isinstance(self.guild, (PartialInviteGuild, Object)): - self.roles: List[Role] = [ - Role(state=self._state, guild=self.guild, data=role_data) for role_data in data.get('roles', []) + roles = data.get('roles', []) + self.roles: List[Union[Role, Object]] + if roles and self.guild is not None and not isinstance(self.guild, (PartialInviteGuild, Object)): + self.roles = [ + Role(state=self._state, guild=self.guild, data=role_data) for role_data in roles ] else: - self.roles: List[Role] = [] + self.roles = [] @classmethod def from_incomplete(cls, *, state: ConnectionState, data: InvitePayload) -> Self: @@ -535,7 +538,14 @@ class Invite(Hashable): guild = state._get_or_create_unavailable_guild(guild_id) if guild_id is not None else None channel = Object(id=channel_id) - return cls(state=state, data=data, guild=guild, channel=channel) # type: ignore + res = cls(state=state, data=data, guild=guild, channel=channel) # type: ignore + + # gateway events do not include role objects, only IDs + role_ids: list[Union[int, str]] = data.pop('role_ids', []) # type: ignore # .pop returns T | object + if role_ids and guild is not None and not isinstance(guild, (PartialInviteGuild, Object)): + res.roles = [guild.get_role(int(role_id)) or Object(role_id) for role_id in role_ids] + + return res def _resolve_guild( self, diff --git a/discord/types/invite.py b/discord/types/invite.py index 40842d1ea..ef0f41be9 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -88,6 +88,7 @@ class GatewayInviteCreate(TypedDict): target_user: NotRequired[PartialUser] target_application: NotRequired[PartialAppInfo] flags: NotRequired[int] + role_ids: NotRequired[list[Snowflake]] class GatewayInviteDelete(TypedDict): From 8193941050c7c2034ba7d94360cacbf1fe96d42c Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:06:20 +0100 Subject: [PATCH 15/27] uv run ruff format discord --- discord/invite.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index 63955d21c..585d1f3ee 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -497,9 +497,7 @@ class Invite(Hashable): roles = data.get('roles', []) self.roles: List[Union[Role, Object]] if roles and self.guild is not None and not isinstance(self.guild, (PartialInviteGuild, Object)): - self.roles = [ - Role(state=self._state, guild=self.guild, data=role_data) for role_data in roles - ] + self.roles = [Role(state=self._state, guild=self.guild, data=role_data) for role_data in roles] else: self.roles = [] @@ -541,7 +539,7 @@ class Invite(Hashable): res = cls(state=state, data=data, guild=guild, channel=channel) # type: ignore # gateway events do not include role objects, only IDs - role_ids: list[Union[int, str]] = data.pop('role_ids', []) # type: ignore # .pop returns T | object + role_ids: list[Union[int, str]] = data.pop('role_ids', []) # type: ignore # .pop returns T | object if role_ids and guild is not None and not isinstance(guild, (PartialInviteGuild, Object)): res.roles = [guild.get_role(int(role_id)) or Object(role_id) for role_id in role_ids] From b0533663ce8ed8aea0362d3ebb4674fea03eb148 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:09:32 +0100 Subject: [PATCH 16/27] Simplify form data gen --- discord/http.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/discord/http.py b/discord/http.py index ee02fe052..83acd33c4 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1837,24 +1837,18 @@ class HTTPClient: # Invite management - def _generate_invite_multipart( + def _generate_invite_form( self, *, payload: dict[str, Any], user_ids: List[Snowflake], - ) -> MultipartParameters: + ) -> list[dict[str, Any]]: users = 'Users\n' + '\n'.join(map(str, user_ids)) - form = [ + return [ {'name': 'payload_json', 'value': utils._to_json(payload)}, {'name': 'target_users_file', 'value': users, 'filename': 'users.csv', 'content_type': 'text/csv'}, ] - return MultipartParameters( - multipart=form, - payload={}, - files=None, - ) - def create_invite( self, channel_id: Snowflake, @@ -1895,10 +1889,10 @@ class HTTPClient: payload['role_ids'] = list(map(str, role_ids)) if user_ids: - multipart_params = self._generate_invite_multipart(payload=payload, user_ids=user_ids) + form = self._generate_invite_form(payload=payload, user_ids=user_ids) return self.request( r, - form=multipart_params.multipart, + form=form, reason=reason, ) @@ -1940,14 +1934,11 @@ class HTTPClient: invite_id: str, user_ids: List[Snowflake], ) -> Response[None]: - multipart_params = self._generate_invite_multipart( + form = self._generate_invite_form( payload={}, user_ids=user_ids, ) - return self.request( - Route('PUT', '/invites/{invite_id}/target-users', invite_id=invite_id), - form=multipart_params.multipart, - ) + return self.request(Route('PUT', '/invites/{invite_id}/target-users', invite_id=invite_id), form=form) def get_invite_target_users_job_status( self, From a07ed32e9e70d1b3be4758a0b5684cd64451dc72 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:57:11 +0100 Subject: [PATCH 17/27] Do requested changes Co-Authored-By: dolfies --- discord/abc.py | 6 +++--- discord/invite.py | 30 +++++++++++++++++------------- discord/utils.py | 7 +++++++ 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index bb7d1faba..54253572d 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1284,7 +1284,7 @@ class GuildChannel: target_application_id: Optional[int] = None, guest: bool = False, roles: Optional[Sequence[Snowflake]] = None, - users: Optional[Sequence[Snowflake]] = None, + target_users: Optional[Sequence[Snowflake]] = None, ) -> Invite: """|coro| @@ -1334,7 +1334,7 @@ class GuildChannel: assign roles with higher permissions than the bot. .. versionadded:: 2.7 - users: Optional[Sequence[:class:`~discord.abc.Snowflake`]] + target_users: Optional[Sequence[:class:`~discord.abc.Snowflake`]] A list of users that are allowed to join via this invite. Requires the :attr:`~discord.Permissions.manage_guild` permission. @@ -1374,7 +1374,7 @@ class GuildChannel: target_application_id=target_application_id, flags=flags.value if flags else None, role_ids=[role.id for role in roles or []], - user_ids=[user.id for user in users or []], + user_ids=[user.id for user in target_users or []], ) return Invite.from_incomplete(data=data, state=self._state) diff --git a/discord/invite.py b/discord/invite.py index 585d1f3ee..ffcbebea7 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -26,7 +26,7 @@ from __future__ import annotations from typing import List, Optional, Sequence, Union, TYPE_CHECKING from .asset import Asset -from .utils import parse_time, snowflake_time, _get_as_snowflake, MISSING +from .utils import parse_time, snowflake_time, _get_as_snowflake, MISSING, _get_target_ids_from_csv from .object import Object from .mixins import Hashable from .enums import ( @@ -89,7 +89,7 @@ class InviteUsersJob: The total number of users in the job. processed_users: :class:`int` The number of users that have been processed so far. - created_at: :class:`datetime.datetime` + created_at: Optional[:class:`datetime.datetime`] The time the job was created. error_message: Optional[:class:`str`] The error message. @@ -654,7 +654,7 @@ class Invite(Hashable): data = await self._state.http.delete_invite(self.code, reason=reason) return self.from_incomplete(state=self._state, data=data) - async def fetch_target_users(self) -> list[int]: + async def target_users(self) -> list[int]: """|coro| Fetches the users that are allowed to join via this invite. @@ -676,11 +676,10 @@ class Invite(Hashable): Fetching the target users failed. """ - string = await self._state.http.get_invite_target_users(self.code) - users = string.lstrip('Users\n').split('\n') - return [int(user_id) for user_id in users if user_id] + res = await self._state.http.get_invite_target_users(self.code) + return _get_target_ids_from_csv(res) - async def fetch_target_users_job_status(self) -> InviteUsersJob: + async def target_users_job_status(self) -> InviteUsersJob: """|coro| Fetches the status of the target users job for this invite. @@ -689,7 +688,7 @@ class Invite(Hashable): Returns -------- - :class:`InviteJobStatus` + :class:`InviteUsersJob` The status of the target users job. Raises @@ -708,19 +707,24 @@ class Invite(Hashable): async def edit( self, *, - users: Sequence[Snowflake] = MISSING, + target_users: Sequence[Snowflake] = MISSING, ) -> None: """|coro| Edits the invite. - Requires the :attr:`~Permissions.manage_guild` permission. - Parameters ----------- users: List[:class:`~discord.abc.Snowflake`] A list of users that should be able to use this invite. + Requires the :attr:`~Permissions.manage_guild` permission. + + .. note:: + You cannot clear the list of target users once set. + + There must be at least one user in the list. + Raises ------- Forbidden @@ -731,5 +735,5 @@ class Invite(Hashable): Editing the invite failed. """ - if users is not MISSING: - await self._state.http.edit_invite_target_users(self.code, user_ids=[user.id for user in users]) + if target_users: + await self._state.http.edit_invite_target_users(self.code, user_ids=[user.id for user in target_users]) diff --git a/discord/utils.py b/discord/utils.py index ce4b9e396..cc8b6e67b 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -26,6 +26,7 @@ from __future__ import annotations import array import asyncio +import csv from textwrap import TextWrapper from typing import ( Any, @@ -1535,6 +1536,12 @@ def _format_call_duration(duration: datetime.timedelta) -> str: return formatted +def _get_target_ids_from_csv(res: str) -> List[int]: + reader = csv.reader(res.splitlines()) + first_column = [row[0] for row in reader] + return [int(i) for i in first_column[1:]] + + class _RawReprMixin: __slots__: Tuple[str, ...] = () From d9a74407b3d36e6ddb8e8019bc546d0ce34842f4 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:01:24 +0100 Subject: [PATCH 18/27] Document InviteUsersJob --- discord/invite.py | 5 +++-- docs/api.rst | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index ffcbebea7..1349ec792 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -47,6 +47,7 @@ __all__ = ( 'PartialInviteChannel', 'PartialInviteGuild', 'Invite', + 'InviteUsersJob', ) if TYPE_CHECKING: @@ -83,7 +84,7 @@ class InviteUsersJob: ----------- invite: :class:`Invite` The invite this job status is for. - status: :class:`InviteTargetUsersJob` + status: :class:`InviteUsersJobStatus` The status of the job. total_users: :class:`int` The total number of users in the job. @@ -688,7 +689,7 @@ class Invite(Hashable): Returns -------- - :class:`InviteUsersJob` + :class:`discord.InviteUsersJob` The status of the target users job. Raises diff --git a/docs/api.rst b/docs/api.rst index afec60ea7..e874e0f07 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5526,6 +5526,14 @@ Invite .. autoclass:: Invite() :members: +InviteUsersJob +~~~~~~~~~~~~~~ + +.. attributetable:: InviteUsersJob + +.. autoclass:: InviteUsersJob() + :members: + Template ~~~~~~~~~ From 7e9eef1ad875683e1a80a7b0e7da6063b24a055e Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:36:50 +0100 Subject: [PATCH 19/27] Update permissions https://github.com/discord/discord-api-docs/pull/8090 --- discord/abc.py | 2 -- discord/invite.py | 12 +++++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 54253572d..6e0b6f586 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1337,8 +1337,6 @@ class GuildChannel: target_users: Optional[Sequence[:class:`~discord.abc.Snowflake`]] A list of users that are allowed to join via this invite. - Requires the :attr:`~discord.Permissions.manage_guild` permission. - .. versionadded:: 2.7 Raises diff --git a/discord/invite.py b/discord/invite.py index 1349ec792..2b1137fa9 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -660,7 +660,9 @@ class Invite(Hashable): Fetches the users that are allowed to join via this invite. - Requires the :attr:`~Permissions.manage_guild` permission. + Requires the bot to have created the invite or one of + the following permissions: :attr:`~Permissions.manage_guild`, + :attr:`~Permissions.view_audit_log`. Returns -------- @@ -685,7 +687,10 @@ class Invite(Hashable): Fetches the status of the target users job for this invite. - Requires the :attr:`~Permissions.manage_guild` permission. + Requires the bot to have created the invite or one of + the following permissions: :attr:`~Permissions.manage_guild`, + :attr:`~Permissions.view_audit_log`. + Returns -------- @@ -719,7 +724,8 @@ class Invite(Hashable): users: List[:class:`~discord.abc.Snowflake`] A list of users that should be able to use this invite. - Requires the :attr:`~Permissions.manage_guild` permission. + Requires the bot to have created the invite or the + :attr:`~Permissions.manage_guild` permission. .. note:: You cannot clear the list of target users once set. From a251a3f6465b7b3eef67195d55ef972b8cb4403b Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:27:56 +0100 Subject: [PATCH 20/27] Remove invite from InviteUsersJob for consistency --- discord/invite.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index 2b1137fa9..5a4345d01 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -82,8 +82,6 @@ class InviteUsersJob: Attributes ----------- - invite: :class:`Invite` - The invite this job status is for. status: :class:`InviteUsersJobStatus` The status of the job. total_users: :class:`int` @@ -98,8 +96,7 @@ class InviteUsersJob: The time the job was completed, if applicable. """ - def __init__(self, *, invite: Invite, data: InviteTargetUsersJobStatusPayload) -> None: - self.invite: Invite = invite + def __init__(self, data: InviteTargetUsersJobStatusPayload) -> None: self.status: InviteUsersJobStatus = try_enum(InviteUsersJobStatus, data['status']) self.total_users: int = data['total_users'] self.processed_users: int = data['processed_users'] @@ -109,7 +106,7 @@ class InviteUsersJob: def __repr__(self) -> str: return ( - f'<{self.__class__.__name__} invite={self.invite.code!r} status={self.status} ' + f'<{self.__class__.__name__} status={self.status} ' f'total_users={self.total_users} processed_users={self.processed_users}>' ) @@ -708,7 +705,7 @@ class Invite(Hashable): """ data = await self._state.http.get_invite_target_users_job_status(self.code) - return InviteUsersJob(invite=self, data=data) + return InviteUsersJob(data) async def edit( self, From 96427cca1b41f8b824a753004525ca350f9ee4ca Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:32:48 +0100 Subject: [PATCH 21/27] Revert to splitting target ids --- discord/http.py | 2 +- discord/invite.py | 4 ++-- discord/utils.py | 7 ------- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/discord/http.py b/discord/http.py index 83acd33c4..33825c925 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1843,7 +1843,7 @@ class HTTPClient: payload: dict[str, Any], user_ids: List[Snowflake], ) -> list[dict[str, Any]]: - users = 'Users\n' + '\n'.join(map(str, user_ids)) + users = 'user_id\n' + '\n'.join(map(str, user_ids)) return [ {'name': 'payload_json', 'value': utils._to_json(payload)}, {'name': 'target_users_file', 'value': users, 'filename': 'users.csv', 'content_type': 'text/csv'}, diff --git a/discord/invite.py b/discord/invite.py index 5a4345d01..fb70c43b4 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -26,7 +26,7 @@ from __future__ import annotations from typing import List, Optional, Sequence, Union, TYPE_CHECKING from .asset import Asset -from .utils import parse_time, snowflake_time, _get_as_snowflake, MISSING, _get_target_ids_from_csv +from .utils import parse_time, snowflake_time, _get_as_snowflake, MISSING from .object import Object from .mixins import Hashable from .enums import ( @@ -677,7 +677,7 @@ class Invite(Hashable): """ res = await self._state.http.get_invite_target_users(self.code) - return _get_target_ids_from_csv(res) + return list(map(int, res.lstrip('user_id\n').strip().split('\n'))) if res else [] async def target_users_job_status(self) -> InviteUsersJob: """|coro| diff --git a/discord/utils.py b/discord/utils.py index cc8b6e67b..ce4b9e396 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -26,7 +26,6 @@ from __future__ import annotations import array import asyncio -import csv from textwrap import TextWrapper from typing import ( Any, @@ -1536,12 +1535,6 @@ def _format_call_duration(duration: datetime.timedelta) -> str: return formatted -def _get_target_ids_from_csv(res: str) -> List[int]: - reader = csv.reader(res.splitlines()) - first_column = [row[0] for row in reader] - return [int(i) for i in first_column[1:]] - - class _RawReprMixin: __slots__: Tuple[str, ...] = () From 3b8ce4ddecbf9d931abeec760669965c3f731d43 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:33:47 +0100 Subject: [PATCH 22/27] Add support for partial roles --- discord/invite.py | 176 +++++++++++++++++++++++++++++++++++----- discord/types/invite.py | 4 +- discord/types/role.py | 11 +++ docs/api.rst | 8 ++ 4 files changed, 178 insertions(+), 21 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index fb70c43b4..ad71aeeae 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -41,13 +41,15 @@ from .enums import ( from .appinfo import PartialAppInfo from .scheduled_event import ScheduledEvent from .flags import InviteFlags -from .role import Role +from .permissions import Permissions +from .colour import Colour __all__ = ( 'PartialInviteChannel', 'PartialInviteGuild', 'Invite', 'InviteUsersJob', + 'PartialInviteRole', ) if TYPE_CHECKING: @@ -58,6 +60,7 @@ if TYPE_CHECKING: InviteGuild as InviteGuildPayload, GatewayInvite as GatewayInvitePayload, InviteTargetUsersJobStatus as InviteTargetUsersJobStatusPayload, + InviteRole as InviteRolePayload, ) from .types.guild import GuildFeature from .types.channel import ( @@ -68,6 +71,7 @@ if TYPE_CHECKING: from .abc import GuildChannel from .user import User from .abc import Snowflake + from .role import Role InviteGuildType = Union[Guild, 'PartialInviteGuild', Object] InviteChannelType = Union[GuildChannel, 'PartialInviteChannel', Object] @@ -111,6 +115,116 @@ class InviteUsersJob: ) +class PartialInviteRole: + """Represents a "partial" invite role. + + This model will be given when the bot is not part of the + guild the :class:`Invite` resolves to, or when the role + is not in cache. + + .. versionadded:: 2.7 + """ + + __slots__ = ( + 'id', + 'name', + 'position', + 'unicode_emoji', + '_colour', + '_secondary_colour', + '_tertiary_colour', + '_icon', + '_permissions', + '_state', + ) + + def __init__(self, state: ConnectionState, data: InviteRolePayload) -> None: + self._state: ConnectionState = state + + self.id: int = int(data['id']) + self.name: str = data['name'] + self.position: int = data.get('position', 0) + + colors = data.get('colors', {}) + self._colour: int = colors.get('primary_color', 0) + self._secondary_colour = colors.get('secondary_color', None) + self._tertiary_colour = colors.get('tertiary_color', None) + + self.unicode_emoji: Optional[str] = data.get('unicode_emoji') + self._icon: Optional[str] = data.get('icon') + self._permissions: int = int(data.get('permissions', 0)) + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f'' + + @property + def secondary_colour(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: The role's secondary colour.""" + return Colour(self._secondary_colour) if self._secondary_colour is not None else None + + @property + def secondary_color(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: Alias for :attr:`secondary_colour`.""" + return self.secondary_colour + + @property + def tertiary_colour(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: The role's tertiary colour.""" + return Colour(self._tertiary_colour) if self._tertiary_colour is not None else None + + @property + def tertiary_color(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: Alias for :attr:`tertiary_colour`.""" + return self.tertiary_colour + + @property + def permissions(self) -> Permissions: + """:class:`Permissions`: Returns the role's permissions.""" + return Permissions(self._permissions) + + @property + def colour(self) -> Colour: + """:class:`Colour`: Returns the role's primary colour. An alias exists under ``color``.""" + return Colour(self._colour) + + @property + def color(self) -> Colour: + """:class:`Colour`: Returns the role's primary colour. An alias exists under ``colour``.""" + return self.colour + + @property + def icon(self) -> Optional[Asset]: + """Optional[:class:`.Asset`]: Returns the role's icon asset, if available. + + .. note:: + If this is ``None``, the role might instead have unicode emoji as its icon + if :attr:`unicode_emoji` is not ``None``. + + If you want the icon that a role has displayed, consider using :attr:`display_icon`. + """ + if self._icon is None: + return None + return Asset._from_icon(self._state, self.id, self._icon, path='role') + + @property + def display_icon(self) -> Optional[Union[Asset, str]]: + """Optional[Union[:class:`.Asset`, :class:`str`]]: Returns the role's display icon, if available.""" + return self.icon or self.unicode_emoji + + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the role's creation time in UTC.""" + return snowflake_time(self.id) + + @property + def mention(self) -> str: + """:class:`str`: Returns a string that allows you to mention a role.""" + return f'<@&{self.id}>' + + class PartialInviteChannel: """Represents a "partial" invite channel. @@ -405,11 +519,14 @@ class Invite(Hashable): The ID of the scheduled event associated with this invite, if any. .. versionadded:: 2.0 - roles: List[Union[:class:`Role`, :class:`Object`]] + roles: List[Union[:class:`PartialInviteRole`, :class:`Role`, :class:`Object`]] A list of roles that are granted to users joining via this invite. - This is only filled if the bot is in the guild where the invite belongs. - This may contain :class:`Object` instances if the role is not cached. + Objects in this list may be... + - :class:`Role` if the bot is part of the guild this invite resolves to and the role is in cache. + - :class:`PartialInviteRole` if the invite is fetched through :meth:`Client.fetch_invite` and + the bot is not in the guild the invite resolves to, or if the role is not in cache. + - :class:`Object` if the invite is received through a gateway event or the role is not in cache. .. versionadded:: 2.7 """ @@ -492,12 +609,9 @@ class Invite(Hashable): self.scheduled_event_id: Optional[int] = self.scheduled_event.id if self.scheduled_event else None self._flags: int = data.get('flags', 0) - roles = data.get('roles', []) - self.roles: List[Union[Role, Object]] - if roles and self.guild is not None and not isinstance(self.guild, (PartialInviteGuild, Object)): - self.roles = [Role(state=self._state, guild=self.guild, data=role_data) for role_data in roles] - else: - self.roles = [] + self.roles: List[Union[PartialInviteRole, Role, Object]] = self._resolve_roles( + data.get('roles', []) or data.get('role_ids', []) + ) @classmethod def from_incomplete(cls, *, state: ConnectionState, data: InvitePayload) -> Self: @@ -521,7 +635,12 @@ class Invite(Hashable): # Upgrade the partial data if applicable channel = guild.get_channel(channel.id) or channel - return cls(state=state, data=data, guild=guild, channel=channel) + return cls( + state=state, + data=data, + guild=guild, + channel=channel, + ) @classmethod def from_gateway(cls, *, state: ConnectionState, data: GatewayInvitePayload) -> Self: @@ -534,14 +653,7 @@ class Invite(Hashable): guild = state._get_or_create_unavailable_guild(guild_id) if guild_id is not None else None channel = Object(id=channel_id) - res = cls(state=state, data=data, guild=guild, channel=channel) # type: ignore - - # gateway events do not include role objects, only IDs - role_ids: list[Union[int, str]] = data.pop('role_ids', []) # type: ignore # .pop returns T | object - if role_ids and guild is not None and not isinstance(guild, (PartialInviteGuild, Object)): - res.roles = [guild.get_role(int(role_id)) or Object(role_id) for role_id in role_ids] - - return res + return cls(state=state, data=data, guild=guild, channel=channel) # type: ignore def _resolve_guild( self, @@ -570,6 +682,32 @@ class Invite(Hashable): return PartialInviteChannel(data) + def _resolve_roles( + self, + data: Optional[Sequence[Union[InviteRolePayload, int, str]]], + ) -> list[Union[PartialInviteRole, Role, Object]]: + if not data: + return [] + + guild = self.guild + res: List[Union[PartialInviteRole, Role, Object]] = [] + + for role in data: + if isinstance(role, (int, str)): + role_id = int(role) + if guild is not None and not isinstance(guild, (PartialInviteGuild, Object)): + res.append(guild.get_role(role_id) or Object(role_id)) + else: + res.append(Object(role_id)) + else: + role_id = int(role['id']) + if guild is not None and not isinstance(guild, (PartialInviteGuild, Object)): + res.append(guild.get_role(role_id) or PartialInviteRole(self._state, role)) + else: + res.append(PartialInviteRole(self._state, role)) + + return res + def __str__(self) -> str: return self.url diff --git a/discord/types/invite.py b/discord/types/invite.py index ef0f41be9..305d5b18f 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -33,7 +33,7 @@ from .guild import InviteGuild, _GuildPreviewUnique from .channel import PartialChannel from .user import PartialUser from .appinfo import PartialAppInfo -from .role import Role +from .role import InviteRole InviteTargetType = Literal[1, 2] InviteType = Literal[0, 1, 2] @@ -67,7 +67,7 @@ class Invite(IncompleteInvite, total=False): type: InviteType flags: NotRequired[int] expires_at: Optional[str] - roles: NotRequired[list[Role]] + roles: NotRequired[list[InviteRole]] class InviteWithCounts(Invite, _GuildPreviewUnique): ... diff --git a/discord/types/role.py b/discord/types/role.py index dabd1c1cf..2662b9ba3 100644 --- a/discord/types/role.py +++ b/discord/types/role.py @@ -59,3 +59,14 @@ class RoleTags(TypedDict, total=False): premium_subscriber: None available_for_purchase: None guild_connections: None + + +class InviteRole(TypedDict): + id: Snowflake + name: str + position: int + color: int + colors: RoleColours + icon: NotRequired[Optional[str]] + unicode_emoji: NotRequired[Optional[str]] + permissions: NotRequired[str] diff --git a/docs/api.rst b/docs/api.rst index e874e0f07..3a849076e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5518,6 +5518,14 @@ PartialInviteChannel .. autoclass:: PartialInviteChannel() :members: +PartialInviteRole +~~~~~~~~~~~~~~~~~~ + +.. attributetable:: PartialInviteRole + +.. autoclass:: PartialInviteRole() + :members: + Invite ~~~~~~~ From d25c8846833047b82c52670e2ea4e814afa58707 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:33:47 +0100 Subject: [PATCH 23/27] Add support for partial roles --- discord/invite.py | 19 +++++++++++-------- discord/types/role.py | 1 - 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index ad71aeeae..a72695466 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -41,7 +41,6 @@ from .enums import ( from .appinfo import PartialAppInfo from .scheduled_event import ScheduledEvent from .flags import InviteFlags -from .permissions import Permissions from .colour import Colour __all__ = ( @@ -123,6 +122,17 @@ class PartialInviteRole: is not in cache. .. versionadded:: 2.7 + + Attributes + ----------- + id: :class:`int` + The role's ID. + name: :class:`str` + The role's name. + position: :class:`int` + The role's position. + unicode_emoji: Optional[:class:`str`] + The role's unicode emoji, if it has one. """ __slots__ = ( @@ -134,7 +144,6 @@ class PartialInviteRole: '_secondary_colour', '_tertiary_colour', '_icon', - '_permissions', '_state', ) @@ -152,7 +161,6 @@ class PartialInviteRole: self.unicode_emoji: Optional[str] = data.get('unicode_emoji') self._icon: Optional[str] = data.get('icon') - self._permissions: int = int(data.get('permissions', 0)) def __str__(self) -> str: return self.name @@ -180,11 +188,6 @@ class PartialInviteRole: """Optional[:class:`Colour`]: Alias for :attr:`tertiary_colour`.""" return self.tertiary_colour - @property - def permissions(self) -> Permissions: - """:class:`Permissions`: Returns the role's permissions.""" - return Permissions(self._permissions) - @property def colour(self) -> Colour: """:class:`Colour`: Returns the role's primary colour. An alias exists under ``color``.""" diff --git a/discord/types/role.py b/discord/types/role.py index 2662b9ba3..2c8ea346e 100644 --- a/discord/types/role.py +++ b/discord/types/role.py @@ -69,4 +69,3 @@ class InviteRole(TypedDict): colors: RoleColours icon: NotRequired[Optional[str]] unicode_emoji: NotRequired[Optional[str]] - permissions: NotRequired[str] From 0e5c6e7eb044959ed3628b3615b03fa154d2d16c Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:36:37 +0100 Subject: [PATCH 24/27] Do requested changes --- discord/invite.py | 98 ++++++++++++++++++++++++++++++++++++----------- discord/role.py | 16 ++++++++ 2 files changed, 91 insertions(+), 23 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index a72695466..dcc94c3ff 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -42,6 +42,9 @@ from .appinfo import PartialAppInfo from .scheduled_event import ScheduledEvent from .flags import InviteFlags from .colour import Colour +from .errors import ClientException +from .role import Role + __all__ = ( 'PartialInviteChannel', @@ -70,7 +73,6 @@ if TYPE_CHECKING: from .abc import GuildChannel from .user import User from .abc import Snowflake - from .role import Role InviteGuildType = Union[Guild, 'PartialInviteGuild', Object] InviteChannelType = Union[GuildChannel, 'PartialInviteChannel', Object] @@ -140,6 +142,7 @@ class PartialInviteRole: 'name', 'position', 'unicode_emoji', + '_guild_id', '_colour', '_secondary_colour', '_tertiary_colour', @@ -147,9 +150,11 @@ class PartialInviteRole: '_state', ) - def __init__(self, state: ConnectionState, data: InviteRolePayload) -> None: + def __init__(self, *, state: ConnectionState, data: InviteRolePayload, guild_id: int) -> None: self._state: ConnectionState = state + self._guild_id: int = guild_id + self.id: int = int(data['id']) self.name: str = data['name'] self.position: int = data.get('position', 0) @@ -166,7 +171,7 @@ class PartialInviteRole: return self.name def __repr__(self) -> str: - return f'' + return f'<{self.__class__.__name__} id={self.id} name={self.name!r}>' @property def secondary_colour(self) -> Optional[Colour]: @@ -227,6 +232,44 @@ class PartialInviteRole: """:class:`str`: Returns a string that allows you to mention a role.""" return f'<@&{self.id}>' + def resolve(self) -> Optional[Role]: + """Resolves this partial role to a full :class:`~discord.Role` object, + if the guild and role are found in cache. + + Returns + -------- + Optional[:class:`.Role`] + The resolved role, or ``None`` if the guild or role is not found in cache. + """ + guild = self._state._get_guild(self._guild_id) + if guild is None: + return None + + return guild.get_role(self.id) + + async def fetch(self) -> Role: + """|coro| + + Fetches the partial role to a full :class:`~discord.Role`. + + Raises + -------- + NotFound + The role was not found. + HTTPException + Retrieving the role failed. + + Returns + -------- + :class:`~discord.Role` + The full role. + """ + guild = self._state._get_guild(self._guild_id) + if guild is None: + raise ClientException(f'Guild with ID {self._guild_id} not found') + + return await guild.fetch_role(self.id) + class PartialInviteChannel: """Represents a "partial" invite channel. @@ -522,13 +565,11 @@ class Invite(Hashable): The ID of the scheduled event associated with this invite, if any. .. versionadded:: 2.0 - roles: List[Union[:class:`PartialInviteRole`, :class:`Role`, :class:`Object`]] + roles: List[Union[:class:`PartialInviteRole`, :class:`Object`]] A list of roles that are granted to users joining via this invite. Objects in this list may be... - - :class:`Role` if the bot is part of the guild this invite resolves to and the role is in cache. - - :class:`PartialInviteRole` if the invite is fetched through :meth:`Client.fetch_invite` and - the bot is not in the guild the invite resolves to, or if the role is not in cache. + - :class:`PartialInviteRole` if the invite is fetched through :meth:`Client.fetch_invite`. - :class:`Object` if the invite is received through a gateway event or the role is not in cache. .. versionadded:: 2.7 @@ -612,7 +653,7 @@ class Invite(Hashable): self.scheduled_event_id: Optional[int] = self.scheduled_event.id if self.scheduled_event else None self._flags: int = data.get('flags', 0) - self.roles: List[Union[PartialInviteRole, Role, Object]] = self._resolve_roles( + self.roles: List[Union[PartialInviteRole, Object]] = self._resolve_roles( data.get('roles', []) or data.get('role_ids', []) ) @@ -688,26 +729,33 @@ class Invite(Hashable): def _resolve_roles( self, data: Optional[Sequence[Union[InviteRolePayload, int, str]]], - ) -> list[Union[PartialInviteRole, Role, Object]]: + ) -> list[Union[PartialInviteRole, Object]]: if not data: return [] guild = self.guild - res: List[Union[PartialInviteRole, Role, Object]] = [] + state = self._state + res: List[Union[PartialInviteRole, Object]] = [] for role in data: + role_id: int + role_data: Optional[InviteRolePayload] = None if isinstance(role, (int, str)): role_id = int(role) - if guild is not None and not isinstance(guild, (PartialInviteGuild, Object)): - res.append(guild.get_role(role_id) or Object(role_id)) - else: - res.append(Object(role_id)) else: role_id = int(role['id']) - if guild is not None and not isinstance(guild, (PartialInviteGuild, Object)): - res.append(guild.get_role(role_id) or PartialInviteRole(self._state, role)) - else: - res.append(PartialInviteRole(self._state, role)) + role_data = role + + if guild: + if not isinstance(guild, (PartialInviteGuild, Object)): + cached_role = guild.get_role(role_id) + if cached_role: + role_data = cached_role._to_partial_dict() + + if role_data: + res.append(PartialInviteRole(state=state, data=role_data, guild_id=guild.id)) + else: + res.append(Object(id=role_id, type=Role)) return res @@ -793,7 +841,7 @@ class Invite(Hashable): data = await self._state.http.delete_invite(self.code, reason=reason) return self.from_incomplete(state=self._state, data=data) - async def target_users(self) -> list[int]: + async def target_users(self) -> list[Object]: """|coro| Fetches the users that are allowed to join via this invite. @@ -804,8 +852,8 @@ class Invite(Hashable): Returns -------- - List[:class:`int`] - A list of user IDs. + List[:class:`discord.Object`] + A list of user objects. Raises ------- @@ -816,9 +864,14 @@ class Invite(Hashable): HTTPException Fetching the target users failed. """ + # circular import + from .user import User res = await self._state.http.get_invite_target_users(self.code) - return list(map(int, res.lstrip('user_id\n').strip().split('\n'))) if res else [] + if not res: + return [] + + return list(map(lambda x: Object(id=int(x), type=User), res.lstrip('user_id\n').strip().split('\n'))) async def target_users_job_status(self) -> InviteUsersJob: """|coro| @@ -844,7 +897,6 @@ class Invite(Hashable): HTTPException Fetching the target users job status failed. """ - data = await self._state.http.get_invite_target_users_job_status(self.code) return InviteUsersJob(data) diff --git a/discord/role.py b/discord/role.py index 55996c3ae..1cd139b80 100644 --- a/discord/role.py +++ b/discord/role.py @@ -42,6 +42,7 @@ if TYPE_CHECKING: from .types.role import ( Role as RolePayload, RoleTags as RoleTagPayload, + InviteRole as InviteRolePayload, ) from .types.guild import RolePositionUpdate from .guild import Guild @@ -295,6 +296,21 @@ class Role(Hashable): except KeyError: self.tags = None + def _to_partial_dict(self) -> InviteRolePayload: + return { + 'id': str(self.id), + 'name': self.name, + 'position': self.position, + 'color': self._colour, + 'colors': { + 'primary_color': self._colour, + 'secondary_color': self._secondary_colour, + 'tertiary_color': self._tertiary_colour, + }, + 'icon': self._icon, + 'unicode_emoji': self.unicode_emoji, + } + def is_default(self) -> bool: """:class:`bool`: Checks if the role is the default role.""" return self.guild.id == self.id From 368e415842d66a84c4e405acf9e85556c8e851ec Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:45:10 +0100 Subject: [PATCH 25/27] Update docs --- discord/invite.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index dcc94c3ff..49694b87e 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -119,10 +119,6 @@ class InviteUsersJob: class PartialInviteRole: """Represents a "partial" invite role. - This model will be given when the bot is not part of the - guild the :class:`Invite` resolves to, or when the role - is not in cache. - .. versionadded:: 2.7 Attributes @@ -256,6 +252,8 @@ class PartialInviteRole: -------- NotFound The role was not found. + ClientException + The guild was not found in cache. HTTPException Retrieving the role failed. From 87889ac2d6ff1be823748ceff4cd8295cbc256c4 Mon Sep 17 00:00:00 2001 From: Soheab_ <33902984+Soheab@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:30:43 +0200 Subject: [PATCH 26/27] Revert to returning Role instead --- discord/invite.py | 30 ++++++++++++++++++++---------- discord/role.py | 14 -------------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index 49694b87e..d7d158be1 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -563,12 +563,13 @@ class Invite(Hashable): The ID of the scheduled event associated with this invite, if any. .. versionadded:: 2.0 - roles: List[Union[:class:`PartialInviteRole`, :class:`Object`]] + roles: List[Union[:class:`PartialInviteRole`, :class:`Object`, :class:`Role`]] A list of roles that are granted to users joining via this invite. Objects in this list may be... - :class:`PartialInviteRole` if the invite is fetched through :meth:`Client.fetch_invite`. - :class:`Object` if the invite is received through a gateway event or the role is not in cache. + - :class:`Role` if the role is in cache. .. versionadded:: 2.7 """ @@ -651,7 +652,7 @@ class Invite(Hashable): self.scheduled_event_id: Optional[int] = self.scheduled_event.id if self.scheduled_event else None self._flags: int = data.get('flags', 0) - self.roles: List[Union[PartialInviteRole, Object]] = self._resolve_roles( + self.roles: List[Union[PartialInviteRole, Object, Role]] = self._resolve_roles( data.get('roles', []) or data.get('role_ids', []) ) @@ -727,33 +728,42 @@ class Invite(Hashable): def _resolve_roles( self, data: Optional[Sequence[Union[InviteRolePayload, int, str]]], - ) -> list[Union[PartialInviteRole, Object]]: + ) -> List[Union[PartialInviteRole, Object, Role]]: if not data: return [] guild = self.guild state = self._state - res: List[Union[PartialInviteRole, Object]] = [] + res: List[Union[PartialInviteRole, Object, Role]] = [] for role in data: role_id: int role_data: Optional[InviteRolePayload] = None + if isinstance(role, (int, str)): role_id = int(role) else: role_id = int(role['id']) role_data = role - if guild: - if not isinstance(guild, (PartialInviteGuild, Object)): - cached_role = guild.get_role(role_id) - if cached_role: - role_data = cached_role._to_partial_dict() + if not guild: + res.append(Object(id=role_id, type=Role)) + continue + if isinstance(guild, (PartialInviteGuild, Object)): if role_data: res.append(PartialInviteRole(state=state, data=role_data, guild_id=guild.id)) + else: + res.append(Object(id=role_id, type=Role)) + continue + + resolved = guild.get_role(role_id) + if not resolved and role_data: + resolved = PartialInviteRole(state=state, data=role_data, guild_id=guild.id) else: - res.append(Object(id=role_id, type=Role)) + resolved = resolved or Object(id=role_id, type=Role) + + res.append(resolved) return res diff --git a/discord/role.py b/discord/role.py index 1cd139b80..0aea5f0d2 100644 --- a/discord/role.py +++ b/discord/role.py @@ -296,20 +296,6 @@ class Role(Hashable): except KeyError: self.tags = None - def _to_partial_dict(self) -> InviteRolePayload: - return { - 'id': str(self.id), - 'name': self.name, - 'position': self.position, - 'color': self._colour, - 'colors': { - 'primary_color': self._colour, - 'secondary_color': self._secondary_colour, - 'tertiary_color': self._tertiary_colour, - }, - 'icon': self._icon, - 'unicode_emoji': self.unicode_emoji, - } def is_default(self) -> bool: """:class:`bool`: Checks if the role is the default role.""" From efb4c8cf18af742245c74f04aa35d9d7306cd335 Mon Sep 17 00:00:00 2001 From: Soheab_ <33902984+Soheab@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:32:10 +0200 Subject: [PATCH 27/27] ruff format --- discord/role.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/role.py b/discord/role.py index 0aea5f0d2..8dfd1eeec 100644 --- a/discord/role.py +++ b/discord/role.py @@ -296,7 +296,6 @@ class Role(Hashable): except KeyError: self.tags = None - def is_default(self) -> bool: """:class:`bool`: Checks if the role is the default role.""" return self.guild.id == self.id