From f7e4821418611199c28ba1c63ccfbe4630d8aead Mon Sep 17 00:00:00 2001 From: Ice Wolfy Date: Tue, 4 Nov 2025 22:37:48 -0500 Subject: [PATCH 01/61] fix: Initial Commit To Fix Command Auto Syncing --- discord/bot.py | 223 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 157 insertions(+), 66 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index 7dd246afe3..cc7216cd45 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -270,81 +270,172 @@ async def get_desynced_commands( """ # We can suggest the user to upsert, edit, delete, or bulk upsert the commands + class DefaultComparison: + """ + Comparison rule for when there are multiple default values that should be considered equivalent when comparing 2 objects. + Allows for a custom check to be passed for further control over equality. + + Attributes + ---------- + defaults: :class:`tuple` + The values that should be considered equivalent to each other + callback: Callable[[Any, Any], bool] + A callable that will do additional comparison on the objects if neither are a default value. + Defaults to a `!=` comparison. + It should accept the 2 objects as arguments and return True if they should be considered equivalent + and False otherwise. + """ + + def __init__( + self, + defaults: tuple[Any, ...], + callback: Callable[[Any, Any], bool] = lambda x, y: x != y, + ): + self.defaults = defaults + self.callback = callback + + def _check_defaults(self, local, remote) -> bool | None: + defaults = (local in self.defaults) + (remote in self.defaults) + if defaults == 2: + # Both are defaults, so they can be counted as the same + return False + elif defaults == 0: + # Neither are defaults so the callback has to be used + return None + else: + # Only one is a default, so the command must be out of sync + return True + + def check(self, local, remote) -> bool: + if (rtn := self._check_defaults(local, remote)) is not None: + return rtn + else: + return self.callback(local, remote) + + class DefaultSetComparison(DefaultComparison): + def check(self, local, remote) -> bool: + try: + local = set(local) + except TypeError: + pass + try: + remote = set(remote) + except TypeError: + pass + return super().check(local, remote) + + type NestedComparison = dict[str, NestedComparison | DefaultComparison] + + def _compare_defaults( + obj: Mapping[str, Any] | Any, + match: Mapping[str, Any] | Any, + schema: NestedComparison, + ) -> bool: + if not isinstance(match, Mapping) or not isinstance(obj, Mapping): + return obj != match + for field, comparison in schema.items(): + remote = match.get(field, MISSING) + local = obj.get(field, MISSING) + if isinstance(comparison, dict): + _compare_defaults(local, remote, comparison) + elif isinstance(comparison, DefaultComparison): + if comparison.check(local, remote): + return True + return False def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: + cmd = cmd.to_dict() + + option_default_values = ([], MISSING) + + def _option_comparison_check(local, remote) -> bool: + matching = (local in option_default_values) + ( + remote in option_default_values + ) + if matching == 2: + return False + elif matching == 1: + return True + else: + return len(local) != len(remote) or any( + [ + _compare_defaults(local[x], remote[x], option_defaults) + for x in range(len(local)) + ] + ) + + choices_default_values = ([], MISSING) + + def _choices_comparison_check(local, remote) -> bool: + matching = (local in choices_default_values) + ( + remote in choices_default_values + ) + if matching == 2: + return False + elif matching == 1: + return True + else: + return len(local) != len(remote) or any( + [ + _compare_defaults(local[x], remote[x], choices_defaults) + for x in range(len(local)) + ] + ) + + defaults: NestedComparison = { + "type": DefaultComparison((1, MISSING)), + "name": DefaultComparison(()), + "description": DefaultComparison((MISSING,)), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "description_localizations": DefaultComparison((None, {}, MISSING)), + "options": DefaultComparison( + option_default_values, _option_comparison_check + ), + "default_member_permissions": DefaultComparison((None, MISSING)), + "nsfw": DefaultComparison((False, MISSING)), + # TODO: Change the below default if needed to use the correct default integration types and contexts + "integration_types": DefaultSetComparison( + (MISSING, {0, 1}), lambda x, y: set(x) != set(y) + ), + # Discord States That This Defaults To "your app's configured contexts" + "contexts": DefaultSetComparison( + (None, {0, 1, 2}, MISSING), lambda x, y: set(x) != set(y) + ), + } + option_defaults: NestedComparison = { + "type": DefaultComparison(()), + "name": DefaultComparison(()), + "description": DefaultComparison(()), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "description_localizations": DefaultComparison((None, {}, MISSING)), + "required": DefaultComparison((False, MISSING)), + "choices": DefaultComparison( + choices_default_values, _choices_comparison_check + ), + "channel_types": DefaultComparison(([], MISSING)), + "min_value": DefaultComparison((MISSING,)), + "max_value": DefaultComparison((MISSING,)), + "min_length": DefaultComparison((MISSING,)), + "max_length": DefaultComparison((MISSING,)), + "autocomplete": DefaultComparison((MISSING, False)), + } + choices_defaults: NestedComparison = { + "name": DefaultComparison(()), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "value": DefaultComparison(()), + } + if isinstance(cmd, SlashCommandGroup): if len(cmd.subcommands) != len(match.get("options", [])): return True for i, subcommand in enumerate(cmd.subcommands): - match_ = next( - ( - data - for data in match["options"] - if data["name"] == subcommand.name - ), - MISSING, + match_ = find( + lambda x: x["name"] == subcommand.name, match["options"] ) - if match_ is not MISSING and _check_command(subcommand, match_): + if match_ is not None and _check_command(subcommand, match_): return True else: - as_dict = cmd.to_dict() - to_check = { - "nsfw": None, - "default_member_permissions": None, - "name": None, - "description": None, - "name_localizations": None, - "description_localizations": None, - "options": [ - "type", - "name", - "description", - "autocomplete", - "choices", - "name_localizations", - "description_localizations", - ], - "contexts": None, - "integration_types": None, - } - for check, value in to_check.items(): - if type(to_check[check]) == list: - # We need to do some falsy conversion here - # The API considers False (autocomplete) and [] (choices) to be falsy values - falsy_vals = (False, []) - for opt in value: - cmd_vals = ( - [val.get(opt, MISSING) for val in as_dict[check]] - if check in as_dict - else [] - ) - for i, val in enumerate(cmd_vals): - if val in falsy_vals: - cmd_vals[i] = MISSING - if match.get( - check, MISSING - ) is not MISSING and cmd_vals != [ - val.get(opt, MISSING) for val in match[check] - ]: - # We have a difference - return True - elif (attr := getattr(cmd, check, None)) != ( - found := match.get(check) - ): - # We might have a difference - if "localizations" in check and bool(attr) == bool(found): - # unlike other attrs, localizations are MISSING by default - continue - elif ( - check == "default_permission" - and attr is True - and found is None - ): - # This is a special case - # TODO: Remove for perms v2 - continue - return True - return False + return _compare_defaults(cmd, match, defaults) return_value = [] cmds = self.pending_application_commands.copy() From 6cabb6fb32e5201279c333ee034c8abe89c9489f Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 7 Nov 2025 17:15:57 +0200 Subject: [PATCH 02/61] Add support for editing application info and new fields Introduces the AppInfo.edit coroutine to allow editing application settings. Updates AppInfo and related types to support new fields such as bot, flags, event webhooks, integration_types_config, and approximate_user_authorization_count. Also refactors type hints and improves handling of optional fields for better API compatibility. --- discord/appinfo.py | 85 +++++++++++++++++++++++++++++++++++++++- discord/client.py | 19 +++------ discord/http.py | 5 +++ discord/types/appinfo.py | 78 ++++++++++++++++++++++++------------ 4 files changed, 149 insertions(+), 38 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 034b1bb158..441e72e3aa 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -161,6 +161,7 @@ class AppInfo: "bot_public", "bot_require_code_grant", "owner", + "bot", "_icon", "_summary", "verify_key", @@ -173,9 +174,15 @@ class AppInfo: "privacy_policy_url", "approximate_guild_count", "approximate_user_install_count", + "approximate_user_authorization_count", + "flags", "redirect_uris", "interactions_endpoint_url", "role_connections_verification_url", + "event_webhooks_url", + "event_webhooks_status", + "event_webhooks_types", + "integration_types_config", "install_params", "tags", "custom_install_url", @@ -189,7 +196,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.name: str = data["name"] self.description: str = data["description"] self._icon: str | None = data["icon"] - self.rpc_origins: list[str] = data["rpc_origins"] + self.rpc_origins: list[str] | None = data.get("rpc_origins") self.bot_public: bool = data["bot_public"] self.bot_require_code_grant: bool = data["bot_require_code_grant"] self.owner: User = state.create_user(data["owner"]) @@ -199,6 +206,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self._summary: str = data["summary"] self.verify_key: str = data["verify_key"] + self.bot: User | None = data.get("bot") and state.create_user(data["bot"]) self.guild_id: int | None = utils._get_as_snowflake(data, "guild_id") @@ -213,6 +221,10 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.approximate_user_install_count: int | None = data.get( "approximate_user_install_count" ) + self.approximate_user_authorization_count: int | None = data.get( + "approximate_user_authorization_count" + ) + self.flags: int | None = data.get("flags") self.redirect_uris: list[str] | None = data.get("redirect_uris", []) self.interactions_endpoint_url: str | None = data.get( "interactions_endpoint_url" @@ -220,6 +232,9 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.role_connections_verification_url: str | None = data.get( "role_connections_verification_url" ) + self.event_webhooks_url: str | None = data.get("event_webhooks_url") + self.event_webhooks_status: int | None = data.get("event_webhooks_status") + self.event_webhooks_types: list[str] | None = data.get("event_webhooks_types") install_params = data.get("install_params") self.install_params: AppInstallParams | None = ( @@ -227,6 +242,9 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): ) self.tags: list[str] | None = data.get("tags", []) self.custom_install_url: str | None = data.get("custom_install_url") + self.integration_types_config: dict[int, dict[str, object] | None] | None = ( + data.get("integration_types_config") + ) def __repr__(self) -> str: return ( @@ -235,6 +253,71 @@ def __repr__(self) -> str: f"owner={self.owner!r}>" ) + async def edit( + self, + *, + description: str | None = utils.MISSING, + terms_of_service_url: str | None = utils.MISSING, + privacy_policy_url: str | None = utils.MISSING, + role_connections_verification_url: str | None = utils.MISSING, + interactions_endpoint_url: str | None = utils.MISSING, + tags: list[str] | None = utils.MISSING, + install_params: dict | None = utils.MISSING, + custom_install_url: str | None = utils.MISSING, + ) -> "AppInfo": + """|coro| + + Edit the current application's settings. + + This method wraps the Edit Current Application endpoint and returns the updated application info. + + Parameters + ---------- + description: Optional[:class:`str`] + The new application description. Pass ``None`` to clear. + terms_of_service_url: Optional[:class:`str`] + The application's Terms of Service URL. + privacy_policy_url: Optional[:class:`str`] + The application's Privacy Policy URL. + role_connections_verification_url: Optional[:class:`str`] + The role connection verification URL for the application. + interactions_endpoint_url: Optional[:class:`str`] + The interactions endpoint callback URL. + tags: Optional[List[:class:`str`]] + List of tags for the application (max 5). + install_params: Optional[:class:`dict`] + Dict with keys ``scopes`` (list[str]) and ``permissions`` (str) used for default install link. + custom_install_url: Optional[:class:`str`] + The default custom authorization URL for the application. + + Returns + ------- + :class:`.AppInfo` + The updated application information. + """ + payload: dict[str, object] = {} + if description is not utils.MISSING: + payload["description"] = description + if terms_of_service_url is not utils.MISSING: + payload["terms_of_service_url"] = terms_of_service_url + if privacy_policy_url is not utils.MISSING: + payload["privacy_policy_url"] = privacy_policy_url + if role_connections_verification_url is not utils.MISSING: + payload["role_connections_verification_url"] = ( + role_connections_verification_url + ) + if interactions_endpoint_url is not utils.MISSING: + payload["interactions_endpoint_url"] = interactions_endpoint_url + if tags is not utils.MISSING: + payload["tags"] = tags + if install_params is not utils.MISSING: + payload["install_params"] = install_params + if custom_install_url is not utils.MISSING: + payload["custom_install_url"] = custom_install_url + + data = await self._state.http.edit_current_application(payload) + return AppInfo(self._state, data) + @property def icon(self) -> Asset | None: """Retrieves the application's icon asset, if any.""" diff --git a/discord/client.py b/discord/client.py index f339cbfe92..d1b693045f 100644 --- a/discord/client.py +++ b/discord/client.py @@ -59,7 +59,7 @@ from .invite import Invite from .iterators import EntitlementIterator, GuildIterator from .mentions import AllowedMentions -from .monetization import SKU, Entitlement +from .monetization import SKU from .object import Object from .soundboard import SoundboardSound from .stage_instance import StageInstance @@ -77,20 +77,15 @@ if TYPE_CHECKING: from .abc import GuildChannel, PrivateChannel, Snowflake, SnowflakeTime from .channel import ( - CategoryChannel, DMChannel, - ForumChannel, - StageChannel, - TextChannel, - VoiceChannel, ) from .interactions import Interaction from .member import Member from .message import Message from .poll import Poll from .soundboard import SoundboardSound - from .threads import Thread, ThreadMember - from .ui.item import Item, ViewItem + from .threads import Thread + from .ui.item import ViewItem from .voice_client import VoiceProtocol __all__ = ("Client",) @@ -249,9 +244,9 @@ def __init__( self.loop: asyncio.AbstractEventLoop = ( asyncio.get_event_loop() if loop is None else loop ) - self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( - {} - ) + self._listeners: dict[ + str, list[tuple[asyncio.Future, Callable[..., bool]]] + ] = {} self.shard_id: int | None = options.get("shard_id") self.shard_count: int | None = options.get("shard_count") @@ -1922,8 +1917,6 @@ async def application_info(self) -> AppInfo: Retrieving the information failed somehow. """ data = await self.http.application_info() - if "rpc_origins" not in data: - data["rpc_origins"] = None return AppInfo(self._connection, data) async def fetch_user(self, user_id: int, /) -> User: diff --git a/discord/http.py b/discord/http.py index ae64703ba6..74cf19c541 100644 --- a/discord/http.py +++ b/discord/http.py @@ -3213,6 +3213,11 @@ def get_answer_voters( def application_info(self) -> Response[appinfo.AppInfo]: return self.request(Route("GET", "/oauth2/applications/@me")) + def edit_current_application( + self, payload: dict[str, Any] + ) -> Response[appinfo.AppInfo]: + return self.request(Route("PATCH", "/applications/@me"), json=payload) + def get_application( self, application_id: Snowflake, / ) -> Response[appinfo.PartialAppInfo]: diff --git a/discord/types/appinfo.py b/discord/types/appinfo.py index c22f665745..a5b3c6311e 100644 --- a/discord/types/appinfo.py +++ b/discord/types/appinfo.py @@ -25,43 +25,73 @@ from __future__ import annotations +from typing import Literal from typing_extensions import NotRequired, TypedDict from .snowflake import Snowflake from .team import Team -from .user import User +from .guild import Guild +from .user import PartialUser + + +ApplicationIntegrationType = Literal[0, 1] +ApplicationEventWebhookStatus = Literal[1, 2, 3] + + +class AppInstallParams(TypedDict): + scopes: list[str] + permissions: str + + +class ApplicationIntegrationTypeConfiguration(TypedDict, total=False): + oauth2_install_params: AppInstallParams class BaseAppInfo(TypedDict): id: Snowflake name: str - verify_key: str - icon: str | None - summary: str description: str - terms_of_service_url: NotRequired[str] - privacy_policy_url: NotRequired[str] - hook: NotRequired[bool] - max_participants: NotRequired[int] - + verify_key: str + # Deprecated by Discord but still present in some payloads; prefer 'description'. + summary: NotRequired[str] -class AppInfo(BaseAppInfo): - team: NotRequired[Team] + icon: NotRequired[str | None] + cover_image: NotRequired[str] guild_id: NotRequired[Snowflake] - primary_sku_id: NotRequired[Snowflake] - slug: NotRequired[str] - rpc_origins: list[str] - owner: User - bot_public: bool - bot_require_code_grant: bool + guild: NotRequired[Guild] + bot: NotRequired[PartialUser] + owner: NotRequired[PartialUser] + team: NotRequired[Team | None] + rpc_origins: NotRequired[list[str]] + bot_public: NotRequired[bool] + bot_require_code_grant: NotRequired[bool] + terms_of_service_url: NotRequired[str | None] + privacy_policy_url: NotRequired[str | None] + tags: NotRequired[list[str]] + install_params: NotRequired[AppInstallParams] + custom_install_url: NotRequired[str] + integration_types_config: NotRequired[ + dict[ + ApplicationIntegrationType, + ApplicationIntegrationTypeConfiguration | None, + ] + ] -class PartialAppInfo(BaseAppInfo): - rpc_origins: NotRequired[list[str]] - cover_image: NotRequired[str] - flags: NotRequired[int] +class AppInfo(BaseAppInfo, total=False): + primary_sku_id: Snowflake + slug: str + flags: int + approximate_guild_count: int + approximate_user_install_count: int + approximate_user_authorization_count: int + redirect_uris: list[str] + interactions_endpoint_url: str | None + role_connections_verification_url: str | None + event_webhooks_url: str | None + event_webhooks_status: ApplicationEventWebhookStatus + event_webhooks_types: list[str] -class AppInstallParams(TypedDict): - scopes: list[str] - permissions: str +class PartialAppInfo(BaseAppInfo, total=False): + pass From 2f54fe792708859bb8d62ee0ec615b6edcaa6d6a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:17:42 +0000 Subject: [PATCH 03/61] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/appinfo.py | 2 +- discord/client.py | 6 +++--- discord/types/appinfo.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 441e72e3aa..f61f7a3c5b 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -264,7 +264,7 @@ async def edit( tags: list[str] | None = utils.MISSING, install_params: dict | None = utils.MISSING, custom_install_url: str | None = utils.MISSING, - ) -> "AppInfo": + ) -> AppInfo: """|coro| Edit the current application's settings. diff --git a/discord/client.py b/discord/client.py index d1b693045f..9b949a2781 100644 --- a/discord/client.py +++ b/discord/client.py @@ -244,9 +244,9 @@ def __init__( self.loop: asyncio.AbstractEventLoop = ( asyncio.get_event_loop() if loop is None else loop ) - self._listeners: dict[ - str, list[tuple[asyncio.Future, Callable[..., bool]]] - ] = {} + self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( + {} + ) self.shard_id: int | None = options.get("shard_id") self.shard_count: int | None = options.get("shard_count") diff --git a/discord/types/appinfo.py b/discord/types/appinfo.py index a5b3c6311e..afb95dcc0b 100644 --- a/discord/types/appinfo.py +++ b/discord/types/appinfo.py @@ -26,14 +26,14 @@ from __future__ import annotations from typing import Literal + from typing_extensions import NotRequired, TypedDict +from .guild import Guild from .snowflake import Snowflake from .team import Team -from .guild import Guild from .user import PartialUser - ApplicationIntegrationType = Literal[0, 1] ApplicationEventWebhookStatus = Literal[1, 2, 3] From fab14785db0053ef72b166c5f478fa54af0b2974 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:00:21 +0200 Subject: [PATCH 04/61] Add AppInfo.edit() and missing fields support Introduces the AppInfo.edit() method to allow editing application settings, including new and previously missing fields such as icon, cover_image, tags, install_params, integration_types_config, flags, event_webhooks_url, event_webhooks_status, and event_webhooks_types. Also adds related helper classes and properties for handling these fields and updates the CHANGELOG accordingly. --- CHANGELOG.md | 2 + discord/appinfo.py | 208 ++++++++++++++++++++++++++++++++++----- discord/types/appinfo.py | 2 - 3 files changed, 188 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35321c2858..ae6887be2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ These changes are available on the `master` branch, but have not yet been releas - Added `Attachment.read_chunked` and added optional `chunksize` argument to `Attachment.save` for retrieving attachments in chunks. ([#2956](https://github.com/Pycord-Development/pycord/pull/2956)) +- Added `AppInfo.edit()` method and missing fields. + ([#2994](https://github.com/Pycord-Development/pycord/pull/2994)) ### Changed diff --git a/discord/appinfo.py b/discord/appinfo.py index f61f7a3c5b..9f3272ebb3 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -30,6 +30,7 @@ from . import utils from .asset import Asset from .permissions import Permissions +from .flags import ApplicationFlags if TYPE_CHECKING: from .guild import Guild @@ -44,6 +45,7 @@ "AppInfo", "PartialAppInfo", "AppInstallParams", + "IntegrationTypesConfig", ) @@ -134,11 +136,33 @@ class AppInfo: .. versionadded:: 2.7 - install_params: Optional[List[:class:`AppInstallParams`]] + install_params: Optional[:class:`AppInstallParams`] The settings for the application's default in-app authorization link, if set. .. versionadded:: 2.7 + integration_types_config: Optional[Dict[:class:`int`, Optional[Dict[:class:`str`, Any]]]] + Per-installation context configuration. Keys are ``0`` (guild) and ``1`` (user) mapping to an object containing + ``oauth2_install_params`` or ``None`` if cleared. + + .. versionadded:: 2.7 + + event_webhooks_url: Optional[:class:`str`] + The URL used to receive application event webhooks, if set. + + .. versionadded:: 2.7 + + event_webhooks_status: Optional[:class:`int`] + The raw event webhooks status integer from the API (``2`` enabled, ``1`` disabled) if present. + Prefer :attr:`event_webhooks_enabled` for a boolean form. + + .. versionadded:: 2.7 + + event_webhooks_types: Optional[List[:class:`str`]] + List of event webhook types subscribed to, if set. + + .. versionadded:: 2.7 + tags: Optional[List[:class:`str`]] The list of tags describing the content and functionality of the app, if set. @@ -149,6 +173,16 @@ class AppInfo: custom_install_url: Optional[:class:`str`] The default custom authorization URL for the application, if set. + .. versionadded:: 2.7 + + approximate_user_authorization_count: Optional[:class:`int`] + The approximate count of users who have authorized the application, if any. + + .. versionadded:: 2.7 + + flags: Optional[:class:`ApplicationFlags`] + The public application flags, if set. + .. versionadded:: 2.7 """ @@ -175,7 +209,7 @@ class AppInfo: "approximate_guild_count", "approximate_user_install_count", "approximate_user_authorization_count", - "flags", + "_flags", "redirect_uris", "interactions_endpoint_url", "role_connections_verification_url", @@ -224,7 +258,8 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.approximate_user_authorization_count: int | None = data.get( "approximate_user_authorization_count" ) - self.flags: int | None = data.get("flags") + raw_flags = data.get("flags") + self._flags: int | None = raw_flags if isinstance(raw_flags, int) else None self.redirect_uris: list[str] | None = data.get("redirect_uris", []) self.interactions_endpoint_url: str | None = data.get( "interactions_endpoint_url" @@ -253,18 +288,35 @@ def __repr__(self) -> str: f"owner={self.owner!r}>" ) + @property + def flags(self) -> ApplicationFlags | None: + """The public application flags, if set. + + Returns an :class:`ApplicationFlags` instance or ``None`` when not present. + """ + if self._flags is None: + return None + return ApplicationFlags._from_value(self._flags) + async def edit( self, *, description: str | None = utils.MISSING, + icon: bytes | str | None = utils.MISSING, + cover_image: bytes | str | None = utils.MISSING, + tags: list[str] | None = utils.MISSING, terms_of_service_url: str | None = utils.MISSING, privacy_policy_url: str | None = utils.MISSING, - role_connections_verification_url: str | None = utils.MISSING, interactions_endpoint_url: str | None = utils.MISSING, - tags: list[str] | None = utils.MISSING, - install_params: dict | None = utils.MISSING, + role_connections_verification_url: str | None = utils.MISSING, + install_params: "AppInstallParams | None" = utils.MISSING, custom_install_url: str | None = utils.MISSING, - ) -> AppInfo: + integration_types_config: "IntegrationTypesConfig | None" = utils.MISSING, + flags: ApplicationFlags | None = utils.MISSING, + event_webhooks_url: str | None = utils.MISSING, + event_webhooks_status: bool = utils.MISSING, + event_webhooks_types: list[str] | None = utils.MISSING, + ) -> "AppInfo": """|coro| Edit the current application's settings. @@ -275,20 +327,38 @@ async def edit( ---------- description: Optional[:class:`str`] The new application description. Pass ``None`` to clear. + icon: Optional[Union[:class:`bytes`, :class:`str`]] + New icon image. If ``bytes`` is given it will be base64 encoded automatically. If a ``str`` is given it is assumed + to be a pre-encoded base64 data URI or hash and sent as-is. Pass ``None`` to clear. + cover_image: Optional[Union[:class:`bytes`, :class:`str`]] + New cover image for the store embed. If ``bytes`` is given it will be base64 encoded automatically. If a ``str`` is given it is assumed + to be a pre-encoded base64 data URI or hash and sent as-is. Pass ``None`` to clear. + tags: Optional[List[:class:`str`]] + List of tags for the application (max 5). Pass ``None`` to clear. terms_of_service_url: Optional[:class:`str`] - The application's Terms of Service URL. + The application's Terms of Service URL. Pass ``None`` to clear. privacy_policy_url: Optional[:class:`str`] - The application's Privacy Policy URL. - role_connections_verification_url: Optional[:class:`str`] - The role connection verification URL for the application. + The application's Privacy Policy URL. Pass ``None`` to clear. interactions_endpoint_url: Optional[:class:`str`] - The interactions endpoint callback URL. - tags: Optional[List[:class:`str`]] - List of tags for the application (max 5). - install_params: Optional[:class:`dict`] - Dict with keys ``scopes`` (list[str]) and ``permissions`` (str) used for default install link. + The interactions endpoint callback URL. Pass ``None`` to clear. + role_connections_verification_url: Optional[:class:`str`] + The role connection verification URL for the application. Pass ``None`` to clear. + install_params: Optional[:class:`AppInstallParams`] + Settings for the application's default in-app authorization link. Pass ``None`` to clear. Omit entirely to leave unchanged. custom_install_url: Optional[:class:`str`] - The default custom authorization URL for the application. + The default custom authorization URL for the application. Pass ``None`` to clear. + integration_types_config: Optional[:class:`IntegrationTypesConfig`] + Object specifying per-installation context configuration (guild and/or user). You may set contexts individually + and omit others to leave them unchanged. Pass the object with a context explicitly set to ``None`` to clear just that + context, or pass ``None`` to clear the entire integration types configuration. + flags: Optional[:class:`ApplicationFlags`] + Application public flags. Pass ``None`` to clear (not typical). + event_webhooks_url: Optional[:class:`str`] + Event webhooks callback URL for receiving application webhook events. Pass ``None`` to clear. + event_webhooks_status: :class:`bool` + Whether webhook events are enabled. ``True`` maps to API value ``2`` (enabled), ``False`` maps to ``1`` (disabled). + event_webhooks_types: Optional[List[:class:`str`]] + List of webhook event types to subscribe to. Pass ``None`` to clear. Returns ------- @@ -298,22 +368,50 @@ async def edit( payload: dict[str, object] = {} if description is not utils.MISSING: payload["description"] = description + if icon is not utils.MISSING: + if icon is None: + payload["icon"] = None + else: + payload["icon"] = utils._bytes_to_base64_data(icon) + if cover_image is not utils.MISSING: + if cover_image is None: + payload["cover_image"] = None + else: + payload["cover_image"] = utils._bytes_to_base64_data(cover_image) + if tags is not utils.MISSING: + payload["tags"] = tags if terms_of_service_url is not utils.MISSING: payload["terms_of_service_url"] = terms_of_service_url if privacy_policy_url is not utils.MISSING: payload["privacy_policy_url"] = privacy_policy_url + if interactions_endpoint_url is not utils.MISSING: + payload["interactions_endpoint_url"] = interactions_endpoint_url if role_connections_verification_url is not utils.MISSING: payload["role_connections_verification_url"] = ( role_connections_verification_url ) - if interactions_endpoint_url is not utils.MISSING: - payload["interactions_endpoint_url"] = interactions_endpoint_url - if tags is not utils.MISSING: - payload["tags"] = tags if install_params is not utils.MISSING: - payload["install_params"] = install_params + if install_params is None: + payload["install_params"] = None + else: + payload["install_params"] = install_params.to_payload() if custom_install_url is not utils.MISSING: payload["custom_install_url"] = custom_install_url + if integration_types_config is not utils.MISSING: + if integration_types_config is None: + payload["integration_types_config"] = None + else: + payload["integration_types_config"] = ( + integration_types_config.to_payload() + ) + if flags is not utils.MISSING: + payload["flags"] = None if flags is None else flags.value + if event_webhooks_url is not utils.MISSING: + payload["event_webhooks_url"] = event_webhooks_url + if event_webhooks_status is not utils.MISSING: + payload["event_webhooks_status"] = 2 if event_webhooks_status else 1 + if event_webhooks_types is not utils.MISSING: + payload["event_webhooks_types"] = event_webhooks_types data = await self._state.http.edit_current_application(payload) return AppInfo(self._state, data) @@ -361,6 +459,17 @@ def summary(self) -> str | None: ) return self._summary + @property + def event_webhooks_enabled(self) -> bool | None: + """Returns whether event webhooks are enabled. + + This is a convenience around :attr:`event_webhooks_status` where ``True`` means enabled and ``False`` means disabled. + ``None`` indicates the status is not present. + """ + if self.event_webhooks_status is None: + return None + return self.event_webhooks_status == 2 + class PartialAppInfo: """Represents a partial AppInfo given by :func:`~discord.abc.GuildChannel.create_invite` @@ -443,3 +552,58 @@ class AppInstallParams: def __init__(self, data: AppInstallParamsPayload) -> None: self.scopes: list[str] = data.get("scopes", []) self.permissions: Permissions = Permissions(int(data["permissions"])) + + def to_payload(self) -> dict[str, object]: + """Serialize this object into an application install params payload. + + Returns + ------- + Dict[str, Any] + A dict with ``scopes`` and ``permissions`` (string form) suitable for the API. + """ + if self.permissions.value > 0 and "bot" not in self.scopes: + raise ValueError( + "'bot' must be in install_params.scopes if permissions are requested" + ) + return { + "scopes": list(self.scopes), + "permissions": str(int(self.permissions.value)), + } + + +class IntegrationTypesConfig: + """Represents per-installation context configuration for an application. + + This object is used to build the payload for the ``integration_types_config`` field when editing an application. + + Parameters + ---------- + guild: Optional[:class:`AppInstallParams`] + The configuration for the guild installation context. Omit to leave unchanged; pass ``None`` to clear. + user: Optional[:class:`AppInstallParams`] + The configuration for the user installation context. Omit to leave unchanged; pass ``None`` to clear. + """ + + __slots__ = ("guild", "user") + + def __init__( + self, + *, + guild: AppInstallParams | None = utils.MISSING, + user: AppInstallParams | None = utils.MISSING, + ) -> None: + self.guild = guild + self.user = user + + def _encode_install_params(self, value: AppInstallParams | None) -> dict | None: + if value is None: + return None + return {"oauth2_install_params": value.to_payload()} + + def to_payload(self) -> dict[int, dict[str, object] | None]: + payload: dict[int, dict[str, object] | None] = {} + if self.guild is not utils.MISSING: + payload[0] = self._encode_install_params(self.guild) + if self.user is not utils.MISSING: + payload[1] = self._encode_install_params(self.user) + return payload diff --git a/discord/types/appinfo.py b/discord/types/appinfo.py index afb95dcc0b..8fdccf35f2 100644 --- a/discord/types/appinfo.py +++ b/discord/types/appinfo.py @@ -52,8 +52,6 @@ class BaseAppInfo(TypedDict): name: str description: str verify_key: str - # Deprecated by Discord but still present in some payloads; prefer 'description'. - summary: NotRequired[str] icon: NotRequired[str | None] cover_image: NotRequired[str] From bfa2940e55989549fe927cfd5f8aadb3be7f72e4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:00:56 +0000 Subject: [PATCH 05/61] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/appinfo.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 9f3272ebb3..c65d878e8e 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -29,8 +29,8 @@ from . import utils from .asset import Asset -from .permissions import Permissions from .flags import ApplicationFlags +from .permissions import Permissions if TYPE_CHECKING: from .guild import Guild @@ -309,14 +309,14 @@ async def edit( privacy_policy_url: str | None = utils.MISSING, interactions_endpoint_url: str | None = utils.MISSING, role_connections_verification_url: str | None = utils.MISSING, - install_params: "AppInstallParams | None" = utils.MISSING, + install_params: AppInstallParams | None = utils.MISSING, custom_install_url: str | None = utils.MISSING, - integration_types_config: "IntegrationTypesConfig | None" = utils.MISSING, + integration_types_config: IntegrationTypesConfig | None = utils.MISSING, flags: ApplicationFlags | None = utils.MISSING, event_webhooks_url: str | None = utils.MISSING, event_webhooks_status: bool = utils.MISSING, event_webhooks_types: list[str] | None = utils.MISSING, - ) -> "AppInfo": + ) -> AppInfo: """|coro| Edit the current application's settings. From 9893239a108696e0a615db3bd498a7cbd481aae2 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 7 Nov 2025 19:08:13 +0100 Subject: [PATCH 06/61] Update discord/appinfo.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index c65d878e8e..cc755e4858 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -229,7 +229,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.id: int = int(data["id"]) self.name: str = data["name"] self.description: str = data["description"] - self._icon: str | None = data["icon"] + self._icon: str | None = data.get("icon") self.rpc_origins: list[str] | None = data.get("rpc_origins") self.bot_public: bool = data["bot_public"] self.bot_require_code_grant: bool = data["bot_require_code_grant"] From 884cd6cd849fca9d0ba3f11b0a5218d847d4d800 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 7 Nov 2025 19:10:09 +0100 Subject: [PATCH 07/61] Update discord/appinfo.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index cc755e4858..f45bc251f9 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -275,7 +275,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.install_params: AppInstallParams | None = ( AppInstallParams(install_params) if install_params else None ) - self.tags: list[str] | None = data.get("tags", []) + self.tags: list[str] = data.get("tags", []) self.custom_install_url: str | None = data.get("custom_install_url") self.integration_types_config: dict[int, dict[str, object] | None] | None = ( data.get("integration_types_config") From 41f12a2fee232498fd9ff9e63134a4245c0512dc Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 7 Nov 2025 19:10:31 +0100 Subject: [PATCH 08/61] Update discord/appinfo.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index f45bc251f9..2b586c1ad7 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -260,7 +260,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): ) raw_flags = data.get("flags") self._flags: int | None = raw_flags if isinstance(raw_flags, int) else None - self.redirect_uris: list[str] | None = data.get("redirect_uris", []) + self.redirect_uris: list[str] = data.get("redirect_uris", []) self.interactions_endpoint_url: str | None = data.get( "interactions_endpoint_url" ) From 714c68aa02480d8d85c065e04ba58f9297bb1da9 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 7 Nov 2025 19:10:43 +0100 Subject: [PATCH 09/61] Update discord/appinfo.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 2b586c1ad7..0c9a5e7501 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -595,7 +595,7 @@ def __init__( self.guild = guild self.user = user - def _encode_install_params(self, value: AppInstallParams | None) -> dict | None: + def _encode_install_params(self, value: AppInstallParams | None) -> dict[str, object] | None: if value is None: return None return {"oauth2_install_params": value.to_payload()} From 734b5ceca89b29c08158fe4d798d531bedb626c4 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 7 Nov 2025 19:10:58 +0100 Subject: [PATCH 10/61] Update discord/appinfo.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 0c9a5e7501..20a5a6ce90 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -567,7 +567,7 @@ def to_payload(self) -> dict[str, object]: ) return { "scopes": list(self.scopes), - "permissions": str(int(self.permissions.value)), + "permissions": str(self.permissions.value), } From 49c90b9f4d73c321c15e6690687153e529911294 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 7 Nov 2025 19:11:20 +0100 Subject: [PATCH 11/61] Update discord/appinfo.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 20a5a6ce90..7bb0ea6e87 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -231,9 +231,10 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.description: str = data["description"] self._icon: str | None = data.get("icon") self.rpc_origins: list[str] | None = data.get("rpc_origins") - self.bot_public: bool = data["bot_public"] - self.bot_require_code_grant: bool = data["bot_require_code_grant"] - self.owner: User = state.create_user(data["owner"]) + self.bot_public: bool = data.get("bot_public", False) + self.bot_require_code_grant: bool = data.get("bot_require_code_grant", False) + owner_data = data.get("owner") + self.owner: User | None = state.create_user(owner_data) if owner_data is not None else None team: TeamPayload | None = data.get("team") self.team: Team | None = Team(state, team) if team else None From 76eb8838ccae9832a554f2b31ff008c6129738a4 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:12:22 +0200 Subject: [PATCH 12/61] Restrict icon and cover_image types to bytes or None Updated the AppInfo class to only accept bytes or None for the icon and cover_image parameters, removing support for str. This change clarifies the expected types and may prevent type-related errors. --- discord/appinfo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 7bb0ea6e87..0bfafc0722 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -303,8 +303,8 @@ async def edit( self, *, description: str | None = utils.MISSING, - icon: bytes | str | None = utils.MISSING, - cover_image: bytes | str | None = utils.MISSING, + icon: bytes | None = utils.MISSING, + cover_image: bytes | None = utils.MISSING, tags: list[str] | None = utils.MISSING, terms_of_service_url: str | None = utils.MISSING, privacy_policy_url: str | None = utils.MISSING, From bd917890dc2828216b25d9b9c0f8c5da892bed34 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:12:20 +0000 Subject: [PATCH 13/61] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/appinfo.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 7bb0ea6e87..aec596b053 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -234,7 +234,9 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.bot_public: bool = data.get("bot_public", False) self.bot_require_code_grant: bool = data.get("bot_require_code_grant", False) owner_data = data.get("owner") - self.owner: User | None = state.create_user(owner_data) if owner_data is not None else None + self.owner: User | None = ( + state.create_user(owner_data) if owner_data is not None else None + ) team: TeamPayload | None = data.get("team") self.team: Team | None = Team(state, team) if team else None @@ -596,7 +598,9 @@ def __init__( self.guild = guild self.user = user - def _encode_install_params(self, value: AppInstallParams | None) -> dict[str, object] | None: + def _encode_install_params( + self, value: AppInstallParams | None + ) -> dict[str, object] | None: if value is None: return None return {"oauth2_install_params": value.to_payload()} From 7e5ff542e9fb8906178249daca72cc3a7ecc145a Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:28:43 +0200 Subject: [PATCH 14/61] feat(emoji): add mention property to BaseEmoji for easier emoji referencing --- discord/appinfo.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 0bfafc0722..9183716726 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -183,6 +183,11 @@ class AppInfo: flags: Optional[:class:`ApplicationFlags`] The public application flags, if set. + .. versionadded:: 2.7 + + bot: Optional[:class:`User`] + The bot user associated with this application, if any. + .. versionadded:: 2.7 """ @@ -234,7 +239,9 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.bot_public: bool = data.get("bot_public", False) self.bot_require_code_grant: bool = data.get("bot_require_code_grant", False) owner_data = data.get("owner") - self.owner: User | None = state.create_user(owner_data) if owner_data is not None else None + self.owner: User | None = ( + state.create_user(owner_data) if owner_data is not None else None + ) team: TeamPayload | None = data.get("team") self.team: Team | None = Team(state, team) if team else None @@ -596,7 +603,9 @@ def __init__( self.guild = guild self.user = user - def _encode_install_params(self, value: AppInstallParams | None) -> dict[str, object] | None: + def _encode_install_params( + self, value: AppInstallParams | None + ) -> dict[str, object] | None: if value is None: return None return {"oauth2_install_params": value.to_payload()} From 89d30110079dba616ee49d8b48952e5f042a6359 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:57:45 +0200 Subject: [PATCH 15/61] refactor(AppInfo): improve integration_types_config handling and simplify owner assignment --- discord/appinfo.py | 51 +++++++++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 9183716726..eed4f0fd18 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -141,9 +141,8 @@ class AppInfo: .. versionadded:: 2.7 - integration_types_config: Optional[Dict[:class:`int`, Optional[Dict[:class:`str`, Any]]]] - Per-installation context configuration. Keys are ``0`` (guild) and ``1`` (user) mapping to an object containing - ``oauth2_install_params`` or ``None`` if cleared. + integration_types_config: Optional[:class:`IntegrationTypesConfig`] + Per-installation context configuration for guild (``0``) and user (``1``) contexts. .. versionadded:: 2.7 @@ -238,10 +237,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.rpc_origins: list[str] | None = data.get("rpc_origins") self.bot_public: bool = data.get("bot_public", False) self.bot_require_code_grant: bool = data.get("bot_require_code_grant", False) - owner_data = data.get("owner") - self.owner: User | None = ( - state.create_user(owner_data) if owner_data is not None else None - ) + self.owner: User | None = data.get("owner") and state.create_user(data["owner"]) team: TeamPayload | None = data.get("team") self.team: Team | None = Team(state, team) if team else None @@ -266,8 +262,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.approximate_user_authorization_count: int | None = data.get( "approximate_user_authorization_count" ) - raw_flags = data.get("flags") - self._flags: int | None = raw_flags if isinstance(raw_flags, int) else None + self._flags: int | None = data.get("flags") self.redirect_uris: list[str] = data.get("redirect_uris", []) self.interactions_endpoint_url: str | None = data.get( "interactions_endpoint_url" @@ -279,13 +274,12 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.event_webhooks_status: int | None = data.get("event_webhooks_status") self.event_webhooks_types: list[str] | None = data.get("event_webhooks_types") - install_params = data.get("install_params") - self.install_params: AppInstallParams | None = ( - AppInstallParams(install_params) if install_params else None + self.install_params: AppInstallParams | None = data.get("install_params") and ( + AppInstallParams(data["install_params"]) ) self.tags: list[str] = data.get("tags", []) self.custom_install_url: str | None = data.get("custom_install_url") - self.integration_types_config: dict[int, dict[str, object] | None] | None = ( + self.integration_types_config = IntegrationTypesConfig.from_payload( data.get("integration_types_config") ) @@ -301,6 +295,8 @@ def flags(self) -> ApplicationFlags | None: """The public application flags, if set. Returns an :class:`ApplicationFlags` instance or ``None`` when not present. + + .. versionadded:: 2.7 """ if self._flags is None: return None @@ -329,12 +325,10 @@ async def edit( Edit the current application's settings. - This method wraps the Edit Current Application endpoint and returns the updated application info. - Parameters ---------- description: Optional[:class:`str`] - The new application description. Pass ``None`` to clear. + The new application description or ``None`` to clear. icon: Optional[Union[:class:`bytes`, :class:`str`]] New icon image. If ``bytes`` is given it will be base64 encoded automatically. If a ``str`` is given it is assumed to be a pre-encoded base64 data URI or hash and sent as-is. Pass ``None`` to clear. @@ -372,6 +366,8 @@ async def edit( ------- :class:`.AppInfo` The updated application information. + + .. versionadded:: 2.7 """ payload: dict[str, object] = {} if description is not utils.MISSING: @@ -603,6 +599,29 @@ def __init__( self.guild = guild self.user = user + @classmethod + def from_payload(cls, data: dict | None) -> "IntegrationTypesConfig | None": + if data is None: + return None + + def _get_ctx(raw: dict, key: int): + if key in raw: + return raw[key] + skey = str(key) + return raw.get(skey) + + def _decode_ctx(value: dict | None) -> AppInstallParams | None: + if value is None: + return None + params = value.get("oauth2_install_params") + if not params: + return None + return AppInstallParams(params) + + guild_ctx = _decode_ctx(_get_ctx(data, 0)) + user_ctx = _decode_ctx(_get_ctx(data, 1)) + return cls(guild=guild_ctx, user=user_ctx) + def _encode_install_params( self, value: AppInstallParams | None ) -> dict[str, object] | None: From dfb79074d19bf0d1af8b89ff57bd38f0ca6f55b2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:58:25 +0000 Subject: [PATCH 16/61] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index eed4f0fd18..5b8fded5d0 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -600,7 +600,7 @@ def __init__( self.user = user @classmethod - def from_payload(cls, data: dict | None) -> "IntegrationTypesConfig | None": + def from_payload(cls, data: dict | None) -> IntegrationTypesConfig | None: if data is None: return None From 9eecbfa73a41def68e076b2cb0d15597cf72b068 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:22:00 +0200 Subject: [PATCH 17/61] refactor(AppInfo): remove deprecated flags attribute from AppInfo class --- discord/appinfo.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 5b8fded5d0..7c787261f1 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -179,11 +179,6 @@ class AppInfo: .. versionadded:: 2.7 - flags: Optional[:class:`ApplicationFlags`] - The public application flags, if set. - - .. versionadded:: 2.7 - bot: Optional[:class:`User`] The bot user associated with this application, if any. From 03a46e8bf54f1b5cc6e10e37ed207366a592c169 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:06:58 +0200 Subject: [PATCH 18/61] refactor(AppInfo): rename event_webhooks_status to _event_webhooks_status and add setter for event_webhooks_enabled --- discord/appinfo.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 7c787261f1..f12a6f7c5e 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -151,12 +151,6 @@ class AppInfo: .. versionadded:: 2.7 - event_webhooks_status: Optional[:class:`int`] - The raw event webhooks status integer from the API (``2`` enabled, ``1`` disabled) if present. - Prefer :attr:`event_webhooks_enabled` for a boolean form. - - .. versionadded:: 2.7 - event_webhooks_types: Optional[List[:class:`str`]] List of event webhook types subscribed to, if set. @@ -213,7 +207,7 @@ class AppInfo: "interactions_endpoint_url", "role_connections_verification_url", "event_webhooks_url", - "event_webhooks_status", + "_event_webhooks_status", "event_webhooks_types", "integration_types_config", "install_params", @@ -266,7 +260,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): "role_connections_verification_url" ) self.event_webhooks_url: str | None = data.get("event_webhooks_url") - self.event_webhooks_status: int | None = data.get("event_webhooks_status") + self._event_webhooks_status: int | None = data.get("event_webhooks_status") self.event_webhooks_types: list[str] | None = data.get("event_webhooks_types") self.install_params: AppInstallParams | None = data.get("install_params") and ( @@ -461,13 +455,24 @@ def summary(self) -> str | None: @property def event_webhooks_enabled(self) -> bool | None: """Returns whether event webhooks are enabled. - - This is a convenience around :attr:`event_webhooks_status` where ``True`` means enabled and ``False`` means disabled. + This is a convenience around the raw API status integer where ``True`` means enabled and ``False`` means disabled. ``None`` indicates the status is not present. """ - if self.event_webhooks_status is None: + if self._event_webhooks_status is None: return None - return self.event_webhooks_status == 2 + return self._event_webhooks_status == 2 + + @event_webhooks_enabled.setter + def event_webhooks_enabled(self, value: bool | None) -> None: + """Set whether event webhooks are enabled. + + Setting to ``True`` maps to the API value ``2`` (enabled), ``False`` maps to ``1`` (disabled), + and ``None`` clears the status (sets it to ``None``). + """ + if value is None: + self._event_webhooks_status = None + return + self._event_webhooks_status = 2 if value else 1 class PartialAppInfo: From 0b603f5d7cbecbb9154d0630901141740b6b4132 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:15:28 +0200 Subject: [PATCH 19/61] refactor(AppInfo): remove setter for event_webhooks_enabled and associated docstring --- discord/appinfo.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index f12a6f7c5e..ec33429581 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -462,18 +462,6 @@ def event_webhooks_enabled(self) -> bool | None: return None return self._event_webhooks_status == 2 - @event_webhooks_enabled.setter - def event_webhooks_enabled(self, value: bool | None) -> None: - """Set whether event webhooks are enabled. - - Setting to ``True`` maps to the API value ``2`` (enabled), ``False`` maps to ``1`` (disabled), - and ``None`` clears the status (sets it to ``None``). - """ - if value is None: - self._event_webhooks_status = None - return - self._event_webhooks_status = 2 if value else 1 - class PartialAppInfo: """Represents a partial AppInfo given by :func:`~discord.abc.GuildChannel.create_invite` From 3c398ccf0c23f827f68dd8fabb9a0d0c9d4ea02a Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Sat, 29 Nov 2025 07:33:30 +0100 Subject: [PATCH 20/61] Update CHANGELOG.md Co-authored-by: Paillat Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc07928a3..69cbdf0f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ These changes are available on the `master` branch, but have not yet been releas - Added `Attachment.read_chunked` and added optional `chunksize` argument to `Attachment.save` for retrieving attachments in chunks. ([#2956](https://github.com/Pycord-Development/pycord/pull/2956)) -- Added `AppInfo.edit()` method and missing fields. +- Added `AppInfo.edit()` method and missing `AppInfo` fields. ([#2994](https://github.com/Pycord-Development/pycord/pull/2994)) ### Changed From 1b0017c97a6d938c05500cec2c4aba8052dc9ca2 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 2 Dec 2025 19:53:07 +0200 Subject: [PATCH 21/61] refactor: update method names and types in AppInfo and HTTPClient --- discord/appinfo.py | 53 ++++++++++++++++++++++++---------------------- discord/http.py | 2 +- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index ec33429581..a6476f3c57 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -25,7 +25,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from . import utils from .asset import Asset @@ -358,7 +358,7 @@ async def edit( .. versionadded:: 2.7 """ - payload: dict[str, object] = {} + payload: dict[str, Any] = {} if description is not utils.MISSING: payload["description"] = description if icon is not utils.MISSING: @@ -387,7 +387,7 @@ async def edit( if install_params is None: payload["install_params"] = None else: - payload["install_params"] = install_params.to_payload() + payload["install_params"] = install_params._to_payload() if custom_install_url is not utils.MISSING: payload["custom_install_url"] = custom_install_url if integration_types_config is not utils.MISSING: @@ -395,7 +395,7 @@ async def edit( payload["integration_types_config"] = None else: payload["integration_types_config"] = ( - integration_types_config.to_payload() + integration_types_config._to_payload() ) if flags is not utils.MISSING: payload["flags"] = None if flags is None else flags.value @@ -406,7 +406,7 @@ async def edit( if event_webhooks_types is not utils.MISSING: payload["event_webhooks_types"] = event_webhooks_types - data = await self._state.http.edit_current_application(payload) + data = await self._state.http.edit_current_application_info(payload) return AppInfo(self._state, data) @property @@ -545,7 +545,7 @@ def __init__(self, data: AppInstallParamsPayload) -> None: self.scopes: list[str] = data.get("scopes", []) self.permissions: Permissions = Permissions(int(data["permissions"])) - def to_payload(self) -> dict[str, object]: + def _to_payload(self) -> dict[str, object]: """Serialize this object into an application install params payload. Returns @@ -587,27 +587,30 @@ def __init__( self.guild = guild self.user = user + @staticmethod + def _get_ctx(raw: dict | None, key: int): + if raw is None: + return None + if key in raw: + return raw[key] + skey = str(key) + return raw.get(skey) + + @staticmethod + def _decode_ctx(value: dict | None) -> AppInstallParams | None: + if value is None: + return None + params = value.get("oauth2_install_params") + if not params: + return None + return AppInstallParams(params) + @classmethod def from_payload(cls, data: dict | None) -> IntegrationTypesConfig | None: if data is None: return None - - def _get_ctx(raw: dict, key: int): - if key in raw: - return raw[key] - skey = str(key) - return raw.get(skey) - - def _decode_ctx(value: dict | None) -> AppInstallParams | None: - if value is None: - return None - params = value.get("oauth2_install_params") - if not params: - return None - return AppInstallParams(params) - - guild_ctx = _decode_ctx(_get_ctx(data, 0)) - user_ctx = _decode_ctx(_get_ctx(data, 1)) + guild_ctx = cls._decode_ctx(cls._get_ctx(data, 0)) + user_ctx = cls._decode_ctx(cls._get_ctx(data, 1)) return cls(guild=guild_ctx, user=user_ctx) def _encode_install_params( @@ -615,9 +618,9 @@ def _encode_install_params( ) -> dict[str, object] | None: if value is None: return None - return {"oauth2_install_params": value.to_payload()} + return {"oauth2_install_params": value._to_payload()} - def to_payload(self) -> dict[int, dict[str, object] | None]: + def _to_payload(self) -> dict[int, dict[str, object] | None]: payload: dict[int, dict[str, object] | None] = {} if self.guild is not utils.MISSING: payload[0] = self._encode_install_params(self.guild) diff --git a/discord/http.py b/discord/http.py index 74cf19c541..b575b416f1 100644 --- a/discord/http.py +++ b/discord/http.py @@ -3213,7 +3213,7 @@ def get_answer_voters( def application_info(self) -> Response[appinfo.AppInfo]: return self.request(Route("GET", "/oauth2/applications/@me")) - def edit_current_application( + def edit_current_application_info( self, payload: dict[str, Any] ) -> Response[appinfo.AppInfo]: return self.request(Route("PATCH", "/applications/@me"), json=payload) From ef0cb756146e64a793bff0106c421e3bddcc03a7 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 5 Dec 2025 09:53:10 +0200 Subject: [PATCH 22/61] feat: add ApplicationEventWebhookStatus enum and update AppInfo to use it --- discord/appinfo.py | 30 +++++++++++++++--------------- discord/client.py | 6 +++--- discord/enums.py | 9 +++++++++ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index a6476f3c57..6ec01ef6f7 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -26,6 +26,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any +from .enums import ApplicationEventWebhookStatus, try_enum from . import utils from .asset import Asset @@ -46,6 +47,7 @@ "PartialAppInfo", "AppInstallParams", "IntegrationTypesConfig", + "ApplicationEventWebhookStatus", ) @@ -151,6 +153,11 @@ class AppInfo: .. versionadded:: 2.7 + event_webhooks_status: :class:`ApplicationEventWebhookStatus` + The status of event webhooks for the application. + + .. versionadded:: 2.7 + event_webhooks_types: Optional[List[:class:`str`]] List of event webhook types subscribed to, if set. @@ -260,7 +267,9 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): "role_connections_verification_url" ) self.event_webhooks_url: str | None = data.get("event_webhooks_url") - self._event_webhooks_status: int | None = data.get("event_webhooks_status") + self.event_webhooks_status: ApplicationEventWebhookStatus | None = data.get( + "event_webhooks_status" + ) and try_enum(ApplicationEventWebhookStatus, data["event_webhooks_status"]) self.event_webhooks_types: list[str] | None = data.get("event_webhooks_types") self.install_params: AppInstallParams | None = data.get("install_params") and ( @@ -307,7 +316,7 @@ async def edit( integration_types_config: IntegrationTypesConfig | None = utils.MISSING, flags: ApplicationFlags | None = utils.MISSING, event_webhooks_url: str | None = utils.MISSING, - event_webhooks_status: bool = utils.MISSING, + event_webhooks_status: ApplicationEventWebhookStatus = utils.MISSING, event_webhooks_types: list[str] | None = utils.MISSING, ) -> AppInfo: """|coro| @@ -346,8 +355,9 @@ async def edit( Application public flags. Pass ``None`` to clear (not typical). event_webhooks_url: Optional[:class:`str`] Event webhooks callback URL for receiving application webhook events. Pass ``None`` to clear. - event_webhooks_status: :class:`bool` - Whether webhook events are enabled. ``True`` maps to API value ``2`` (enabled), ``False`` maps to ``1`` (disabled). + event_webhooks_status: :class:`ApplicationEventWebhookStatus` + The desired webhook status. Pass an :class:`ApplicationEventWebhookStatus` value to set + a specific status (``DISABLED``=1, ``ENABLED``=2, ``DISABLED_BY_DISCORD``=3). event_webhooks_types: Optional[List[:class:`str`]] List of webhook event types to subscribe to. Pass ``None`` to clear. @@ -402,7 +412,7 @@ async def edit( if event_webhooks_url is not utils.MISSING: payload["event_webhooks_url"] = event_webhooks_url if event_webhooks_status is not utils.MISSING: - payload["event_webhooks_status"] = 2 if event_webhooks_status else 1 + payload["event_webhooks_status"] = event_webhooks_status.value if event_webhooks_types is not utils.MISSING: payload["event_webhooks_types"] = event_webhooks_types @@ -452,16 +462,6 @@ def summary(self) -> str | None: ) return self._summary - @property - def event_webhooks_enabled(self) -> bool | None: - """Returns whether event webhooks are enabled. - This is a convenience around the raw API status integer where ``True`` means enabled and ``False`` means disabled. - ``None`` indicates the status is not present. - """ - if self._event_webhooks_status is None: - return None - return self._event_webhooks_status == 2 - class PartialAppInfo: """Represents a partial AppInfo given by :func:`~discord.abc.GuildChannel.create_invite` diff --git a/discord/client.py b/discord/client.py index 9b949a2781..d1b693045f 100644 --- a/discord/client.py +++ b/discord/client.py @@ -244,9 +244,9 @@ def __init__( self.loop: asyncio.AbstractEventLoop = ( asyncio.get_event_loop() if loop is None else loop ) - self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( - {} - ) + self._listeners: dict[ + str, list[tuple[asyncio.Future, Callable[..., bool]]] + ] = {} self.shard_id: int | None = options.get("shard_id") self.shard_count: int | None = options.get("shard_count") diff --git a/discord/enums.py b/discord/enums.py index 63557c853b..d584b7f09b 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -84,6 +84,7 @@ "SubscriptionStatus", "SeparatorSpacingSize", "SelectDefaultValueType", + "ApplicationEventWebhookStatus", ) @@ -1131,6 +1132,14 @@ class SelectDefaultValueType(Enum): user = "user" +class ApplicationEventWebhookStatus(Enum): + """Represents the application event webhook status.""" + + DISABLED = 1 + ENABLED = 2 + DISABLED_BY_DISCORD = 3 + + T = TypeVar("T") From 5f939dd7dc8ff84eb5b76e6a7c5a6278ea4d08df Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 07:53:54 +0000 Subject: [PATCH 23/61] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/appinfo.py | 2 +- discord/client.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 6ec01ef6f7..9ca4b78ed8 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -26,10 +26,10 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from .enums import ApplicationEventWebhookStatus, try_enum from . import utils from .asset import Asset +from .enums import ApplicationEventWebhookStatus, try_enum from .flags import ApplicationFlags from .permissions import Permissions diff --git a/discord/client.py b/discord/client.py index d1b693045f..9b949a2781 100644 --- a/discord/client.py +++ b/discord/client.py @@ -244,9 +244,9 @@ def __init__( self.loop: asyncio.AbstractEventLoop = ( asyncio.get_event_loop() if loop is None else loop ) - self._listeners: dict[ - str, list[tuple[asyncio.Future, Callable[..., bool]]] - ] = {} + self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( + {} + ) self.shard_id: int | None = options.get("shard_id") self.shard_count: int | None = options.get("shard_count") From ae03d857dfcf21beaffacc3552602276b54b5314 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:13:00 +0100 Subject: [PATCH 24/61] Update discord/appinfo.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 9ca4b78ed8..283d596cd4 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -153,8 +153,8 @@ class AppInfo: .. versionadded:: 2.7 - event_webhooks_status: :class:`ApplicationEventWebhookStatus` - The status of event webhooks for the application. + event_webhooks_status: Optional[:class:`ApplicationEventWebhookStatus`] + The status of event webhooks for the application, if set. .. versionadded:: 2.7 From f2d461daa13b25b21888f05d81b861e828825e57 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:19:17 +0200 Subject: [PATCH 25/61] refactor: simplify icon and cover_image type annotations in AppInfo --- discord/appinfo.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 283d596cd4..add448f1e9 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -327,12 +327,10 @@ async def edit( ---------- description: Optional[:class:`str`] The new application description or ``None`` to clear. - icon: Optional[Union[:class:`bytes`, :class:`str`]] - New icon image. If ``bytes`` is given it will be base64 encoded automatically. If a ``str`` is given it is assumed - to be a pre-encoded base64 data URI or hash and sent as-is. Pass ``None`` to clear. - cover_image: Optional[Union[:class:`bytes`, :class:`str`]] - New cover image for the store embed. If ``bytes`` is given it will be base64 encoded automatically. If a ``str`` is given it is assumed - to be a pre-encoded base64 data URI or hash and sent as-is. Pass ``None`` to clear. + icon: Optional[:class:`bytes`] + New icon image. If ``bytes`` is given it will be base64 encoded automatically. Pass ``None`` to clear. + cover_image: Optional[:class:`bytes`] + New cover image for the store embed. If ``bytes`` is given it will be base64 encoded automatically. Pass ``None`` to clear. tags: Optional[List[:class:`str`]] List of tags for the application (max 5). Pass ``None`` to clear. terms_of_service_url: Optional[:class:`str`] From 7afeed14ae4286bdee1ebf02cd8fe92044514fef Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 9 Dec 2025 19:45:09 +0200 Subject: [PATCH 26/61] feat: implement FlagCommand for enhanced command argument parsing --- discord/appinfo.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index add448f1e9..1dec9a3da9 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -233,14 +233,20 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.rpc_origins: list[str] | None = data.get("rpc_origins") self.bot_public: bool = data.get("bot_public", False) self.bot_require_code_grant: bool = data.get("bot_require_code_grant", False) - self.owner: User | None = data.get("owner") and state.create_user(data["owner"]) + self.owner: User | None = ( + state.create_user(owner) + if (owner := data.get("owner")) is not None + else None + ) team: TeamPayload | None = data.get("team") self.team: Team | None = Team(state, team) if team else None self._summary: str = data["summary"] self.verify_key: str = data["verify_key"] - self.bot: User | None = data.get("bot") and state.create_user(data["bot"]) + self.bot: User | None = ( + state.create_user(bot) if (bot := data.get("bot")) is not None else None + ) self.guild_id: int | None = utils._get_as_snowflake(data, "guild_id") @@ -267,13 +273,17 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): "role_connections_verification_url" ) self.event_webhooks_url: str | None = data.get("event_webhooks_url") - self.event_webhooks_status: ApplicationEventWebhookStatus | None = data.get( - "event_webhooks_status" - ) and try_enum(ApplicationEventWebhookStatus, data["event_webhooks_status"]) + self.event_webhooks_status: ApplicationEventWebhookStatus | None = ( + try_enum(ApplicationEventWebhookStatus, status) + if (status := data.get("event_webhooks_status")) is not None + else None + ) self.event_webhooks_types: list[str] | None = data.get("event_webhooks_types") - self.install_params: AppInstallParams | None = data.get("install_params") and ( - AppInstallParams(data["install_params"]) + self.install_params: AppInstallParams | None = ( + AppInstallParams(install_params) + if (install_params := data.get("install_params")) is not None + else None ) self.tags: list[str] = data.get("tags", []) self.custom_install_url: str | None = data.get("custom_install_url") From 03700adf535bd9486aa35340901719e4ca497b0d Mon Sep 17 00:00:00 2001 From: Paillat Date: Tue, 9 Dec 2025 18:55:58 +0100 Subject: [PATCH 27/61] Update discord/appinfo.py Signed-off-by: Paillat --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 1dec9a3da9..c4eabcae30 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -214,7 +214,7 @@ class AppInfo: "interactions_endpoint_url", "role_connections_verification_url", "event_webhooks_url", - "_event_webhooks_status", + "event_webhooks_status", "event_webhooks_types", "integration_types_config", "install_params", From 30e6fa09ca0c2e9423b1c80f3a767ed86ae4b0e0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:34:24 +0000 Subject: [PATCH 28/61] style(pre-commit): auto fixes from pre-commit.com hooks --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe8bb50b9..10ac33e14c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,6 @@ These changes are available on the `master` branch, but have not yet been releas - Added `Guild.fetch_roles_member_counts` method and `GuildRoleCounts` class. ([#3020](https://github.com/Pycord-Development/pycord/pull/3020)) - ### Changed ### Fixed From fc63c77f07287d94545ada7541e195fa835933b9 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 23 Dec 2025 21:08:22 +0100 Subject: [PATCH 29/61] docs: add versionadded directive for 2.7 in AppInfo and enums --- discord/appinfo.py | 17 +++++++++++++++-- discord/enums.py | 10 ++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index c4eabcae30..fcae6cf116 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -333,6 +333,8 @@ async def edit( Edit the current application's settings. + .. versionadded:: 2.7 + Parameters ---------- description: Optional[:class:`str`] @@ -373,8 +375,6 @@ async def edit( ------- :class:`.AppInfo` The updated application information. - - .. versionadded:: 2.7 """ payload: dict[str, Any] = {} if description is not utils.MISSING: @@ -556,6 +556,8 @@ def __init__(self, data: AppInstallParamsPayload) -> None: def _to_payload(self) -> dict[str, object]: """Serialize this object into an application install params payload. + .. versionadded:: 2.7 + Returns ------- Dict[str, Any] @@ -576,6 +578,8 @@ class IntegrationTypesConfig: This object is used to build the payload for the ``integration_types_config`` field when editing an application. + .. versionadded:: 2.7 + Parameters ---------- guild: Optional[:class:`AppInstallParams`] @@ -629,6 +633,15 @@ def _encode_install_params( return {"oauth2_install_params": value._to_payload()} def _to_payload(self) -> dict[int, dict[str, object] | None]: + """Serialize this configuration into the payload expected by the API. + + Returns + ------- + Dict[int, Dict[str, object] | None] + Mapping of integration context IDs to encoded install parameters, or ``None`` to clear. + + .. versionadded:: 2.7 + """ payload: dict[int, dict[str, object] | None] = {} if self.guild is not utils.MISSING: payload[0] = self._encode_install_params(self.guild) diff --git a/discord/enums.py b/discord/enums.py index d584b7f09b..ef91163035 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1060,7 +1060,10 @@ class EntitlementOwnerType(Enum): class IntegrationType(Enum): - """The application's integration type""" + """The application's integration type. + + .. versionadded:: 2.7 + """ guild_install = 0 user_install = 1 @@ -1133,7 +1136,10 @@ class SelectDefaultValueType(Enum): class ApplicationEventWebhookStatus(Enum): - """Represents the application event webhook status.""" + """Represents the application event webhook status. + + .. versionadded:: 2.7 + """ DISABLED = 1 ENABLED = 2 From ab26c2a81b1b7cab88bf6b4feab589de31171092 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 23 Dec 2025 21:11:46 +0100 Subject: [PATCH 30/61] =?UTF-8?q?docs:=20=F0=9F=93=9D=20Add=20versionadded?= =?UTF-8?q?=20directive=20for=20ApplicationEventWebhookStatus=20in=20enums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- discord/enums.py | 5 +---- docs/api/enums.rst | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index ef91163035..06fd7d4d25 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1136,10 +1136,7 @@ class SelectDefaultValueType(Enum): class ApplicationEventWebhookStatus(Enum): - """Represents the application event webhook status. - - .. versionadded:: 2.7 - """ + """Represents the application event webhook status.""" DISABLED = 1 ENABLED = 2 diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 2e4f28a644..3f15aa83b2 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2630,3 +2630,22 @@ of :class:`enum.Enum`. .. attribute:: user The default value is a user. + + +.. class:: ApplicationEventWebhookStatus + + Represents the application event webhook status. + + .. versionadded:: 2.7 + + .. attribute:: DISABLED + + The application webhook is disabled. + + .. attribute:: ENABLED + + The application webhook is enabled. + + .. attribute:: DISABLED_BY_DISCORD + + The application webhook has been disabled by Discord. From 92a96165d6c497a2a2affac25485a2cecfb4fed0 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:14:27 +0100 Subject: [PATCH 31/61] =?UTF-8?q?docs:=20=F0=9F=93=9D=20Update=20versionad?= =?UTF-8?q?ded=20directive=20to=202.7.1=20for=20AppInfo=20attributes=20and?= =?UTF-8?q?=20ApplicationEventWebhookStatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- discord/appinfo.py | 69 ++++++++++++++++++++++++---------------------- discord/enums.py | 11 +++----- docs/api/enums.rst | 2 +- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index fcae6cf116..8faec05065 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -116,74 +116,74 @@ class AppInfo: approximate_guild_count: Optional[:class:`int`] The approximate count of guilds to which the app has been added, if any. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 approximate_user_install_count: Optional[:class:`int`] The approximate count of users who have installed the application, if any. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 redirect_uris: Optional[List[:class:`str`]] The list of redirect URIs for the application, if set. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 interactions_endpoint_url: Optional[:class:`str`] The interactions endpoint URL for the application, if set. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 role_connections_verification_url: Optional[:class:`str`] The role connection verification URL for the application, if set. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 install_params: Optional[:class:`AppInstallParams`] The settings for the application's default in-app authorization link, if set. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 integration_types_config: Optional[:class:`IntegrationTypesConfig`] Per-installation context configuration for guild (``0``) and user (``1``) contexts. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 event_webhooks_url: Optional[:class:`str`] The URL used to receive application event webhooks, if set. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 event_webhooks_status: Optional[:class:`ApplicationEventWebhookStatus`] The status of event webhooks for the application, if set. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 event_webhooks_types: Optional[List[:class:`str`]] List of event webhook types subscribed to, if set. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 tags: Optional[List[:class:`str`]] The list of tags describing the content and functionality of the app, if set. Maximium of 5 tags. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 custom_install_url: Optional[:class:`str`] The default custom authorization URL for the application, if set. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 approximate_user_authorization_count: Optional[:class:`int`] The approximate count of users who have authorized the application, if any. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 bot: Optional[:class:`User`] The bot user associated with this application, if any. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 """ __slots__ = ( @@ -231,7 +231,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.description: str = data["description"] self._icon: str | None = data.get("icon") self.rpc_origins: list[str] | None = data.get("rpc_origins") - self.bot_public: bool = data.get("bot_public", False) + self.bot_public: bool = data.get("bot_public", True) self.bot_require_code_grant: bool = data.get("bot_require_code_grant", False) self.owner: User | None = ( state.create_user(owner) @@ -242,7 +242,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): team: TeamPayload | None = data.get("team") self.team: Team | None = Team(state, team) if team else None - self._summary: str = data["summary"] + self._summary: str | None = data.get("summary") self.verify_key: str = data["verify_key"] self.bot: User | None = ( state.create_user(bot) if (bot := data.get("bot")) is not None else None @@ -287,8 +287,8 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): ) self.tags: list[str] = data.get("tags", []) self.custom_install_url: str | None = data.get("custom_install_url") - self.integration_types_config = IntegrationTypesConfig.from_payload( - data.get("integration_types_config") + self.integration_types_config: IntegrationTypesConfig | None = ( + IntegrationTypesConfig.from_payload(data.get("integration_types_config")) ) def __repr__(self) -> str: @@ -304,7 +304,7 @@ def flags(self) -> ApplicationFlags | None: Returns an :class:`ApplicationFlags` instance or ``None`` when not present. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 """ if self._flags is None: return None @@ -333,7 +333,7 @@ async def edit( Edit the current application's settings. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 Parameters ---------- @@ -366,8 +366,7 @@ async def edit( event_webhooks_url: Optional[:class:`str`] Event webhooks callback URL for receiving application webhook events. Pass ``None`` to clear. event_webhooks_status: :class:`ApplicationEventWebhookStatus` - The desired webhook status. Pass an :class:`ApplicationEventWebhookStatus` value to set - a specific status (``DISABLED``=1, ``ENABLED``=2, ``DISABLED_BY_DISCORD``=3). + The desired webhook status. event_webhooks_types: Optional[List[:class:`str`]] List of webhook event types to subscribe to. Pass ``None`` to clear. @@ -537,7 +536,7 @@ def icon(self) -> Asset | None: class AppInstallParams: """Represents the settings for the custom authorization URL of an application. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 Attributes ---------- @@ -556,7 +555,7 @@ def __init__(self, data: AppInstallParamsPayload) -> None: def _to_payload(self) -> dict[str, object]: """Serialize this object into an application install params payload. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 Returns ------- @@ -578,7 +577,7 @@ class IntegrationTypesConfig: This object is used to build the payload for the ``integration_types_config`` field when editing an application. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 Parameters ---------- @@ -600,7 +599,9 @@ def __init__( self.user = user @staticmethod - def _get_ctx(raw: dict | None, key: int): + def _get_ctx( + raw: dict[int | str, dict[str, object] | None] | None, key: int + ) -> dict[str, object] | None: if raw is None: return None if key in raw: @@ -609,7 +610,7 @@ def _get_ctx(raw: dict | None, key: int): return raw.get(skey) @staticmethod - def _decode_ctx(value: dict | None) -> AppInstallParams | None: + def _decode_ctx(value: dict[str, Any] | None) -> AppInstallParams | None: if value is None: return None params = value.get("oauth2_install_params") @@ -618,7 +619,9 @@ def _decode_ctx(value: dict | None) -> AppInstallParams | None: return AppInstallParams(params) @classmethod - def from_payload(cls, data: dict | None) -> IntegrationTypesConfig | None: + def from_payload( + cls, data: dict[int | str, dict[str, Any] | None] | None + ) -> IntegrationTypesConfig | None: if data is None: return None guild_ctx = cls._decode_ctx(cls._get_ctx(data, 0)) @@ -627,22 +630,22 @@ def from_payload(cls, data: dict | None) -> IntegrationTypesConfig | None: def _encode_install_params( self, value: AppInstallParams | None - ) -> dict[str, object] | None: + ) -> dict[str, dict[str, Any]] | None: if value is None: return None return {"oauth2_install_params": value._to_payload()} - def _to_payload(self) -> dict[int, dict[str, object] | None]: + def _to_payload(self) -> dict[int, dict[str, dict[str, Any]] | None]: """Serialize this configuration into the payload expected by the API. Returns ------- - Dict[int, Dict[str, object] | None] + Dict[int, Dict[str, Dict[str, Any]] | None] Mapping of integration context IDs to encoded install parameters, or ``None`` to clear. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 """ - payload: dict[int, dict[str, object] | None] = {} + payload: dict[int, dict[str, dict[str, Any]] | None] = {} if self.guild is not utils.MISSING: payload[0] = self._encode_install_params(self.guild) if self.user is not utils.MISSING: diff --git a/discord/enums.py b/discord/enums.py index 06fd7d4d25..9a44839adf 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1060,10 +1060,7 @@ class EntitlementOwnerType(Enum): class IntegrationType(Enum): - """The application's integration type. - - .. versionadded:: 2.7 - """ + """The application's integration type.""" guild_install = 0 user_install = 1 @@ -1138,9 +1135,9 @@ class SelectDefaultValueType(Enum): class ApplicationEventWebhookStatus(Enum): """Represents the application event webhook status.""" - DISABLED = 1 - ENABLED = 2 - DISABLED_BY_DISCORD = 3 + disabled = 1 + enabled = 2 + disabled_by_discord = 3 T = TypeVar("T") diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 3f15aa83b2..79a5a8552e 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2636,7 +2636,7 @@ of :class:`enum.Enum`. Represents the application event webhook status. - .. versionadded:: 2.7 + .. versionadded:: 2.7.1 .. attribute:: DISABLED From 94ea2c61e01166d8c51339c44ba1d15eda7be31b Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 30 Dec 2025 15:12:42 +0100 Subject: [PATCH 32/61] =?UTF-8?q?docs:=20=F0=9F=93=9D=20Update=20attribute?= =?UTF-8?q?=20names=20for=20ApplicationEventWebhookStatus=20to=20lowercase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/enums.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 7437a65053..43fe19ee0c 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2638,14 +2638,13 @@ of :class:`enum.Enum`. .. versionadded:: 2.7.1 - .. attribute:: DISABLED + .. attribute:: disabled The application webhook is disabled. - .. attribute:: ENABLED + .. attribute:: enabled The application webhook is enabled. - .. attribute:: DISABLED_BY_DISCORD - + .. attribute:: disabled_by_discord The application webhook has been disabled by Discord. From 4f7bbcc6ba9549b12625f5c4d98d3e88fe6c9ea8 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:26:30 +0100 Subject: [PATCH 33/61] Update discord/appinfo.py Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 8faec05065..49e7eaf25b 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -121,7 +121,7 @@ class AppInfo: approximate_user_install_count: Optional[:class:`int`] The approximate count of users who have installed the application, if any. - .. versionadded:: 2.7.1 + .. versionadded:: 2.7 redirect_uris: Optional[List[:class:`str`]] The list of redirect URIs for the application, if set. From 9afae378cce8fea20f8bfdb03a7967687e4b58f1 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:26:38 +0100 Subject: [PATCH 34/61] Update discord/appinfo.py Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 49e7eaf25b..e7b1dd5c63 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -126,7 +126,7 @@ class AppInfo: redirect_uris: Optional[List[:class:`str`]] The list of redirect URIs for the application, if set. - .. versionadded:: 2.7.1 + .. versionadded:: 2.7 interactions_endpoint_url: Optional[:class:`str`] The interactions endpoint URL for the application, if set. From b4998954ea4741157b3d6141c242ae00c2c16ca3 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:26:47 +0100 Subject: [PATCH 35/61] Update discord/appinfo.py Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index e7b1dd5c63..b7d9106c56 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -131,7 +131,7 @@ class AppInfo: interactions_endpoint_url: Optional[:class:`str`] The interactions endpoint URL for the application, if set. - .. versionadded:: 2.7.1 + .. versionadded:: 2.7 role_connections_verification_url: Optional[:class:`str`] The role connection verification URL for the application, if set. From 97e9ab8f72908704236eb8520108f67ea2fd5c84 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:26:55 +0100 Subject: [PATCH 36/61] Update discord/appinfo.py Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index b7d9106c56..5b75371e7b 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -116,7 +116,7 @@ class AppInfo: approximate_guild_count: Optional[:class:`int`] The approximate count of guilds to which the app has been added, if any. - .. versionadded:: 2.7.1 + .. versionadded:: 2.7 approximate_user_install_count: Optional[:class:`int`] The approximate count of users who have installed the application, if any. From d37080a14893a9949828b4eda168847c0c0e6a2c Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:27:02 +0100 Subject: [PATCH 37/61] Update discord/appinfo.py Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 5b75371e7b..7372a58175 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -136,7 +136,7 @@ class AppInfo: role_connections_verification_url: Optional[:class:`str`] The role connection verification URL for the application, if set. - .. versionadded:: 2.7.1 + .. versionadded:: 2.7 install_params: Optional[:class:`AppInstallParams`] The settings for the application's default in-app authorization link, if set. From c98a56bf0f8c79484564a1c44ffab6b6b7021923 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:41:16 +0100 Subject: [PATCH 38/61] Update discord/appinfo.py Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 7372a58175..836162c2d3 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -168,7 +168,7 @@ class AppInfo: Maximium of 5 tags. - .. versionadded:: 2.7.1 + .. versionadded:: 2.7 custom_install_url: Optional[:class:`str`] The default custom authorization URL for the application, if set. From fc3c7467df8942a14226a0a5e4e3fa807251d2fc Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:41:38 +0100 Subject: [PATCH 39/61] Update discord/appinfo.py Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 836162c2d3..9dab9b35b9 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -536,7 +536,7 @@ def icon(self) -> Asset | None: class AppInstallParams: """Represents the settings for the custom authorization URL of an application. - .. versionadded:: 2.7.1 + .. versionadded:: 2.7 Attributes ---------- From 587921afc074bcf7b55a410a568b57cd9733eef9 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:41:56 +0100 Subject: [PATCH 40/61] Update discord/appinfo.py Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 9dab9b35b9..6e9e729cc7 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -173,7 +173,7 @@ class AppInfo: custom_install_url: Optional[:class:`str`] The default custom authorization URL for the application, if set. - .. versionadded:: 2.7.1 + .. versionadded:: 2.7 approximate_user_authorization_count: Optional[:class:`int`] The approximate count of users who have authorized the application, if any. From 558937200fb3eac7aa3fc4575b7522eca29e7b5c Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:13:10 +0100 Subject: [PATCH 41/61] Update enums.rst Co-authored-by: JustaSqu1d <89910983+JustaSqu1d@users.noreply.github.com> Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- docs/api/enums.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 43fe19ee0c..0dab8794ea 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2647,4 +2647,4 @@ of :class:`enum.Enum`. The application webhook is enabled. .. attribute:: disabled_by_discord - The application webhook has been disabled by Discord. + The application webhook is disabled by Discord. From e87195279aa25ece1192005051aa826f88214f7a Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:13:23 +0100 Subject: [PATCH 42/61] Update CHANGELOG.md Co-authored-by: Paillat Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c45bc2dc6d..fd43f59297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ These changes are available on the `master` branch, but have not yet been releas - Added `Attachment.read_chunked` and added optional `chunksize` argument to `Attachment.save` for retrieving attachments in chunks. ([#2956](https://github.com/Pycord-Development/pycord/pull/2956)) -- Added `AppInfo.edit()` method and missing `AppInfo` fields. +- Added `AppInfo.edit()` method and missing `AppInfo` attributes. ([#2994](https://github.com/Pycord-Development/pycord/pull/2994)) - Added `Guild.fetch_roles_member_counts` method and `GuildRoleCounts` class. ([#3020](https://github.com/Pycord-Development/pycord/pull/3020)) From 80b65cbde92683929b5af765937dc95d18ca0284 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:13:52 +0100 Subject: [PATCH 43/61] Update appinfo.py Co-authored-by: Paillat Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/appinfo.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 6e9e729cc7..a8af071755 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -141,7 +141,8 @@ class AppInfo: install_params: Optional[:class:`AppInstallParams`] The settings for the application's default in-app authorization link, if set. - .. versionadded:: 2.7.1 + .. versionchanged:: 2.7.1 + Fixed incorrect type documentation. integration_types_config: Optional[:class:`IntegrationTypesConfig`] Per-installation context configuration for guild (``0``) and user (``1``) contexts. From 8f5a9cde0bde6301174b9df4c94af95714103264 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:14:33 +0000 Subject: [PATCH 44/61] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index a8af071755..d1637ec65b 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -142,7 +142,7 @@ class AppInfo: The settings for the application's default in-app authorization link, if set. .. versionchanged:: 2.7.1 - Fixed incorrect type documentation. + Fixed incorrect type documentation. integration_types_config: Optional[:class:`IntegrationTypesConfig`] Per-installation context configuration for guild (``0``) and user (``1``) contexts. From c90e24efb3e8714bbed9f35bb568e7977b9981de Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:49:59 +0100 Subject: [PATCH 45/61] fix(appinfo): update versionadded annotations from 2.7.1 to 2.8 --- discord/appinfo.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index d1637ec65b..c4d55c3b8a 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -147,22 +147,22 @@ class AppInfo: integration_types_config: Optional[:class:`IntegrationTypesConfig`] Per-installation context configuration for guild (``0``) and user (``1``) contexts. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 event_webhooks_url: Optional[:class:`str`] The URL used to receive application event webhooks, if set. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 event_webhooks_status: Optional[:class:`ApplicationEventWebhookStatus`] The status of event webhooks for the application, if set. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 event_webhooks_types: Optional[List[:class:`str`]] List of event webhook types subscribed to, if set. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 tags: Optional[List[:class:`str`]] The list of tags describing the content and functionality of the app, if set. @@ -179,12 +179,12 @@ class AppInfo: approximate_user_authorization_count: Optional[:class:`int`] The approximate count of users who have authorized the application, if any. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 bot: Optional[:class:`User`] The bot user associated with this application, if any. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 """ __slots__ = ( @@ -305,7 +305,7 @@ def flags(self) -> ApplicationFlags | None: Returns an :class:`ApplicationFlags` instance or ``None`` when not present. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 """ if self._flags is None: return None @@ -334,7 +334,7 @@ async def edit( Edit the current application's settings. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 Parameters ---------- @@ -556,7 +556,7 @@ def __init__(self, data: AppInstallParamsPayload) -> None: def _to_payload(self) -> dict[str, object]: """Serialize this object into an application install params payload. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 Returns ------- @@ -578,7 +578,7 @@ class IntegrationTypesConfig: This object is used to build the payload for the ``integration_types_config`` field when editing an application. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 Parameters ---------- @@ -644,7 +644,7 @@ def _to_payload(self) -> dict[int, dict[str, dict[str, Any]] | None]: Dict[int, Dict[str, Dict[str, Any]] | None] Mapping of integration context IDs to encoded install parameters, or ``None`` to clear. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 """ payload: dict[int, dict[str, dict[str, Any]] | None] = {} if self.guild is not utils.MISSING: From 6b4f274c08f5412bd83099ac3760a29253bfa1ee Mon Sep 17 00:00:00 2001 From: Ice Wolfy Date: Mon, 16 Feb 2026 22:02:58 -0500 Subject: [PATCH 46/61] fix: Apply Suggestions From Code Review --- discord/bot.py | 317 +++++++++++++++++++++++++------------------------ 1 file changed, 162 insertions(+), 155 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index cc7216cd45..b5260e4ab3 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -42,9 +42,13 @@ Generator, Literal, Mapping, + TypeAlias, TypeVar, + Union, ) +from typing_extensions import override + from .client import Client from .cog import CogMixin from .commands import ( @@ -80,6 +84,160 @@ _log = logging.getLogger(__name__) +class DefaultComparison: + """ + Comparison rule for when there are multiple default values that should be considered equivalent when comparing 2 objects. + Allows for a custom check to be passed for further control over equality. + + Attributes + ---------- + defaults: :class:`tuple` + The values that should be considered equivalent to each other + callback: Callable[[Any, Any], bool] + A callable that will do additional comparison on the objects if neither are a default value. + Defaults to a `!=` comparison. + It should accept the 2 objects as arguments and return True if they should be considered equivalent + and False otherwise. + """ + + def __init__( + self, + defaults: tuple[Any, ...], + callback: Callable[[Any, Any], bool] = lambda x, y: x != y, + ): + self.defaults = defaults + self.callback = callback + + def _check_defaults(self, local, remote) -> bool | None: + defaults = (local in self.defaults) + (remote in self.defaults) + if defaults == 2: + # Both are COMMAND_DEFAULTS, so they can be counted as the same + return False + elif defaults == 0: + # Neither are COMMAND_DEFAULTS so the callback has to be used + return None + else: + # Only one is a default, so the command must be out of sync + return True + + def check(self, local, remote) -> bool: + if (rtn := self._check_defaults(local, remote)) is not None: + return rtn + else: + return self.callback(local, remote) + + +class DefaultSetComparison(DefaultComparison): + @override + def check(self, local, remote) -> bool: + try: + local = set(local) + except TypeError: + pass + try: + remote = set(remote) + except TypeError: + pass + return super().check(local, remote) + + +NestedComparison: TypeAlias = dict[str, Union["NestedComparison", DefaultComparison]] + + +def _compare_defaults( + obj: Mapping[str, Any] | Any, + match: Mapping[str, Any] | Any, + schema: NestedComparison, +) -> bool: + if not isinstance(match, Mapping) or not isinstance(obj, Mapping): + return obj != match + for field, comparison in schema.items(): + remote = match.get(field, MISSING) + local = obj.get(field, MISSING) + if isinstance(comparison, dict): + _compare_defaults(local, remote, comparison) + elif isinstance(comparison, DefaultComparison): + if comparison.check(local, remote): + return True + return False + + +option_default_values = ([], MISSING) + + +def _option_comparison_check(local, remote) -> bool: + matching = (local in option_default_values) + (remote in option_default_values) + if matching == 2: + return False + elif matching == 1: + return True + else: + return len(local) != len(remote) or any( + [ + _compare_defaults(local[x], remote[x], COMMAND_OPTION_DEFAULTS) + for x in range(len(local)) + ] + ) + + +choices_default_values = ([], MISSING) + + +def _choices_comparison_check(local, remote) -> bool: + matching = (local in choices_default_values) + (remote in choices_default_values) + if matching == 2: + return False + elif matching == 1: + return True + else: + return len(local) != len(remote) or any( + [ + _compare_defaults(local[x], remote[x], OPTIONS_CHOICES_DEFAULTS) + for x in range(len(local)) + ] + ) + + +COMMAND_DEFAULTS: NestedComparison = { + "type": DefaultComparison((1, MISSING)), + "name": DefaultComparison(()), + "description": DefaultComparison((MISSING,)), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "description_localizations": DefaultComparison((None, {}, MISSING)), + "options": DefaultComparison(option_default_values, _option_comparison_check), + "default_member_permissions": DefaultComparison((None, MISSING)), + "nsfw": DefaultComparison((False, MISSING)), + # TODO: Change the below default if needed to use the correct default integration types and contexts + "integration_types": DefaultSetComparison( + (MISSING, {0, 1}), lambda x, y: set(x) != set(y) + ), + # Discord States That This Defaults To "your app's configured contexts" + "contexts": DefaultSetComparison( + (None, {0, 1, 2}, MISSING), lambda x, y: set(x) != set(y) + ), +} +COMMAND_OPTION_DEFAULTS: NestedComparison = { + "type": DefaultComparison(()), + "name": DefaultComparison(()), + "description": DefaultComparison(()), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "description_localizations": DefaultComparison((None, {}, MISSING)), + "required": DefaultComparison((False, MISSING)), + "choices": DefaultComparison(choices_default_values, _choices_comparison_check), + "channel_types": DefaultComparison(([], MISSING)), + "min_value": DefaultComparison((MISSING,)), + "max_value": DefaultComparison((MISSING,)), + "min_length": DefaultComparison((MISSING,)), + "max_length": DefaultComparison((MISSING,)), + "autocomplete": DefaultComparison((MISSING, False)), +} +OPTIONS_CHOICES_DEFAULTS: NestedComparison = { + "name": DefaultComparison(()), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "value": DefaultComparison(()), +} + + class ApplicationCommandMixin(ABC): """A mixin that implements common functionality for classes that need application command compatibility. @@ -270,161 +428,9 @@ async def get_desynced_commands( """ # We can suggest the user to upsert, edit, delete, or bulk upsert the commands - class DefaultComparison: - """ - Comparison rule for when there are multiple default values that should be considered equivalent when comparing 2 objects. - Allows for a custom check to be passed for further control over equality. - - Attributes - ---------- - defaults: :class:`tuple` - The values that should be considered equivalent to each other - callback: Callable[[Any, Any], bool] - A callable that will do additional comparison on the objects if neither are a default value. - Defaults to a `!=` comparison. - It should accept the 2 objects as arguments and return True if they should be considered equivalent - and False otherwise. - """ - - def __init__( - self, - defaults: tuple[Any, ...], - callback: Callable[[Any, Any], bool] = lambda x, y: x != y, - ): - self.defaults = defaults - self.callback = callback - - def _check_defaults(self, local, remote) -> bool | None: - defaults = (local in self.defaults) + (remote in self.defaults) - if defaults == 2: - # Both are defaults, so they can be counted as the same - return False - elif defaults == 0: - # Neither are defaults so the callback has to be used - return None - else: - # Only one is a default, so the command must be out of sync - return True - - def check(self, local, remote) -> bool: - if (rtn := self._check_defaults(local, remote)) is not None: - return rtn - else: - return self.callback(local, remote) - - class DefaultSetComparison(DefaultComparison): - def check(self, local, remote) -> bool: - try: - local = set(local) - except TypeError: - pass - try: - remote = set(remote) - except TypeError: - pass - return super().check(local, remote) - - type NestedComparison = dict[str, NestedComparison | DefaultComparison] - - def _compare_defaults( - obj: Mapping[str, Any] | Any, - match: Mapping[str, Any] | Any, - schema: NestedComparison, - ) -> bool: - if not isinstance(match, Mapping) or not isinstance(obj, Mapping): - return obj != match - for field, comparison in schema.items(): - remote = match.get(field, MISSING) - local = obj.get(field, MISSING) - if isinstance(comparison, dict): - _compare_defaults(local, remote, comparison) - elif isinstance(comparison, DefaultComparison): - if comparison.check(local, remote): - return True - return False - def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: cmd = cmd.to_dict() - option_default_values = ([], MISSING) - - def _option_comparison_check(local, remote) -> bool: - matching = (local in option_default_values) + ( - remote in option_default_values - ) - if matching == 2: - return False - elif matching == 1: - return True - else: - return len(local) != len(remote) or any( - [ - _compare_defaults(local[x], remote[x], option_defaults) - for x in range(len(local)) - ] - ) - - choices_default_values = ([], MISSING) - - def _choices_comparison_check(local, remote) -> bool: - matching = (local in choices_default_values) + ( - remote in choices_default_values - ) - if matching == 2: - return False - elif matching == 1: - return True - else: - return len(local) != len(remote) or any( - [ - _compare_defaults(local[x], remote[x], choices_defaults) - for x in range(len(local)) - ] - ) - - defaults: NestedComparison = { - "type": DefaultComparison((1, MISSING)), - "name": DefaultComparison(()), - "description": DefaultComparison((MISSING,)), - "name_localizations": DefaultComparison((None, {}, MISSING)), - "description_localizations": DefaultComparison((None, {}, MISSING)), - "options": DefaultComparison( - option_default_values, _option_comparison_check - ), - "default_member_permissions": DefaultComparison((None, MISSING)), - "nsfw": DefaultComparison((False, MISSING)), - # TODO: Change the below default if needed to use the correct default integration types and contexts - "integration_types": DefaultSetComparison( - (MISSING, {0, 1}), lambda x, y: set(x) != set(y) - ), - # Discord States That This Defaults To "your app's configured contexts" - "contexts": DefaultSetComparison( - (None, {0, 1, 2}, MISSING), lambda x, y: set(x) != set(y) - ), - } - option_defaults: NestedComparison = { - "type": DefaultComparison(()), - "name": DefaultComparison(()), - "description": DefaultComparison(()), - "name_localizations": DefaultComparison((None, {}, MISSING)), - "description_localizations": DefaultComparison((None, {}, MISSING)), - "required": DefaultComparison((False, MISSING)), - "choices": DefaultComparison( - choices_default_values, _choices_comparison_check - ), - "channel_types": DefaultComparison(([], MISSING)), - "min_value": DefaultComparison((MISSING,)), - "max_value": DefaultComparison((MISSING,)), - "min_length": DefaultComparison((MISSING,)), - "max_length": DefaultComparison((MISSING,)), - "autocomplete": DefaultComparison((MISSING, False)), - } - choices_defaults: NestedComparison = { - "name": DefaultComparison(()), - "name_localizations": DefaultComparison((None, {}, MISSING)), - "value": DefaultComparison(()), - } - if isinstance(cmd, SlashCommandGroup): if len(cmd.subcommands) != len(match.get("options", [])): return True @@ -435,7 +441,7 @@ def _choices_comparison_check(local, remote) -> bool: if match_ is not None and _check_command(subcommand, match_): return True else: - return _compare_defaults(cmd, match, defaults) + return _compare_defaults(cmd, match, COMMAND_DEFAULTS) return_value = [] cmds = self.pending_application_commands.copy() @@ -466,9 +472,10 @@ def _choices_comparison_check(local, remote) -> bool: # First let's check if the commands we have locally are the same as the ones on discord for cmd in pending: match = registered_commands_dict.get(cmd.name) + # We don't have this command registered if match is None: - # We don't have this command registered return_value.append({"command": cmd, "action": "upsert"}) + # We have a different version of the command then Discord elif _check_command(cmd, match): return_value.append( { @@ -478,7 +485,7 @@ def _choices_comparison_check(local, remote) -> bool: } ) else: - # We have this command registered but it's the same + # We have this command registered and it's the same return_value.append( {"command": cmd, "action": None, "id": int(match["id"])} ) From 8a5e7f0a4ec237a9aa12a95e00e4ea3504480c7b Mon Sep 17 00:00:00 2001 From: Ice Wolfy Date: Sat, 21 Feb 2026 23:39:43 -0500 Subject: [PATCH 47/61] refactor: Apply Suggestions From Code Review --- discord/bot.py | 71 +++++++++++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index 1ce1a48ea8..e6e542b6fa 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -96,7 +96,7 @@ class DefaultComparison: The values that should be considered equivalent to each other callback: Callable[[Any, Any], bool] A callable that will do additional comparison on the objects if neither are a default value. - Defaults to a `!=` comparison. + Defaults to a `==` comparison. It should accept the 2 objects as arguments and return True if they should be considered equivalent and False otherwise. """ @@ -104,7 +104,7 @@ class DefaultComparison: def __init__( self, defaults: tuple[Any, ...], - callback: Callable[[Any, Any], bool] = lambda x, y: x != y, + callback: Callable[[Any, Any], bool] = lambda x, y: x == y, ): self.defaults = defaults self.callback = callback @@ -113,15 +113,23 @@ def _check_defaults(self, local, remote) -> bool | None: defaults = (local in self.defaults) + (remote in self.defaults) if defaults == 2: # Both are COMMAND_DEFAULTS, so they can be counted as the same - return False + return True elif defaults == 0: # Neither are COMMAND_DEFAULTS so the callback has to be used return None else: # Only one is a default, so the command must be out of sync - return True + return False def check(self, local, remote) -> bool: + """ + Compares the local and remote objects. + + Returns + ------- + bool + True if local and remote are deemed to be equivalent. False otherwise. + """ if (rtn := self._check_defaults(local, remote)) is not None: return rtn else: @@ -151,29 +159,29 @@ def _compare_defaults( schema: NestedComparison, ) -> bool: if not isinstance(match, Mapping) or not isinstance(obj, Mapping): - return obj != match + return obj == match for field, comparison in schema.items(): remote = match.get(field, MISSING) local = obj.get(field, MISSING) if isinstance(comparison, dict): _compare_defaults(local, remote, comparison) elif isinstance(comparison, DefaultComparison): - if comparison.check(local, remote): - return True - return False + if not comparison.check(local, remote): + return False + return True -option_default_values = ([], MISSING) +OPTION_DEFAULT_VALUES = ([], MISSING) def _option_comparison_check(local, remote) -> bool: - matching = (local in option_default_values) + (remote in option_default_values) + matching = (local in OPTION_DEFAULT_VALUES) + (remote in OPTION_DEFAULT_VALUES) if matching == 2: - return False - elif matching == 1: return True + elif matching == 1: + return False else: - return len(local) != len(remote) or any( + return len(local) == len(remote) and all( [ _compare_defaults(local[x], remote[x], COMMAND_OPTION_DEFAULTS) for x in range(len(local)) @@ -181,17 +189,17 @@ def _option_comparison_check(local, remote) -> bool: ) -choices_default_values = ([], MISSING) +CHOICES_DEFAULT_VALUES = ([], MISSING) def _choices_comparison_check(local, remote) -> bool: - matching = (local in choices_default_values) + (remote in choices_default_values) + matching = (local in CHOICES_DEFAULT_VALUES) + (remote in CHOICES_DEFAULT_VALUES) if matching == 2: - return False - elif matching == 1: return True + elif matching == 1: + return False else: - return len(local) != len(remote) or any( + return len(local) == len(remote) and all( [ _compare_defaults(local[x], remote[x], OPTIONS_CHOICES_DEFAULTS) for x in range(len(local)) @@ -205,17 +213,15 @@ def _choices_comparison_check(local, remote) -> bool: "description": DefaultComparison((MISSING,)), "name_localizations": DefaultComparison((None, {}, MISSING)), "description_localizations": DefaultComparison((None, {}, MISSING)), - "options": DefaultComparison(option_default_values, _option_comparison_check), + "options": DefaultComparison(OPTION_DEFAULT_VALUES, _option_comparison_check), "default_member_permissions": DefaultComparison((None, MISSING)), "nsfw": DefaultComparison((False, MISSING)), - # TODO: Change the below default if needed to use the correct default integration types and contexts - "integration_types": DefaultSetComparison( - (MISSING, {0, 1}), lambda x, y: set(x) != set(y) - ), + # TODO: Change the below default if needed to use the correct default integration types # Discord States That This Defaults To "your app's configured contexts" - "contexts": DefaultSetComparison( - (None, {0, 1, 2}, MISSING), lambda x, y: set(x) != set(y) + "integration_types": DefaultSetComparison( + (MISSING, {0, 1}), lambda x, y: set(x) == set(y) ), + "contexts": DefaultSetComparison((None, MISSING), lambda x, y: set(x) == set(y)), } COMMAND_OPTION_DEFAULTS: NestedComparison = { "type": DefaultComparison(()), @@ -224,7 +230,7 @@ def _choices_comparison_check(local, remote) -> bool: "name_localizations": DefaultComparison((None, {}, MISSING)), "description_localizations": DefaultComparison((None, {}, MISSING)), "required": DefaultComparison((False, MISSING)), - "choices": DefaultComparison(choices_default_values, _choices_comparison_check), + "choices": DefaultComparison(CHOICES_DEFAULT_VALUES, _choices_comparison_check), "channel_types": DefaultComparison(([], MISSING)), "min_value": DefaultComparison((MISSING,)), "max_value": DefaultComparison((MISSING,)), @@ -430,19 +436,18 @@ async def get_desynced_commands( # We can suggest the user to upsert, edit, delete, or bulk upsert the commands def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: - cmd = cmd.to_dict() - + """Returns True If Commands Are Equivalent""" if isinstance(cmd, SlashCommandGroup): if len(cmd.subcommands) != len(match.get("options", [])): - return True + return False for i, subcommand in enumerate(cmd.subcommands): match_ = find( lambda x: x["name"] == subcommand.name, match["options"] ) - if match_ is not None and _check_command(subcommand, match_): - return True + if match_ is not None and not _check_command(subcommand, match_): + return False else: - return _compare_defaults(cmd, match, COMMAND_DEFAULTS) + return _compare_defaults(cmd.to_dict(), match, COMMAND_DEFAULTS) return_value = [] cmds = self.pending_application_commands.copy() @@ -477,7 +482,7 @@ def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: if match is None: return_value.append({"command": cmd, "action": "upsert"}) # We have a different version of the command then Discord - elif _check_command(cmd, match): + elif not _check_command(cmd, match): return_value.append( { "command": cmd, From 66292d868ee373e6396275ce914d993aa8fd2412 Mon Sep 17 00:00:00 2001 From: Ice Wolfy Date: Sun, 22 Feb 2026 17:06:36 -0500 Subject: [PATCH 48/61] fix: Subcommand Checking --- discord/bot.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index e6e542b6fa..1d5e811234 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -164,7 +164,8 @@ def _compare_defaults( remote = match.get(field, MISSING) local = obj.get(field, MISSING) if isinstance(comparison, dict): - _compare_defaults(local, remote, comparison) + if not _compare_defaults(local, remote, comparison): + return False elif isinstance(comparison, DefaultComparison): if not comparison.check(local, remote): return False @@ -223,6 +224,14 @@ def _choices_comparison_check(local, remote) -> bool: ), "contexts": DefaultSetComparison((None, MISSING), lambda x, y: set(x) == set(y)), } +SUBCOMMAND_DEFAULTS: NestedComparison = { + "type": DefaultComparison(()), + "name": DefaultComparison(()), + "description": DefaultComparison(()), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "description_localizations": DefaultComparison((None, {}, MISSING)), + "options": DefaultComparison(OPTION_DEFAULT_VALUES, _option_comparison_check), +} COMMAND_OPTION_DEFAULTS: NestedComparison = { "type": DefaultComparison(()), "name": DefaultComparison(()), @@ -444,10 +453,13 @@ def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: match_ = find( lambda x: x["name"] == subcommand.name, match["options"] ) - if match_ is not None and not _check_command(subcommand, match_): - return False + if match_ is not None: + return _check_command(subcommand, match_) else: - return _compare_defaults(cmd.to_dict(), match, COMMAND_DEFAULTS) + if cmd.parent is None: + return _compare_defaults(cmd.to_dict(), match, COMMAND_DEFAULTS) + else: + return _compare_defaults(cmd.to_dict(), match, SUBCOMMAND_DEFAULTS) return_value = [] cmds = self.pending_application_commands.copy() From b8b75b9cd42423e2677b0f24d3257b4e19684493 Mon Sep 17 00:00:00 2001 From: Ice Wolfy Date: Sun, 22 Feb 2026 17:12:46 -0500 Subject: [PATCH 49/61] refactor: Use Pipe Union --- discord/bot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index 1d5e811234..a818b2f831 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -44,7 +44,6 @@ Mapping, TypeAlias, TypeVar, - Union, ) from typing_extensions import override @@ -150,7 +149,7 @@ def check(self, local, remote) -> bool: return super().check(local, remote) -NestedComparison: TypeAlias = dict[str, Union["NestedComparison", DefaultComparison]] +NestedComparison: TypeAlias = dict[str, "NestedComparison | DefaultComparison"] def _compare_defaults( From 90e69d4376aab436faa556d0081060e6394cf54f Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 23 Feb 2026 09:50:40 +0100 Subject: [PATCH 50/61] Update docs/api/enums.rst Signed-off-by: Paillat --- docs/api/enums.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 0dab8794ea..4c30e45663 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2636,7 +2636,7 @@ of :class:`enum.Enum`. Represents the application event webhook status. - .. versionadded:: 2.7.1 + .. versionadded:: 2.8 .. attribute:: disabled From 988788a858a95c2d38df374c7338539c3044ecee Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 23 Feb 2026 09:50:50 +0100 Subject: [PATCH 51/61] Update discord/appinfo.py Signed-off-by: Paillat --- discord/appinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index c4d55c3b8a..7d84ab8e48 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -141,7 +141,7 @@ class AppInfo: install_params: Optional[:class:`AppInstallParams`] The settings for the application's default in-app authorization link, if set. - .. versionchanged:: 2.7.1 + .. versionchanged:: 2.8 Fixed incorrect type documentation. integration_types_config: Optional[:class:`IntegrationTypesConfig`] From b3864c9aaaabf5e3975b4eb6410f54ae868f253f Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 28 Feb 2026 18:12:43 +0100 Subject: [PATCH 52/61] refactor: :wrench: New test setup and modernize dev installl (#3128) * refactor: :fire: Remove legacy test files and migrate dev dependencies to `pyproject.toml` * test: :white_check_mark: Add dummy test file * chore: :page_facing_up: License --- .github/workflows/lib-checks.yml | 79 +++++++++------ MANIFEST.in | 1 - pyproject.toml | 35 ++++++- requirements/all.txt | 1 - requirements/dev.txt | 11 --- tests/{helpers.py => test_dummy.py} | 9 +- tests/test_typing_annotated.py | 124 ------------------------ tests/test_utils.py | 145 ---------------------------- 8 files changed, 86 insertions(+), 319 deletions(-) delete mode 100644 requirements/dev.txt rename tests/{helpers.py => test_dummy.py} (89%) delete mode 100644 tests/test_typing_annotated.py delete mode 100644 tests/test_utils.py diff --git a/.github/workflows/lib-checks.yml b/.github/workflows/lib-checks.yml index 1bdf77f950..7bbcbfb177 100644 --- a/.github/workflows/lib-checks.yml +++ b/.github/workflows/lib-checks.yml @@ -38,11 +38,11 @@ jobs: with: python-version: "3.13" cache: "pip" - cache-dependency-path: "requirements/dev.txt" + cache-dependency-path: "pyproject.toml" - name: "Install dependencies" run: | python -m pip install --upgrade pip - pip install -r requirements/dev.txt + pip install . --group dev - name: "Run codespell" run: codespell --ignore-words-list="groupt,nd,ot,ro,falsy,BU" \ @@ -58,11 +58,11 @@ jobs: with: python-version: "3.13" cache: "pip" - cache-dependency-path: "requirements/dev.txt" + cache-dependency-path: "pyproject.toml" - name: "Install dependencies" run: | python -m pip install --upgrade pip - pip install -r requirements/dev.txt + pip install . --group dev - name: "Run bandit" run: bandit --recursive --skip B101,B104,B105,B110,B307,B311,B404,B603,B607 . pylint: @@ -76,11 +76,11 @@ jobs: with: python-version: "3.13" cache: "pip" - cache-dependency-path: "requirements/dev.txt" + cache-dependency-path: "pyproject.toml" - name: "Install dependencies" run: | python -m pip install --upgrade pip - pip install -r requirements/dev.txt + pip install . --group dev - name: "Setup cache" id: cache-pylint uses: actions/cache@v5 @@ -100,11 +100,11 @@ jobs: with: python-version: "3.13" cache: "pip" - cache-dependency-path: "requirements/dev.txt" + cache-dependency-path: "pyproject.toml" - name: "Install dependencies" run: | python -m pip install --upgrade pip - pip install -r requirements/dev.txt + pip install . --group dev - name: "Setup cache" id: cache-mypy uses: actions/cache@v5 @@ -116,39 +116,58 @@ jobs: run: mkdir -p -v .mypy_cache - name: "Run mypy" run: mypy --non-interactive discord/ - pytest: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] - runs-on: ${{ matrix.os }} - env: - OS: ${{ matrix.os }} - PYTHON: ${{ matrix.python-version }} + flake8: + runs-on: ubuntu-latest steps: - name: "Checkout Repository" uses: actions/checkout@v6 - name: "Setup Python" uses: actions/setup-python@v6 with: - python-version: ${{ matrix.python-version }} + python-version: "3.13" cache: "pip" - cache-dependency-path: "requirements/dev.txt" - check-latest: true + cache-dependency-path: "pyproject.toml" - name: "Install dependencies" run: | python -m pip install --upgrade pip - pip install flake8 - pip install -r requirements/dev.txt - - name: "Setup cache" - id: cache-pytest - uses: actions/cache@v5 - with: - path: .pytest_cache - key: ${{ matrix.os }}-${{ matrix.python-version }}-pytest + pip install . --group dev - name: "Lint with flake8" run: | - # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics + tests: + if: ${{ github.event_name != 'schedule' }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - name: "Checkout Repository" + uses: actions/checkout@v6 + + - name: "Setup Python" + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: "pip" + cache-dependency-path: "pyproject.toml" + + - name: "Install dependencies" + run: | + python -m pip install --upgrade pip + pip install . --group dev + + - name: "Run tests" + run: tox + tests-pass: # ref: https://github.com/orgs/community/discussions/4324#discussioncomment-3477871 + runs-on: ubuntu-latest + needs: [tests] + if: always() && github.event_name != 'schedule' + steps: + - name: Tests succeeded + if: ${{ !(contains(needs.*.result, 'failure')) }} + run: exit 0 + - name: Tests failed + if: ${{ contains(needs.*.result, 'failure') }} + run: exit 1 diff --git a/MANIFEST.in b/MANIFEST.in index 7073c80050..81785135cf 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -17,4 +17,3 @@ exclude .prettierrc exclude .readthedocs.yml exclude CHANGELOG.md exclude FUNDING.yml -exclude requirements-dev.txt diff --git a/pyproject.toml b/pyproject.toml index 270485e36a..df166195bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,22 @@ docs = { file = "requirements/docs.txt" } speed = { file = "requirements/speed.txt" } voice = { file = "requirements/voice.txt" } +[dependency-groups] +dev = [ + "pylint~=3.3.8", + "mypy~=1.19.1", + "coverage~=7.10", + "pre-commit==4.5.1", + "codespell==2.4.1", + "bandit==1.9.4", + "flake8==7.3.0", + "tox>=4.27.0", + "tox-gh>=1.5.0" +] +test = [ + "pytest~=9.0.1", + "pytest-asyncio~=1.3.0", +] [tool.setuptools_scm] [tool.black] @@ -102,5 +118,22 @@ disable = [ indent-string = ' ' max-line-length = 120 -[tool.pytest.ini_options] +[tool.pytest] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.tox] +requires = ["tox>=4"] +env_list = ["3.13", "3.12", "3.11", "3.10"] + +[tool.tox.env_run_base] +description = "run unit tests" +commands = [["pytest", { replace = "posargs", default = ["tests"], extend = true }]] +dependency_groups = ["test"] + +# GitHub actions +[tool.tox.gh.python] +"3.13" = ["3.13"] +"3.12" = ["3.12"] +"3.11" = ["3.11"] +"3.10" = ["3.10"] diff --git a/requirements/all.txt b/requirements/all.txt index 6cc0db2902..c26386305c 100644 --- a/requirements/all.txt +++ b/requirements/all.txt @@ -1,4 +1,3 @@ --r dev.txt -r docs.txt -r speed.txt -r voice.txt diff --git a/requirements/dev.txt b/requirements/dev.txt deleted file mode 100644 index 2e79c56fdf..0000000000 --- a/requirements/dev.txt +++ /dev/null @@ -1,11 +0,0 @@ --r _.txt -pylint~=3.3.8 -pytest~=9.0.1 -pytest-asyncio~=1.3.0 -# pytest-order~=1.0.1 -mypy~=1.19.1 -coverage~=7.10 -pre-commit==4.5.1 -codespell==2.4.1 -bandit==1.9.4 -flake8==7.3.0 diff --git a/tests/helpers.py b/tests/test_dummy.py similarity index 89% rename from tests/helpers.py rename to tests/test_dummy.py index 8b62271a5a..9218fb9fb8 100644 --- a/tests/helpers.py +++ b/tests/test_dummy.py @@ -1,7 +1,6 @@ """ The MIT License (MIT) -Copyright (c) 2015-2021 Rapptz Copyright (c) 2021-present Pycord Development Permission is hereby granted, free of charge, to any person obtaining a @@ -23,10 +22,8 @@ DEALINGS IN THE SOFTWARE. """ -from typing import TypeVar +import discord -V = TypeVar("V") - -async def coroutine(val: V) -> V: - return val +def test_dummy(): + assert True diff --git a/tests/test_typing_annotated.py b/tests/test_typing_annotated.py deleted file mode 100644 index 7091a241ff..0000000000 --- a/tests/test_typing_annotated.py +++ /dev/null @@ -1,124 +0,0 @@ -from typing import Optional - -from typing_extensions import Annotated - -import discord -from discord import SlashCommandOptionType -from discord.commands.core import SlashCommand, slash_command - - -def test_typing_annotated(): - async def echo(ctx, txt: Annotated[str, discord.Option()]): - await ctx.respond(txt) - - cmd = SlashCommand(echo) - bot = discord.Bot() - bot.add_application_command(cmd) - dict_result = cmd.to_dict() - assert ( - dict_result.get("options")[0].get("type") == SlashCommandOptionType.string.value - ) - - -def test_typing_annotated_decorator(): - bot = discord.Bot() - - @bot.slash_command() - async def echo(ctx, txt: Annotated[str, discord.Option(description="Some text")]): - await ctx.respond(txt) - - dict_result = echo.to_dict() - - option = dict_result.get("options")[0] - assert option.get("type") == SlashCommandOptionType.string.value - assert option.get("description") == "Some text" - - -def test_typing_annotated_cog(): - class echoCog(discord.Cog): - def __init__(self, bot_) -> None: - self.bot = bot_ - super().__init__() - - @slash_command() - async def echo( - self, ctx, txt: Annotated[str, discord.Option(description="Some text")] - ): - await ctx.respond(txt) - - bot = discord.Bot() - cog = echoCog(bot) - bot.add_cog(cog) - - dict_result = cog.echo.to_dict() - - option = dict_result.get("options")[0] - assert option.get("type") == SlashCommandOptionType.string.value - assert option.get("description") == "Some text" - - -def test_typing_annotated_cog_slashgroup(): - class echoCog(discord.Cog): - grp = discord.commands.SlashCommandGroup("echo") - - def __init__(self, bot_) -> None: - self.bot = bot_ - super().__init__() - - @grp.command() - async def echo( - self, ctx, txt: Annotated[str, discord.Option(description="Some text")] - ): - await ctx.respond(txt) - - bot = discord.Bot() - cog = echoCog(bot) - bot.add_cog(cog) - - dict_result = cog.echo.to_dict() - - option = dict_result.get("options")[0] - assert option.get("type") == SlashCommandOptionType.string.value - assert option.get("description") == "Some text" - - -def test_typing_annotated_optional(): - async def echo(ctx, txt: Annotated[Optional[str], discord.Option()]): - await ctx.respond(txt) - - cmd = SlashCommand(echo) - bot = discord.Bot() - bot.add_application_command(cmd) - - dict_result = cmd.to_dict() - - option = dict_result.get("options")[0] - assert option.get("type") == SlashCommandOptionType.string.value - - -def test_no_annotation(): - async def echo(ctx, txt: str): - await ctx.respond(txt) - - cmd = SlashCommand(echo) - bot = discord.Bot() - bot.add_application_command(cmd) - - dict_result = cmd.to_dict() - - option = dict_result.get("options")[0] - assert option.get("type") == SlashCommandOptionType.string.value - - -def test_annotated_no_option(): - async def echo(ctx, txt: Annotated[str, "..."]): - await ctx.respond(txt) - - cmd = SlashCommand(echo) - bot = discord.Bot() - bot.add_application_command(cmd) - - dict_result = cmd.to_dict() - - option = dict_result.get("options")[0] - assert option.get("type") == SlashCommandOptionType.string.value diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 86ccb3dfb2..0000000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz -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. -""" - -# mypy: implicit-reexport=True -from typing import TypeVar - -import pytest - -from discord.utils import ( - MISSING, - _parse_ratelimit_header, - _unique, - async_all, - copy_doc, - find, - get, - maybe_coroutine, - snowflake_time, - time_snowflake, - utcnow, -) - -from .helpers import coroutine - -A = TypeVar("A") -B = TypeVar("B") - - -def test_temporary(): - assert True - - -# def test_copy_doc() -> None: -# def foo(a: A, b: B) -> Tuple[A, B]: -# """ -# This is a test function. -# """ -# return a, b -# -# @copy_doc(foo) -# def bar(a, b): # type: ignore[no-untyped-def] -# return a, b -# -# assert bar.__doc__ == foo.__doc__ -# assert signature(bar) == signature(foo) -# -# -# def test_snowflake() -> None: -# now = utcnow().replace(microsecond=0) -# snowflake = time_snowflake(now) -# assert snowflake_time(snowflake) == now -# -# -# def test_missing() -> None: -# assert MISSING != object() -# assert not MISSING -# assert repr(MISSING) == '...' -# -# -# def test_find_get() -> None: -# class Obj: -# def __init__(self, value: int): -# self.value = value -# self.deep = self -# -# def __eq__(self, other: Any) -> bool: -# return isinstance(other, self.__class__) and self.value == other.value -# -# def __repr__(self) -> str: -# return f'' -# -# obj_list = [Obj(i) for i in range(10)] -# for i in range(11): -# for val in ( -# find(lambda o: o.value == i, obj_list), -# get(obj_list, value=i), -# get(obj_list, deep__value=i), -# get(obj_list, value=i, deep__value=i), -# ): -# if i >= len(obj_list): -# assert val is None -# else: -# assert val == Obj(i) -# -# -# def test_unique() -> None: -# values = [random.randint(0, 100) for _ in range(1000)] -# unique = _unique(values) -# unique.sort() -# assert unique == list(set(values)) -# -# -# @pytest.mark.parametrize('use_clock', (True, False)) -# @pytest.mark.parametrize('value', list(range(0, 100, random.randrange(5, 10)))) -# def test_parse_ratelimit_header(use_clock, value): # type: ignore[no-untyped-def] -# class RatelimitRequest: -# def __init__(self, reset_after: int): -# self.headers = { -# 'X-Ratelimit-Reset-After': reset_after, -# 'X-Ratelimit-Reset': (utcnow() + datetime.timedelta(seconds=reset_after)).timestamp(), -# } -# -# assert round(_parse_ratelimit_header(RatelimitRequest(value), use_clock=use_clock)) == value -# -# -# @pytest.mark.parametrize('value', range(5)) -# async def test_maybe_coroutine(value) -> None: # type: ignore[no-untyped-def] -# assert value == await maybe_coroutine(lambda v: v, value) -# assert value == await maybe_coroutine(coroutine, value) -# -# -# @pytest.mark.parametrize('size', list(range(10, 20))) -# @pytest.mark.filterwarnings("ignore:coroutine 'coroutine' was never awaited") -# async def test_async_all(size) -> None: # type: ignore[no-untyped-def] -# values = [] -# raw_values = [] -# -# for i in range(size): -# value = random.choices((True, False), (size - 1, 1))[0] -# raw_values.append(value) -# values.append(coroutine(value) if random.choice((True, False)) else value) -# -# assert all(raw_values) == await async_all(values) From e7d06702ca488c5735be92d9a904cfd0a3744465 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:36:37 +0100 Subject: [PATCH 53/61] fix(appinfo): set default value for flags and update flags property type --- discord/appinfo.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index 7d84ab8e48..add49d5719 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -265,7 +265,7 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self.approximate_user_authorization_count: int | None = data.get( "approximate_user_authorization_count" ) - self._flags: int | None = data.get("flags") + self._flags: int = data.get("flags", 0) self.redirect_uris: list[str] = data.get("redirect_uris", []) self.interactions_endpoint_url: str | None = data.get( "interactions_endpoint_url" @@ -300,15 +300,13 @@ def __repr__(self) -> str: ) @property - def flags(self) -> ApplicationFlags | None: - """The public application flags, if set. + def flags(self) -> ApplicationFlags: + """The public application flags. - Returns an :class:`ApplicationFlags` instance or ``None`` when not present. + Returns an :class:`ApplicationFlags` instance. .. versionadded:: 2.8 """ - if self._flags is None: - return None return ApplicationFlags._from_value(self._flags) async def edit( @@ -596,8 +594,8 @@ def __init__( guild: AppInstallParams | None = utils.MISSING, user: AppInstallParams | None = utils.MISSING, ) -> None: - self.guild = guild - self.user = user + self.guild: Guild = guild + self.user: User = user @staticmethod def _get_ctx( From d7424c2ddbaa7445bb7f7c9a08bf7f1a64e32356 Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 2 Mar 2026 23:45:25 +0100 Subject: [PATCH 54/61] Update discord/appinfo.py Signed-off-by: Paillat --- discord/appinfo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index add49d5719..c0fff83781 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -594,8 +594,8 @@ def __init__( guild: AppInstallParams | None = utils.MISSING, user: AppInstallParams | None = utils.MISSING, ) -> None: - self.guild: Guild = guild - self.user: User = user + self.guild: Guild | None = guild + self.user: User | None = user @staticmethod def _get_ctx( From 9ef07a00d5de5089f62d53f20c63aa8f2dec412f Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 2 Mar 2026 23:47:26 +0100 Subject: [PATCH 55/61] Apply suggestion from @Paillat-dev Signed-off-by: Paillat --- discord/enums.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/enums.py b/discord/enums.py index 1d63cca896..6affe47b2a 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -86,6 +86,7 @@ "SelectDefaultValueType", "ApplicationEventWebhookStatus", "InviteTargetUsersJobStatusCode", +) def _create_value_cls(name, comparable): From a385c806aa2d62f159999484bff76d9a143d5263 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:49:24 +0100 Subject: [PATCH 56/61] fix(appinfo): update types for guild and user in IntegrationTypesConfig --- discord/appinfo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index c0fff83781..022007d41a 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -594,8 +594,8 @@ def __init__( guild: AppInstallParams | None = utils.MISSING, user: AppInstallParams | None = utils.MISSING, ) -> None: - self.guild: Guild | None = guild - self.user: User | None = user + self.guild: AppInstallParams | None = guild + self.user: AppInstallParams | None = user @staticmethod def _get_ctx( From 2cdbcc3c5fb291a59b78f47f819121f75f35248a Mon Sep 17 00:00:00 2001 From: Ice Wolfy Date: Mon, 2 Mar 2026 17:17:59 -0600 Subject: [PATCH 57/61] feat: Support IntegrationTypes Defaults Via AppInfo --- discord/bot.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/discord/bot.py b/discord/bot.py index a818b2f831..7e54cdecf9 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -411,6 +411,16 @@ def get_application_command( return return command + async def _get_command_defaults(self): + app_info = await self._bot.application_info() + integration_contexts = app_info.integration_types_config._to_payload().keys() + + command_defaults = COMMAND_DEFAULTS.copy() + command_defaults["integration_types"] = DefaultSetComparison( + (MISSING, integration_contexts), lambda x, y: set(x) == set(y) + ) + return command_defaults + async def get_desynced_commands( self, guild_id: int | None = None, @@ -442,6 +452,8 @@ async def get_desynced_commands( the action, including ``id``. """ + updated_command_defaults = await self._get_command_defaults() + # We can suggest the user to upsert, edit, delete, or bulk upsert the commands def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: """Returns True If Commands Are Equivalent""" @@ -456,7 +468,9 @@ def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: return _check_command(subcommand, match_) else: if cmd.parent is None: - return _compare_defaults(cmd.to_dict(), match, COMMAND_DEFAULTS) + return _compare_defaults( + cmd.to_dict(), match, updated_command_defaults + ) else: return _compare_defaults(cmd.to_dict(), match, SUBCOMMAND_DEFAULTS) From da483b117a84fa0f6b3d7007608ada42e61b1167 Mon Sep 17 00:00:00 2001 From: Ice Wolfy Date: Mon, 2 Mar 2026 17:20:22 -0600 Subject: [PATCH 58/61] chore: Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed8917cbd..842c666572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#3105](https://github.com/Pycord-Development/pycord/pull/3105)) - Fixed the update of a user's `avatar_decoration` to now cause an `on_user_update` event to fire. ([#3103](https://github.com/Pycord-Development/pycord/pull/3103)) +- Fixed backend logic for `sync_commands` to only sync when needed. + ([#2990](https://github.com/Pycord-Development/pycord/pull/2990)) ### Deprecated From 5845999c8abd7a7e69d2a94452de1d5c669871f7 Mon Sep 17 00:00:00 2001 From: Ice Wolfy Date: Tue, 3 Mar 2026 11:36:27 -0600 Subject: [PATCH 59/61] feat: Implement Tests For Command Comparison --- tests/test_command_syncing.py | 1082 +++++++++++++++++++++++++++++++++ 1 file changed, 1082 insertions(+) create mode 100644 tests/test_command_syncing.py diff --git a/tests/test_command_syncing.py b/tests/test_command_syncing.py new file mode 100644 index 0000000000..862c711032 --- /dev/null +++ b/tests/test_command_syncing.py @@ -0,0 +1,1082 @@ +import copy +from typing import Any + +import pytest + +import discord +from discord import MISSING, Bot, SlashCommandGroup +from discord.types.interactions import ApplicationCommand, ApplicationCommandOption + +pytestmark = pytest.mark.asyncio + + +class SlashCommand(discord.SlashCommand): + def __init__(self, **kwargs): + if (r := kwargs.pop("func", None)) is not None: + callback = r + else: + + async def dummy_callback(ctx): + pass + + callback = dummy_callback + if (desc := kwargs.get("description")) is not None: + kwargs.pop("description") + else: + desc = "desc" + if (name := kwargs.get("name")) is not None: + kwargs.pop("name") + else: + name = "testing" + super().__init__(callback, name=name, description=desc, **kwargs) + if self.integration_types is None: + self.integration_types = {discord.IntegrationType.guild_install} + if self.contexts is None: + self.contexts = { + discord.InteractionContextType.private_channel, + discord.InteractionContextType.bot_dm, + discord.InteractionContextType.guild, + } + + +remote_dummy_base: dict = { + "id": "1", + "application_id": "1", + "version": "1", + "default_member_permissions": None, + "type": 1, + "name": "testing", + "name_localizations": None, + "description": "desc", + "description_localizations": None, + "dm_permission": True, + "contexts": [0, 1, 2], + "integration_types": [0], + "nsfw": False, +} + + +async def edit_needed( + local: SlashCommand | SlashCommandGroup, remote: ApplicationCommand +): + b = Bot() + b.add_application_command(local) + r = await b.get_desynced_commands(prefetched=[remote]) + return r[0]["action"] == "edit" + + +class TestCommandSyncing: + @staticmethod + def dict_factory(**kwargs) -> ApplicationCommand: + remote_dummy = copy.deepcopy(remote_dummy_base) + for key, value in kwargs.items(): + if value == MISSING: + del remote_dummy[key] + else: + remote_dummy.update({key: value}) + return remote_dummy + + async def test_default(self): + assert not await edit_needed(SlashCommand(), TestCommandSyncing.dict_factory()) + + async def test_default_member_permissions_defaults(self): + assert not await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(default_member_permissions=None), + ) + assert not await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(default_member_permissions=MISSING), + ) + + async def test_default_member_permissions(self): + assert await edit_needed( + SlashCommand(default_member_permissions=discord.Permissions(8)), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(default_member_permissions="8"), + ) + + async def test_type_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(type=1) + ) + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(type=MISSING) + ) + + async def test_name_localizations_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(name_localizations={}) + ) + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(name_localizations=None) + ) + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(name_localizations=MISSING) + ) + + async def test_name_localizations(self): + assert await edit_needed( + SlashCommand(name_localizations={"no": "testing_no"}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(name_localizations={"no": "testing_no", "ja": "testing_ja"}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(name_localizations={"ja": "testing_ja", "no": "testing_no"}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(name_localizations={"no": "testing_no"}), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory( + name_localizations={"no": "testing_no", "ja": "testing_ja"} + ), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory( + name_localizations={"ja": "testing_ja", "no": "testing_no"} + ), + ) + + async def test_description_localizations_defaults(self): + assert not await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(description_localizations={}), + ) + assert not await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(description_localizations=None), + ) + assert not await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(description_localizations=MISSING), + ) + + async def test_description_localizations(self): + assert await edit_needed( + SlashCommand(description_localizations={"no": "testing_desc_es"}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand( + description_localizations={ + "no": "testing_desc_es", + "ja": "testing_desc_jp", + } + ), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand( + description_localizations={ + "ja": "testing_desc_jp", + "no": "testing_desc_es", + } + ), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory( + description_localizations={"no": "testing_desc_es"} + ), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory( + description_localizations={ + "no": "testing_desc_es", + "ja": "testing_desc_jp", + } + ), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory( + description_localizations={ + "ja": "testing_desc_jp", + "no": "testing_desc_es", + } + ), + ) + + async def test_description(self): + assert await edit_needed( + SlashCommand(description="different"), TestCommandSyncing.dict_factory() + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(description="different") + ) + + async def test_contexts_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(contexts=[2, 1, 0]) + ) + + async def test_contexts(self): + assert await edit_needed( + SlashCommand(contexts=set()), TestCommandSyncing.dict_factory() + ) + assert await edit_needed( + SlashCommand(contexts={discord.InteractionContextType.guild}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(contexts=[]) + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(contexts=[1]) + ) + + async def test_integration_types_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(integration_types=MISSING) + ) + + async def test_integration_types(self): + assert await edit_needed( + SlashCommand(integration_types=set()), TestCommandSyncing.dict_factory() + ) + assert await edit_needed( + SlashCommand(integration_types={discord.IntegrationType.user_install}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(integration_types=[]) + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(integration_types=[1]) + ) + + async def test_nsfw_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(nsfw=False) + ) + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(nsfw=MISSING) + ) + + async def test_nsfw(self): + assert await edit_needed( + SlashCommand(nsfw=True), TestCommandSyncing.dict_factory() + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(nsfw=True) + ) + + async def test_options_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(options=[]) + ) + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(options=MISSING) + ) + + +class TestCommandSyncingWithOption: + @staticmethod + def dict_factory(**kwargs) -> dict[str, Any]: + remote_dummy = copy.deepcopy(remote_dummy_base) + remote_dummy["options"] = [ + { + "type": 3, + "name": "user", + "name_localizations": None, + "description": "name", + "description_localizations": None, + "required": True, + } + ] + for key, value in kwargs.items(): + if value == MISSING: + del remote_dummy["options"][0][key] + else: + remote_dummy["options"][0].update({key: value}) + return remote_dummy + + class SlashOptionCommand(SlashCommand): + @staticmethod + def default_option() -> discord.Option: + return discord.Option(str, name="user", description="name") + + def __init__( + self, options: list[discord.Option | dict] | None = None, **kwargs + ): + async def dummy_callback(ctx, user): + pass + + if options is None: + options = [self.default_option()] + for n, i in enumerate(options): + if isinstance(i, dict): + d = self.default_option() + for key, value in i.items(): + d.__setattr__(key, value) + options[n] = d + super().__init__(func=dummy_callback, options=options, **kwargs) + + async def test_type(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"input_type": discord.SlashCommandOptionType(5)}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(type=5), + ) + + async def test_name(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"name": "pycord"}]), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(name="pycord"), + ) + + async def test_description(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"description": "pycord_desc"}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(description="pycord_desc"), + ) + + async def test_name_localizations_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(name_localizations={}), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(name_localizations=None), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(name_localizations=MISSING), + ) + + async def test_name_localizations(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"name_localizations": {"no": "testing_no"}}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"name_localizations": {"no": "testing_no", "ja": "testing_ja"}}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"name_localizations": {"ja": "testing_ja", "no": "testing_no"}}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + name_localizations={"no": "testing_no"} + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + name_localizations={"no": "testing_no", "ja": "testing_ja"} + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + name_localizations={"ja": "testing_ja", "no": "testing_no"} + ), + ) + + async def test_description_localizations_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(description_localizations={}), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(description_localizations=None), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + description_localizations=MISSING + ), + ) + + async def test_description_localizations(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"description_localizations": {"no": "testing_desc_es"}}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "description_localizations": { + "no": "testing_desc_es", + "ja": "testing_desc_jp", + } + } + ] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "description_localizations": { + "ja": "testing_desc_jp", + "no": "testing_desc_es", + } + } + ] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + description_localizations={"no": "testing_desc_es"} + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + description_localizations={ + "no": "testing_desc_es", + "ja": "testing_desc_jp", + } + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + description_localizations={ + "ja": "testing_desc_jp", + "no": "testing_desc_es", + } + ), + ) + + async def test_required_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"required": False}]), + TestCommandSyncingWithOption.dict_factory(required=MISSING), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(required=True), + ) + + async def test_required(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(required=MISSING), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(required=False), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"required": False}]), + TestCommandSyncingWithOption.dict_factory(required=True), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"required": None}]), + TestCommandSyncingWithOption.dict_factory(required=MISSING), + ) + + async def test_choices_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(choices=MISSING), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(choices=[]), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "choices": [ + discord.OptionChoice("a"), + discord.OptionChoice("b"), + discord.OptionChoice("c"), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[ + {"name": "a", "value": "a"}, + {"name": "b", "value": "b"}, + {"name": "c", "value": "c"}, + ] + ), + ) + + async def test_choices(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"choices": [discord.OptionChoice("a"), discord.OptionChoice("b")]}] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[ + {"name": "a", "value": "a"}, + {"name": "b", "value": "b"}, + {"name": "c", "value": "c"}, + ] + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "choices": [ + discord.OptionChoice("a"), + discord.OptionChoice("b"), + discord.OptionChoice("c"), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[{"name": "a", "value": "a"}, {"name": "b", "value": "b"}] + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "choices": [ + discord.OptionChoice("a"), + discord.OptionChoice("x"), + discord.OptionChoice("c"), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[ + {"name": "a", "value": "a"}, + {"name": "b", "value": "b"}, + {"name": "c", "value": "c"}, + ] + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "choices": [ + discord.OptionChoice("a"), + discord.OptionChoice("b"), + discord.OptionChoice("c"), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[ + {"name": "a", "value": "a"}, + {"name": "x", "value": "x"}, + {"name": "c", "value": "c"}, + ] + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"choices": [discord.OptionChoice("a"), discord.OptionChoice("c")]}] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[ + {"name": "a", "value": "a"}, + {"name": "b", "value": "b"}, + {"name": "c", "value": "c"}, + ] + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "choices": [ + discord.OptionChoice("a"), + discord.OptionChoice("b"), + discord.OptionChoice("c"), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[{"name": "a", "value": "a"}, {"name": "c", "value": "c"}] + ), + ) + + async def test_channel_type_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(channel_types=[]), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(channel_types=MISSING), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(1)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1]), + ) + + async def test_channel_types(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(1)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(1)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=MISSING), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(5)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(1)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 5]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "channel_types": [ + discord.ChannelType(0), + discord.ChannelType(1), + discord.ChannelType(15), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(1)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1, 15]), + ) + + async def test_min_value_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(min_value=MISSING), + ) + + async def test_min_value(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5}]), + TestCommandSyncingWithOption.dict_factory(min_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 6}]), + TestCommandSyncingWithOption.dict_factory(min_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5}]), + TestCommandSyncingWithOption.dict_factory(min_value=6), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(min_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + # Floats + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5.5}]), + TestCommandSyncingWithOption.dict_factory(min_value=5.5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 6.0}]), + TestCommandSyncingWithOption.dict_factory(min_value=5.0), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5.77}]), + TestCommandSyncingWithOption.dict_factory(min_value=6.123), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(min_value=5.333), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5.333}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + # Mixed + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5.0}]), + TestCommandSyncingWithOption.dict_factory(min_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 6}]), + TestCommandSyncingWithOption.dict_factory(min_value=5.0), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5.0}]), + TestCommandSyncingWithOption.dict_factory(min_value=6), + ) + + async def test_max_value_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(max_value=MISSING), + ) + + async def test_max_value(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5}]), + TestCommandSyncingWithOption.dict_factory(max_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 6}]), + TestCommandSyncingWithOption.dict_factory(max_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5}]), + TestCommandSyncingWithOption.dict_factory(max_value=6), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(max_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + # Floats + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5.5}]), + TestCommandSyncingWithOption.dict_factory(max_value=5.5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 6.0}]), + TestCommandSyncingWithOption.dict_factory(max_value=5.0), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5.77}]), + TestCommandSyncingWithOption.dict_factory(max_value=6.123), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(max_value=5.333), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5.333}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + # Mixed + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5.0}]), + TestCommandSyncingWithOption.dict_factory(max_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 6}]), + TestCommandSyncingWithOption.dict_factory(max_value=5.0), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5.0}]), + TestCommandSyncingWithOption.dict_factory(max_value=6), + ) + + async def test_min_length_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(min_length=MISSING), + ) + + async def test_min_length(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_length": 5}]), + TestCommandSyncingWithOption.dict_factory(min_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_length": 6}]), + TestCommandSyncingWithOption.dict_factory(min_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_length": 5}]), + TestCommandSyncingWithOption.dict_factory(min_length=6), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(min_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_length": 5}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + async def test_max_length_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(max_length=MISSING), + ) + + async def test_max_length(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_length": 5}]), + TestCommandSyncingWithOption.dict_factory(max_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_length": 6}]), + TestCommandSyncingWithOption.dict_factory(max_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_length": 5}]), + TestCommandSyncingWithOption.dict_factory(max_length=6), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(max_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_length": 5}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + async def test_autocomplete_default(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(autocomplete=False), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(autocomplete=MISSING), + ) + + async def test_autocomplete(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(autocomplete=True), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"autocomplete": lambda x: x}] + ), + TestCommandSyncingWithOption.dict_factory(autocomplete=False), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"autocomplete": lambda x: x}] + ), + TestCommandSyncingWithOption.dict_factory(autocomplete=MISSING), + ) + + +class TestSubCommandSyncing: + + @staticmethod + def dict_factory(top: dict, command: dict) -> dict[str, Any]: + remote_dummy = TestCommandSyncing.dict_factory(**top) + if "name" not in top: + remote_dummy["name"] = "subcommand" + remote_dummy["options"] = [ + ApplicationCommandOption( + type=1, + name="testing", + name_localizations=None, + description="desc", + description_localizations=None, + required=True, + ) + ] + for key, value in command.items(): + if value == MISSING: + del remote_dummy["options"][0][key] + else: + remote_dummy["options"][0].update({key: value}) + return remote_dummy + + async def test_parent_name(self): + async def edit_needed( + local: SlashCommand | SlashCommandGroup, remote: ApplicationCommand + ): + b = Bot() + b.add_application_command(local) + r = await b.get_desynced_commands(prefetched=[remote]) + return r[0]["action"] == "upsert" and r[1]["action"] == "delete" + + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand()) + assert not await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("newgroupname") + s.add_command(SlashCommand()) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand()) + assert await edit_needed(s, self.dict_factory({"name": "new_discord_name"}, {})) + + async def test_parent_attrs(self): + s = SlashCommandGroup("subcommand", description="newdescname") + s.add_command(SlashCommand()) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand()) + assert await edit_needed( + s, self.dict_factory({"description": "new_discord_desc"}, {}) + ) + + async def test_child_name(self): + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand(parent=s)) + assert not await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand(name="newsubcommand_name")) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand()) + assert await edit_needed(s, self.dict_factory({}, {"name": "new_discord_name"})) + + async def test_child(self): + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand(description="newdescript")) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand()) + assert await edit_needed( + s, self.dict_factory({}, {"description": "new_descript"}) + ) + + async def test_subcommand_counts(self): + s = SlashCommandGroup("subcommand") + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand(parent=s)) + rd = self.dict_factory({}, {}) + rd["options"] = [] + assert await edit_needed(s, rd) + + +class TestSubSubCommandSyncing: + + @staticmethod + def dict_factory(top: dict, command: dict) -> dict[str, Any]: + remote_dummy = TestCommandSyncing.dict_factory(**top) + if "name" not in top: + remote_dummy["name"] = "subcommand" + remote_dummy["options"] = [ + ApplicationCommandOption( + type=2, + name="testing", + name_localizations=None, + description="desc", + description_localizations=None, + required=True, + options=[ + ApplicationCommandOption( + type=1, + name="testing", + name_localizations=None, + description="desc", + description_localizations=None, + required=True, + ) + ], + ) + ] + for key, value in command.items(): + if value == MISSING: + del remote_dummy["options"][0]["options"][0][key] + else: + remote_dummy["options"][0]["options"][0].update({key: value}) + return remote_dummy + + async def test_child_name(self): + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand(parent=ss)) + assert not await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand(name="newsubcommand_name")) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand()) + assert await edit_needed(s, self.dict_factory({}, {"name": "new_discord_name"})) + + async def test_child(self): + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand(description="newdescript")) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand()) + assert await edit_needed( + s, self.dict_factory({}, {"description": "new_descript"}) + ) + + async def test_subsubcommand_counts(self): + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand(parent=ss)) + rd = self.dict_factory({}, {}) + rd["options"][0]["options"] = [] + assert await edit_needed(s, rd) From 06c12ddbacce456026c9dff3f36f89a39fc7dd36 Mon Sep 17 00:00:00 2001 From: Ice Wolfy Date: Tue, 3 Mar 2026 11:48:12 -0600 Subject: [PATCH 60/61] chore: Apply Minor Changes From Code Review --- discord/bot.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index 7e54cdecf9..ab891142ce 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -108,7 +108,7 @@ def __init__( self.defaults = defaults self.callback = callback - def _check_defaults(self, local, remote) -> bool | None: + def _check_defaults(self, local: Any, remote: Any) -> bool | None: defaults = (local in self.defaults) + (remote in self.defaults) if defaults == 2: # Both are COMMAND_DEFAULTS, so they can be counted as the same @@ -120,7 +120,7 @@ def _check_defaults(self, local, remote) -> bool | None: # Only one is a default, so the command must be out of sync return False - def check(self, local, remote) -> bool: + def check(self, local: Any, remote: Any) -> bool: """ Compares the local and remote objects. @@ -137,7 +137,7 @@ def check(self, local, remote) -> bool: class DefaultSetComparison(DefaultComparison): @override - def check(self, local, remote) -> bool: + def check(self, local: Any, remote: Any) -> bool: try: local = set(local) except TypeError: @@ -174,7 +174,7 @@ def _compare_defaults( OPTION_DEFAULT_VALUES = ([], MISSING) -def _option_comparison_check(local, remote) -> bool: +def _option_comparison_check(local: Any, remote: Any) -> bool: matching = (local in OPTION_DEFAULT_VALUES) + (remote in OPTION_DEFAULT_VALUES) if matching == 2: return True @@ -192,7 +192,7 @@ def _option_comparison_check(local, remote) -> bool: CHOICES_DEFAULT_VALUES = ([], MISSING) -def _choices_comparison_check(local, remote) -> bool: +def _choices_comparison_check(local: Any, remote: Any) -> bool: matching = (local in CHOICES_DEFAULT_VALUES) + (remote in CHOICES_DEFAULT_VALUES) if matching == 2: return True @@ -515,8 +515,8 @@ def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: "id": int(registered_commands_dict[cmd.name]["id"]), } ) + # We have this command registered and it's the same else: - # We have this command registered and it's the same return_value.append( {"command": cmd, "action": None, "id": int(match["id"])} ) From 2f7892c6e38e9c58b17a5ebf81980a6ff1f65339 Mon Sep 17 00:00:00 2001 From: Ice Wolfy Date: Tue, 3 Mar 2026 20:51:08 -0600 Subject: [PATCH 61/61] fix: Use Dummy Data In Place Of Fetching AppInfo --- tests/test_command_syncing.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/test_command_syncing.py b/tests/test_command_syncing.py index 862c711032..caec296aef 100644 --- a/tests/test_command_syncing.py +++ b/tests/test_command_syncing.py @@ -1,10 +1,11 @@ import copy -from typing import Any +from typing import Any, override import pytest import discord from discord import MISSING, Bot, SlashCommandGroup +from discord.bot import COMMAND_DEFAULTS, DefaultSetComparison from discord.types.interactions import ApplicationCommand, ApplicationCommandOption pytestmark = pytest.mark.asyncio @@ -56,10 +57,20 @@ async def dummy_callback(ctx): } +class DummyBot(Bot): + @override + async def _get_command_defaults(self): + command_defaults = COMMAND_DEFAULTS.copy() + command_defaults["integration_types"] = DefaultSetComparison( + (MISSING, {0}), lambda x, y: set(x) == set(y) + ) + return command_defaults + + async def edit_needed( local: SlashCommand | SlashCommandGroup, remote: ApplicationCommand ): - b = Bot() + b = DummyBot() b.add_application_command(local) r = await b.get_desynced_commands(prefetched=[remote]) return r[0]["action"] == "edit" @@ -954,7 +965,7 @@ async def test_parent_name(self): async def edit_needed( local: SlashCommand | SlashCommandGroup, remote: ApplicationCommand ): - b = Bot() + b = DummyBot() b.add_application_command(local) r = await b.get_desynced_commands(prefetched=[remote]) return r[0]["action"] == "upsert" and r[1]["action"] == "delete"