diff --git a/.github/workflows/todo-checks.yml b/.github/workflows/todo-checks.yml index ec52cf510c..b210393136 100644 --- a/.github/workflows/todo-checks.yml +++ b/.github/workflows/todo-checks.yml @@ -23,7 +23,7 @@ jobs: - name: "Checkout Repository" uses: actions/checkout@v4 - name: "Track TODO Action" - uses: ribtoks/tdg-github-action@v0.4.11-beta + uses: ribtoks/tdg-github-action@v0.4.12-beta with: TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ceaa298b18..ef3e6995ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ These changes are available on the `master` branch, but have not yet been releas ⚠️ **This Version Removes Support For Python 3.8** ⚠️ +### Added + +- Added `Guild.fetch_role` method. + ([#2528](https://github.com/Pycord-Development/pycord/pull/2528)) +- Added the following `AppInfo` attributes: `approximate_guild_count`, + `approximate_user_install_count`, `custom_install_url`, `install_params`, + `interactions_endpoint_url`, `redirect_uris`, `role_connections_verification_url`, and + `tags`. ([#2520](https://github.com/Pycord-Development/pycord/pull/2520)) +- Added `Member.guild_banner` and `Member.display_banner` properties. + ([#2556](https://github.com/Pycord-Development/pycord/pull/2556)) + ### Changed - Renamed `cover` property of `ScheduledEvent` and `cover` argument of @@ -20,10 +31,25 @@ These changes are available on the `master` branch, but have not yet been releas - ⚠️ **This Version Removes Support For Python 3.8** ⚠️ ([#2521](https://github.com/Pycord-Development/pycord/pull/2521)) -### Added +### Deprecated -- Added `Guild.fetch_role` method. - ([#2528](https://github.com/Pycord-Development/pycord/pull/2528)) +- Deprecated `AppInfo.summary` in favor of `AppInfo.description`. + ([#2520](https://github.com/Pycord-Development/pycord/pull/2520)) + +### Fixed + +- Fixed `EntitlementIterator` behavior with `limit > 100`. + ([#2555](https://github.com/Pycord-Development/pycord/pull/2555)) +- Fixed missing `stacklevel` parameter in `warn_deprecated` function call inside + `@utils.deprecated`. ([#2500](https://github.com/Pycord-Development/pycord/pull/2500)) +- Fixed the typehint in `ConnectionState._polls` to reflect actual behavior, changing it + from `Guild` to `Poll`. + ([#2500](https://github.com/Pycord-Development/pycord/pull/2500)) +- Fixed missing `__slots__` attributes in `RawReactionClearEmojiEvent` and + `RawMessagePollVoteEvent`. + ([#2500](https://github.com/Pycord-Development/pycord/pull/2500)) +- Fixed the type of `ForumChannel.default_sort_order`, changing it from `int` to + `SortOrder`. ([#2500](https://github.com/Pycord-Development/pycord/pull/2500)) ## [2.6.0] - 2024-07-09 diff --git a/discord/appinfo.py b/discord/appinfo.py index 9554c96b5d..034b1bb158 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -29,11 +29,13 @@ from . import utils from .asset import Asset +from .permissions import Permissions if TYPE_CHECKING: from .guild import Guild from .state import ConnectionState from .types.appinfo import AppInfo as AppInfoPayload + from .types.appinfo import AppInstallParams as AppInstallParamsPayload from .types.appinfo import PartialAppInfo as PartialAppInfoPayload from .types.appinfo import Team as TeamPayload from .user import User @@ -41,6 +43,7 @@ __all__ = ( "AppInfo", "PartialAppInfo", + "AppInstallParams", ) @@ -70,11 +73,6 @@ class AppInfo: grant flow to join. rpc_origins: Optional[List[:class:`str`]] A list of RPC origin URLs, if RPC is enabled. - summary: :class:`str` - If this application is a game sold on Discord, - this field will be the summary field for the store page of its primary SKU. - - .. versionadded:: 1.3 verify_key: :class:`str` The hex encoded key for verification in interactions and the @@ -110,6 +108,48 @@ class AppInfo: The application's privacy policy URL, if set. .. versionadded:: 2.0 + + approximate_guild_count: Optional[:class:`int`] + The approximate count of guilds to which the app has been added, if any. + + .. versionadded:: 2.7 + + approximate_user_install_count: Optional[:class:`int`] + The approximate count of users who have installed the application, if any. + + .. versionadded:: 2.7 + + redirect_uris: Optional[List[:class:`str`]] + The list of redirect URIs for the application, if set. + + .. versionadded:: 2.7 + + interactions_endpoint_url: Optional[:class:`str`] + The interactions endpoint URL for the application, if set. + + .. versionadded:: 2.7 + + role_connections_verification_url: Optional[:class:`str`] + The role connection verification URL for the application, if set. + + .. versionadded:: 2.7 + + install_params: Optional[List[:class:`AppInstallParams`]] + The settings for the application's default in-app authorization link, if set. + + .. versionadded:: 2.7 + + 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 + + custom_install_url: Optional[:class:`str`] + The default custom authorization URL for the application, if set. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -122,7 +162,7 @@ class AppInfo: "bot_require_code_grant", "owner", "_icon", - "summary", + "_summary", "verify_key", "team", "guild_id", @@ -131,6 +171,14 @@ class AppInfo: "_cover_image", "terms_of_service_url", "privacy_policy_url", + "approximate_guild_count", + "approximate_user_install_count", + "redirect_uris", + "interactions_endpoint_url", + "role_connections_verification_url", + "install_params", + "tags", + "custom_install_url", ) def __init__(self, state: ConnectionState, data: AppInfoPayload): @@ -149,7 +197,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 = data["summary"] self.verify_key: str = data["verify_key"] self.guild_id: int | None = utils._get_as_snowflake(data, "guild_id") @@ -161,6 +209,24 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload): self._cover_image: str | None = data.get("cover_image") self.terms_of_service_url: str | None = data.get("terms_of_service_url") self.privacy_policy_url: str | None = data.get("privacy_policy_url") + self.approximate_guild_count: int | None = data.get("approximate_guild_count") + self.approximate_user_install_count: int | None = data.get( + "approximate_user_install_count" + ) + self.redirect_uris: list[str] | None = data.get("redirect_uris", []) + self.interactions_endpoint_url: str | None = data.get( + "interactions_endpoint_url" + ) + self.role_connections_verification_url: str | None = data.get( + "role_connections_verification_url" + ) + + install_params = data.get("install_params") + self.install_params: AppInstallParams | None = ( + AppInstallParams(install_params) if install_params else None + ) + self.tags: list[str] | None = data.get("tags", []) + self.custom_install_url: str | None = data.get("custom_install_url") def __repr__(self) -> str: return ( @@ -195,6 +261,23 @@ def guild(self) -> Guild | None: """ return self._state._get_guild(self.guild_id) + @property + def summary(self) -> str | None: + """If this application is a game sold on Discord, + this field will be the summary field for the store page of its primary SKU. + + It currently returns an empty string. + + .. versionadded:: 1.3 + .. deprecated:: 2.7 + """ + utils.warn_deprecated( + "summary", + "description", + reference="https://discord.com/developers/docs/resources/application#application-object-application-structure", + ) + return self._summary + class PartialAppInfo: """Represents a partial AppInfo given by :func:`~discord.abc.GuildChannel.create_invite` @@ -257,3 +340,23 @@ def icon(self) -> Asset | None: if self._icon is None: return None return Asset._from_icon(self._state, self.id, self._icon, path="app") + + +class AppInstallParams: + """Represents the settings for the custom authorization URL of an application. + + .. versionadded:: 2.7 + + Attributes + ---------- + scopes: List[:class:`str`] + The list of OAuth2 scopes for adding the application to a guild. + permissions: :class:`Permissions` + The permissions to request for the bot role in the guild. + """ + + __slots__ = ("scopes", "permissions") + + def __init__(self, data: AppInstallParamsPayload) -> None: + self.scopes: list[str] = data.get("scopes", []) + self.permissions: Permissions = Permissions(int(data["permissions"])) diff --git a/discord/asset.py b/discord/asset.py index c8a33e61e3..07c7ca8e7b 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -212,6 +212,19 @@ def _from_guild_avatar( animated=animated, ) + @classmethod + def _from_guild_banner( + cls, state, guild_id: int, member_id: int, banner: str + ) -> Asset: + animated = banner.startswith("a_") + format = "gif" if animated else "png" + return cls( + state, + url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/banners/{banner}.{format}?size=512", + key=banner, + animated=animated, + ) + @classmethod def _from_icon(cls, state, object_id: int, icon_hash: str, path: str) -> Asset: return cls( diff --git a/discord/channel.py b/discord/channel.py index 7608e1757d..27230a380f 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1036,6 +1036,9 @@ def _update(self, guild: Guild, data: ForumChannelPayload) -> None: for tag in (data.get("available_tags") or []) ] self.default_sort_order: SortOrder | None = data.get("default_sort_order", None) + if self.default_sort_order is not None: + self.default_sort_order = try_enum(SortOrder, self.default_sort_order) + reaction_emoji_ctx: dict = data.get("default_reaction_emoji") if reaction_emoji_ctx is not None: emoji_name = reaction_emoji_ctx.get("emoji_name") diff --git a/discord/http.py b/discord/http.py index 7813d20a23..f42bcdc233 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2592,35 +2592,6 @@ def bulk_upsert_guild_commands( ) return self.request(r, json=payload) - # Application commands (permissions) - - def get_command_permissions( - self, - application_id: Snowflake, - guild_id: Snowflake, - command_id: Snowflake, - ) -> Response[interactions.GuildApplicationCommandPermissions]: - r = Route( - "GET", - "/applications/{application_id}/guilds/{guild_id}/commands/{command_id}/permissions", - application_id=application_id, - guild_id=guild_id, - ) - return self.request(r) - - def get_guild_command_permissions( - self, - application_id: Snowflake, - guild_id: Snowflake, - ) -> Response[list[interactions.GuildApplicationCommandPermissions]]: - r = Route( - "GET", - "/applications/{application_id}/guilds/{guild_id}/commands/permissions", - application_id=application_id, - guild_id=guild_id, - ) - return self.request(r) - # Guild Automod Rules def get_auto_moderation_rules( @@ -2858,6 +2829,8 @@ def delete_followup_message( ) return self.request(r) + # Application commands (permissions) + def get_guild_application_command_permissions( self, application_id: Snowflake, diff --git a/discord/iterators.py b/discord/iterators.py index 7404e790a4..13f67266ea 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -64,6 +64,7 @@ from .types.audit_log import AuditLog as AuditLogPayload from .types.guild import Guild as GuildPayload from .types.message import Message as MessagePayload + from .types.monetization import Entitlement as EntitlementPayload from .types.threads import Thread as ThreadPayload from .types.user import PartialUser as PartialUserPayload from .user import User @@ -988,11 +989,21 @@ def __init__( self.guild_id = guild_id self.exclude_ended = exclude_ended + self._filter = None + + if self.before and self.after: + self._retrieve_entitlements = self._retrieve_entitlements_before_strategy + self._filter = lambda e: int(e["id"]) > self.after.id + elif self.after: + self._retrieve_entitlements = self._retrieve_entitlements_after_strategy + else: + self._retrieve_entitlements = self._retrieve_entitlements_before_strategy + self.state = state self.get_entitlements = state.http.list_entitlements self.entitlements = asyncio.Queue() - async def next(self) -> BanEntry: + async def next(self) -> Entitlement: if self.entitlements.empty(): await self.fill_entitlements() @@ -1014,30 +1025,57 @@ async def fill_entitlements(self): if not self._get_retrieve(): return + data = await self._retrieve_entitlements(self.retrieve) + + if self._filter: + data = list(filter(self._filter, data)) + + if len(data) < 100: + self.limit = 0 # terminate loop + + for element in data: + await self.entitlements.put(Entitlement(data=element, state=self.state)) + + async def _retrieve_entitlements(self, retrieve) -> list[Entitlement]: + """Retrieve entitlements and update next parameters.""" + raise NotImplementedError + + async def _retrieve_entitlements_before_strategy( + self, retrieve: int + ) -> list[EntitlementPayload]: + """Retrieve entitlements using before parameter.""" before = self.before.id if self.before else None - after = self.after.id if self.after else None data = await self.get_entitlements( self.state.application_id, before=before, - after=after, - limit=self.retrieve, + limit=retrieve, user_id=self.user_id, guild_id=self.guild_id, sku_ids=self.sku_ids, exclude_ended=self.exclude_ended, ) + if data: + if self.limit is not None: + self.limit -= retrieve + self.before = Object(id=int(data[-1]["id"])) + return data - if not data: - # no data, terminate - return - - if self.limit: - self.limit -= self.retrieve - - if len(data) < 100: - self.limit = 0 # terminate loop - - self.after = Object(id=int(data[-1]["id"])) - - for element in reversed(data): - await self.entitlements.put(Entitlement(data=element, state=self.state)) + async def _retrieve_entitlements_after_strategy( + self, retrieve: int + ) -> list[EntitlementPayload]: + """Retrieve entitlements using after parameter.""" + after = self.after.id if self.after else None + data = await self.get_entitlements( + self.state.application_id, + after=after, + limit=retrieve, + user_id=self.user_id, + guild_id=self.guild_id, + sku_ids=self.sku_ids, + exclude_ended=self.exclude_ended, + ) + if data: + if self.limit is not None: + self.limit -= retrieve + self.after = Object(id=int(data[-1]["id"])) + return data diff --git a/discord/member.py b/discord/member.py index 18ebf1267f..f1a546c918 100644 --- a/discord/member.py +++ b/discord/member.py @@ -288,6 +288,7 @@ class Member(discord.abc.Messageable, _UserTag): "_user", "_state", "_avatar", + "_banner", "communication_disabled_until", "flags", ) @@ -328,6 +329,7 @@ def __init__( self.nick: str | None = data.get("nick", None) self.pending: bool = data.get("pending", False) self._avatar: str | None = data.get("avatar") + self._banner: str | None = data.get("banner") self.communication_disabled_until: datetime.datetime | None = utils.parse_time( data.get("communication_disabled_until") ) @@ -406,6 +408,7 @@ def _copy(cls: type[M], member: M) -> M: self.activities = member.activities self._state = member._state self._avatar = member._avatar + self._banner = member._banner self.communication_disabled_until = member.communication_disabled_until self.flags = member.flags @@ -434,6 +437,7 @@ def _update(self, data: MemberPayload) -> None: self.premium_since = utils.parse_time(data.get("premium_since")) self._roles = utils.SnowflakeList(map(int, data["roles"])) self._avatar = data.get("avatar") + self._banner = data.get("banner") self.communication_disabled_until = utils.parse_time( data.get("communication_disabled_until") ) @@ -603,6 +607,31 @@ def guild_avatar(self) -> Asset | None: self._state, self.guild.id, self.id, self._avatar ) + @property + def display_banner(self) -> Asset | None: + """Returns the member's display banner. + + For regular members this is just their banner, but + if they have a guild specific banner then that + is returned instead. + + .. versionadded:: 2.7 + """ + return self.guild_banner or self._user.banner + + @property + def guild_banner(self) -> Asset | None: + """Returns an :class:`Asset` for the guild banner + the member has. If unavailable, ``None`` is returned. + + .. versionadded:: 2.7 + """ + if self._banner is None: + return None + return Asset._from_guild_banner( + self._state, self.guild.id, self.id, self._banner + ) + @property def activity(self) -> ActivityTypes | None: """Returns the primary diff --git a/discord/raw_models.py b/discord/raw_models.py index e59507dd48..a2881839a6 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -327,7 +327,17 @@ class RawReactionClearEmojiEvent(_RawReprMixin): .. versionadded:: 2.5 """ - __slots__ = ("message_id", "channel_id", "guild_id", "emoji", "burst", "data") + __slots__ = ( + "message_id", + "channel_id", + "guild_id", + "emoji", + "burst", + "data", + "burst_colours", + "burst_colors", + "type", + ) def __init__(self, data: ReactionClearEmojiEvent, emoji: PartialEmoji) -> None: self.emoji: PartialEmoji = emoji @@ -807,7 +817,15 @@ class RawMessagePollVoteEvent(_RawReprMixin): The raw data sent by the `gateway ` """ - __slots__ = ("user_id", "message_id", "channel_id", "guild_id", "data", "added") + __slots__ = ( + "user_id", + "message_id", + "answer_id", + "channel_id", + "guild_id", + "data", + "added", + ) def __init__(self, data: MessagePollVoteEvent, added: bool) -> None: self.user_id: int = int(data["user_id"]) diff --git a/discord/state.py b/discord/state.py index c8d8d4dced..4170d33fef 100644 --- a/discord/state.py +++ b/discord/state.py @@ -276,7 +276,7 @@ def clear(self, *, views: bool = True) -> None: self._emojis: dict[int, Emoji] = {} self._stickers: dict[int, GuildSticker] = {} self._guilds: dict[int, Guild] = {} - self._polls: dict[int, Guild] = {} + self._polls: dict[int, Poll] = {} if views: self._view_store: ViewStore = ViewStore(self) self._modal_store: ModalStore = ModalStore(self) diff --git a/discord/types/appinfo.py b/discord/types/appinfo.py index 989d3f7a58..8d891acbed 100644 --- a/discord/types/appinfo.py +++ b/discord/types/appinfo.py @@ -59,3 +59,8 @@ class PartialAppInfo(BaseAppInfo): rpc_origins: NotRequired[list[str]] cover_image: NotRequired[str] flags: NotRequired[int] + + +class AppInstallParams(TypedDict): + scopes: list[str] + permissions: str diff --git a/discord/types/member.py b/discord/types/member.py index 0bc1071fb1..618bb13efe 100644 --- a/discord/types/member.py +++ b/discord/types/member.py @@ -42,6 +42,7 @@ class PartialMember(TypedDict): class Member(PartialMember, total=False): avatar: str + banner: str user: User nick: str premium_since: str diff --git a/discord/utils.py b/discord/utils.py index b95678a1c4..9f01f53e71 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -377,6 +377,7 @@ def decorated(*args: P.args, **kwargs: P.kwargs) -> T: since=since, removed=removed, reference=reference, + stacklevel=stacklevel, ) return func(*args, **kwargs) diff --git a/docs/api/application_info.rst b/docs/api/application_info.rst index a0fe725320..585902b3a4 100644 --- a/docs/api/application_info.rst +++ b/docs/api/application_info.rst @@ -13,6 +13,11 @@ Application Info .. autoclass:: PartialAppInfo() :members: +.. attributetable:: AppInstallParams + +.. autoclass:: AppInstallParams() + :members: + .. attributetable:: Team .. autoclass:: Team() diff --git a/docs/ext/bridge/api.rst b/docs/ext/bridge/api.rst index 75dfdb51de..66dbc56125 100644 --- a/docs/ext/bridge/api.rst +++ b/docs/ext/bridge/api.rst @@ -154,7 +154,7 @@ BridgeContext Subclasses .. attributetable:: discord.ext.bridge.Context -.. data:: discord.ext.bridge.Context +.. autoclass:: discord.ext.bridge.Context Alias of :data:`typing.Union` [ :class:`.BridgeExtContext`, :class:`.BridgeApplicationContext` ] for typing convenience. diff --git a/requirements/dev.txt b/requirements/dev.txt index 63f49d2a9f..a520804fe5 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ -r _.txt -pylint~=3.2.6 +pylint~=3.2.7 pytest~=8.3.2 pytest-asyncio~=0.23.8 # pytest-order~=1.0.1