From 1ef4bbe741994747eb736abdefd962a74f4175c5 Mon Sep 17 00:00:00 2001 From: Dorukyum <53639936+Dorukyum@users.noreply.github.com> Date: Thu, 17 Nov 2022 07:05:01 +0300 Subject: [PATCH] feat: Complete forum channel implementation (#1636) * Add ForumTag type * Make emoji_name nullable * Add forum tag fields * Add missing attributes & create ForumTag * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix typehint syntax * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix typehints * Update discord/http.py Co-authored-by: BobDotCom <71356958+BobDotCom@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update channel.py * Update channel.py * Update forum tags - Move available_tags to ForumChannel, turn type to `list[ForumTag]` - Fix versionadded * Implement ForumChannel.get_tag * Add sort order, channel flags and total msg * Fix typehints * Update Thread.applied_tags * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Implement ForumChannel.requires_tag * Implement Thread.is_pinned * Update `versionadded`s in docstring * Update SortOrder to match API values * Implement default_sort_order * Implement default_thread_rate_limit_per_user * Add new fields to edit routes * Rename default_thread_rate_limit_per_user This will be called default_thread_slowmode_delay in Pycord. * Seperate edit methods for text and forum channels * Map default_thread_slowmode_delay to valid field * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add require_tag field to ForumChannel.edit * Parse available_tags to dicts in edit * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: NameError due to TYPE_CHECKING imports * feat: applied_tags field in Thread.edit Co-authored-by: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Lala Sabathil --- discord/abc.py | 20 ++ discord/channel.py | 433 ++++++++++++++++++++++++++++++--------- discord/enums.py | 10 + discord/flags.py | 9 + discord/http.py | 10 + discord/partial_emoji.py | 22 +- discord/threads.py | 47 ++++- discord/types/channel.py | 23 +++ discord/types/threads.py | 3 + 9 files changed, 479 insertions(+), 98 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 8554383178..491b161393 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -400,6 +400,26 @@ async def _edit( except KeyError: pass + try: + options["default_thread_rate_limit_per_user"] = options.pop( + "default_thread_slowmode_delay" + ) + except KeyError: + pass + + try: + if options.pop("require_tag"): + options["flags"] = ChannelFlags.require_tag.flag + except KeyError: + pass + + try: + options["available_tags"] = [ + tag.to_dict() for tag in options.pop("available_tags") + ] + except KeyError: + pass + try: rtc_region = options.pop("rtc_region") except KeyError: diff --git a/discord/channel.py b/discord/channel.py index 8ad00d747b..beea5fe177 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -36,6 +36,7 @@ ChannelType, EmbeddedActivity, InviteTarget, + SortOrder, StagePrivacyLevel, VideoQualityMode, VoiceRegion, @@ -48,6 +49,7 @@ from .iterators import ArchivedThreadIterator from .mixins import Hashable from .object import Object +from .partial_emoji import PartialEmoji, _EmojiTag from .permissions import PermissionOverwrite, Permissions from .stage_instance import StageInstance from .threads import Thread @@ -62,6 +64,7 @@ "GroupChannel", "PartialMessageable", "ForumChannel", + "ForumTag", ) if TYPE_CHECKING: @@ -69,12 +72,13 @@ from .guild import Guild from .guild import GuildChannel as GuildChannelType from .member import Member, VoiceState - from .message import Message, PartialMessage + from .message import EmojiInputType, Message, PartialMessage from .role import Role from .state import ConnectionState from .types.channel import CategoryChannel as CategoryChannelPayload from .types.channel import DMChannel as DMChannelPayload from .types.channel import ForumChannel as ForumChannelPayload + from .types.channel import ForumTag as ForumTagPayload from .types.channel import GroupDMChannel as GroupChannelPayload from .types.channel import StageChannel as StageChannelPayload from .types.channel import TextChannel as TextChannelPayload @@ -85,6 +89,92 @@ from .webhook import Webhook +class ForumTag(Hashable): + """Represents a forum tag that can be added to a thread inside a :class:`ForumChannel` + . + .. versionadded:: 2.3 + + .. container:: operations + + .. describe:: x == y + + Checks if two forum tags are equal. + + .. describe:: x != y + + Checks if two forum tags are not equal. + + .. describe:: hash(x) + + Returns the forum tag's hash. + + .. describe:: str(x) + + Returns the forum tag's name. + + Attributes + ---------- + id: :class:`int` + The tag ID. + Note that if the object was created manually then this will be ``0``. + name: :class:`str` + The name of the tag. Can only be up to 20 characters. + moderated: :class:`bool` + Whether this tag can only be added or removed by a moderator with + the :attr:`~Permissions.manage_threads` permission. + emoji: :class:`PartialEmoji` + The emoji that is used to represent this tag. + Note that if the emoji is a custom emoji, it will *not* have name information. + """ + + __slots__ = ("name", "id", "moderated", "emoji") + + def __init__( + self, *, name: str, emoji: EmojiInputType, moderated: bool = False + ) -> None: + self.name: str = name + self.id: int = 0 + self.moderated: bool = moderated + self.emoji: PartialEmoji + if isinstance(emoji, _EmojiTag): + self.emoji = emoji._to_partial() + elif isinstance(emoji, str): + self.emoji = PartialEmoji.from_str(emoji) + else: + raise TypeError( + f"emoji must be a Emoji, PartialEmoji, or str and not {emoji.__class__!r}" + ) + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return self.name + + @classmethod + def from_data(cls, *, state: ConnectionState, data: ForumTagPayload) -> ForumTag: + self = cls.__new__(cls) + self.name = data["name"] + self.id = int(data["id"]) + self.moderated = data.get("moderated", False) + + emoji_name = data["emoji_name"] or "" + emoji_id = utils._get_as_snowflake(data, "emoji_id") or None + self.emoji = PartialEmoji.with_state(state=state, name=emoji_name, id=emoji_id) + return self + + def to_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "name": self.name, + "moderated": self.moderated, + } | self.emoji._to_forum_tag_payload() + + if self.id: + payload["id"] = self.id + + return payload + + class _TextChannel(discord.abc.GuildChannel, Hashable): __slots__ = ( "name", @@ -100,6 +190,9 @@ class _TextChannel(discord.abc.GuildChannel, Hashable): "_type", "last_message_id", "default_auto_archive_duration", + "default_thread_slowmode_delay", + "default_sort_order", + "available_tags", "flags", ) @@ -142,6 +235,9 @@ def _update( self.default_auto_archive_duration: ThreadArchiveDuration = data.get( "default_auto_archive_duration", 1440 ) + self.default_thread_slowmode_delay: int | None = data.get( + "default_thread_rate_limit_per_user" + ) self.last_message_id: int | None = utils._get_as_snowflake( data, "last_message_id" ) @@ -212,97 +308,9 @@ def last_message(self) -> Message | None: else None ) - @overload - async def edit( - self, - *, - reason: str | None = ..., - name: str = ..., - topic: str = ..., - position: int = ..., - nsfw: bool = ..., - sync_permissions: bool = ..., - category: CategoryChannel | None = ..., - slowmode_delay: int = ..., - default_auto_archive_duration: ThreadArchiveDuration = ..., - type: ChannelType = ..., - overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., - ) -> TextChannel | None: - ... - - @overload - async def edit(self) -> TextChannel | None: - ... - - async def edit(self, *, reason=None, **options): - """|coro| - - Edits the channel. - - You must have the :attr:`~Permissions.manage_channels` permission to - use this. - - .. versionchanged:: 1.3 - The ``overwrites`` keyword-only parameter was added. - - .. versionchanged:: 1.4 - The ``type`` keyword-only parameter was added. - - .. versionchanged:: 2.0 - Edits are no longer in-place, the newly edited channel is returned instead. - - Parameters - ---------- - name: :class:`str` - The new channel name. - topic: :class:`str` - The new channel's topic. - position: :class:`int` - The new channel's position. - nsfw: :class:`bool` - To mark the channel as NSFW or not. - sync_permissions: :class:`bool` - Whether to sync permissions with the channel's new or pre-existing - category. Defaults to ``False``. - category: Optional[:class:`CategoryChannel`] - The new category for this channel. Can be ``None`` to remove the - category. - slowmode_delay: :class:`int` - Specifies the slowmode rate limit for user in this channel, in seconds. - A value of `0` disables slowmode. The maximum value possible is `21600`. - type: :class:`ChannelType` - Change the type of this text channel. Currently, only conversion between - :attr:`ChannelType.text` and :attr:`ChannelType.news` is supported. This - is only available to guilds that contain ``NEWS`` in :attr:`Guild.features`. - reason: Optional[:class:`str`] - The reason for editing this channel. Shows up on the audit log. - overwrites: Dict[Union[:class:`Role`, :class:`Member`, :class:`~discord.abc.Snowflake`], :class:`PermissionOverwrite`] - The overwrites to apply to channel permissions. Useful for creating secret channels. - default_auto_archive_duration: :class:`int` - The new default auto archive duration in minutes for threads created in this channel. - Must be one of ``60``, ``1440``, ``4320``, or ``10080``. - - Returns - ------- - Optional[:class:`.TextChannel`] - The newly edited text channel. If the edit was only positional - then ``None`` is returned instead. - - Raises - ------ - InvalidArgument - If position is less than 0 or greater than the number of channels, or if - the permission overwrite information is not in proper form. - Forbidden - You do not have permissions to edit the channel. - HTTPException - Editing the channel failed. - """ - - payload = await self._edit(options, reason=reason) - if payload is not None: - # the payload will always be the proper channel payload - return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore + async def edit(self, **options) -> _TextChannel: + """Edits the channel.""" + raise NotImplementedError @utils.copy_doc(discord.abc.GuildChannel.clone) async def clone( @@ -721,6 +729,10 @@ class TextChannel(discord.abc.Messageable, _TextChannel): Extra features of the channel. .. versionadded:: 2.0 + default_thread_slowmode_delay: Optional[:class:`int`] + The initial slowmode delay to set on newly created threads in this channel. + + .. versionadded:: 2.3 """ def __init__( @@ -747,6 +759,103 @@ def news(self) -> bool: """Equivalent to :meth:`is_news`.""" return self.is_news() + @overload + async def edit( + self, + *, + reason: str | None = ..., + name: str = ..., + topic: str = ..., + position: int = ..., + nsfw: bool = ..., + sync_permissions: bool = ..., + category: CategoryChannel | None = ..., + slowmode_delay: int = ..., + default_auto_archive_duration: ThreadArchiveDuration = ..., + default_thread_slowmode_delay: int = ..., + type: ChannelType = ..., + overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., + ) -> TextChannel | None: + ... + + @overload + async def edit(self) -> TextChannel | None: + ... + + async def edit(self, *, reason=None, **options): + """|coro| + + Edits the channel. + + You must have the :attr:`~Permissions.manage_channels` permission to + use this. + + .. versionchanged:: 1.3 + The ``overwrites`` keyword-only parameter was added. + + .. versionchanged:: 1.4 + The ``type`` keyword-only parameter was added. + + .. versionchanged:: 2.0 + Edits are no longer in-place, the newly edited channel is returned instead. + + Parameters + ---------- + name: :class:`str` + The new channel name. + topic: :class:`str` + The new channel's topic. + position: :class:`int` + The new channel's position. + nsfw: :class:`bool` + To mark the channel as NSFW or not. + sync_permissions: :class:`bool` + Whether to sync permissions with the channel's new or pre-existing + category. Defaults to ``False``. + category: Optional[:class:`CategoryChannel`] + The new category for this channel. Can be ``None`` to remove the + category. + slowmode_delay: :class:`int` + Specifies the slowmode rate limit for user in this channel, in seconds. + A value of `0` disables slowmode. The maximum value possible is `21600`. + type: :class:`ChannelType` + Change the type of this text channel. Currently, only conversion between + :attr:`ChannelType.text` and :attr:`ChannelType.news` is supported. This + is only available to guilds that contain ``NEWS`` in :attr:`Guild.features`. + reason: Optional[:class:`str`] + The reason for editing this channel. Shows up on the audit log. + overwrites: Dict[Union[:class:`Role`, :class:`Member`, :class:`~discord.abc.Snowflake`], :class:`PermissionOverwrite`] + The overwrites to apply to channel permissions. Useful for creating secret channels. + default_auto_archive_duration: :class:`int` + The new default auto archive duration in minutes for threads created in this channel. + Must be one of ``60``, ``1440``, ``4320``, or ``10080``. + default_thread_slowmode_delay: :class:`int` + The new default slowmode delay in seconds for threads created in this channel. + + .. versionadded:: 2.3 + + Returns + ------- + Optional[:class:`.TextChannel`] + The newly edited text channel. If the edit was only positional + then ``None`` is returned instead. + + Raises + ------ + InvalidArgument + If position is less than 0 or greater than the number of channels, or if + the permission overwrite information is not in proper form. + Forbidden + You do not have permissions to edit the channel. + HTTPException + Editing the channel failed. + """ + + payload = await self._edit(options, reason=reason) + if payload is not None: + # the payload will always be the proper channel payload + return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore + async def create_thread( self, *, @@ -883,6 +992,18 @@ class ForumChannel(_TextChannel): Extra features of the channel. .. versionadded:: 2.0 + available_tags: List[:class:`ForumTag`] + The set of tags that can be used in a forum channel. + + .. versionadded:: 2.3 + default_sort_order: Optional[:class:`SortOrder`] + The default sort order type used to order posts in this channel. + + .. versionadded:: 2.3 + default_thread_slowmode_delay: Optional[:class:`int`] + The initial slowmode delay to set on newly created threads in this channel. + + .. versionadded:: 2.3 """ def __init__( @@ -892,12 +1013,133 @@ def __init__( def _update(self, guild: Guild, data: ForumChannelPayload) -> None: super()._update(guild, data) + self.available_tags: list[ForumTag] = [ + ForumTag.from_data(state=self._state, data=tag) + for tag in (data.get("available_tags") or []) + ] + self.default_sort_order: SortOrder | None = data.get("default_sort_order", None) @property def guidelines(self) -> str | None: """The channel's guidelines. An alias of :attr:`topic`.""" return self.topic + @property + def requires_tag(self) -> bool: + """Whether a tag is required to be specified when creating a thread in this forum channel. + + Tags are specified in :attr:`applied_tags`. + + .. versionadded:: 2.3 + """ + return self.flags.require_tag + + def get_tag(self, id: int, /) -> ForumTag | None: + """Returns the :class:`ForumTag` from this forum channel with the + given ID, if any. + + .. versionadded:: 2.3 + """ + return utils.get(self.available_tags, id=id) + + @overload + async def edit( + self, + *, + reason: str | None = ..., + name: str = ..., + topic: str = ..., + position: int = ..., + nsfw: bool = ..., + sync_permissions: bool = ..., + category: CategoryChannel | None = ..., + slowmode_delay: int = ..., + default_auto_archive_duration: ThreadArchiveDuration = ..., + default_thread_slowmode_delay: int = ..., + default_sort_order: SortOrder = ..., + available_tags: list[ForumTag] = ..., + require_tag: bool = ..., + overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., + ) -> "ForumChannel" | None: + ... + + @overload + async def edit(self) -> "ForumChannel" | None: + ... + + async def edit(self, *, reason=None, **options): + """|coro| + + Edits the channel. + + You must have the :attr:`~Permissions.manage_channels` permission to + use this. + + Parameters + ---------- + name: :class:`str` + The new channel name. + topic: :class:`str` + The new channel's topic. + position: :class:`int` + The new channel's position. + nsfw: :class:`bool` + To mark the channel as NSFW or not. + sync_permissions: :class:`bool` + Whether to sync permissions with the channel's new or pre-existing + category. Defaults to ``False``. + category: Optional[:class:`CategoryChannel`] + The new category for this channel. Can be ``None`` to remove the + category. + slowmode_delay: :class:`int` + Specifies the slowmode rate limit for user in this channel, in seconds. + A value of `0` disables slowmode. The maximum value possible is `21600`. + reason: Optional[:class:`str`] + The reason for editing this channel. Shows up on the audit log. + overwrites: Dict[Union[:class:`Role`, :class:`Member`, :class:`~discord.abc.Snowflake`], :class:`PermissionOverwrite`] + The overwrites to apply to channel permissions. Useful for creating secret channels. + default_auto_archive_duration: :class:`int` + The new default auto archive duration in minutes for threads created in this channel. + Must be one of ``60``, ``1440``, ``4320``, or ``10080``. + default_thread_slowmode_delay: :class:`int` + The new default slowmode delay in seconds for threads created in this channel. + + .. versionadded:: 2.3 + default_sort_order: Optional[:class:`SortOrder`] + The default sort order type to use to order posts in this channel. + + .. versionadded:: 2.3 + available_tags: List[:class:`ForumTag`] + The set of tags that can be used in this channel. Must be less than `20`. + + .. versionadded:: 2.3 + require_tag: :class:`bool` + Whether a tag should be required to be specified when creating a thread in this channel. + + .. versionadded:: 2.3 + + Returns + ------- + Optional[:class:`.ForumChannel`] + The newly edited forum channel. If the edit was only positional + then ``None`` is returned instead. + + Raises + ------ + InvalidArgument + If position is less than 0 or greater than the number of channels, or if + the permission overwrite information is not in proper form. + Forbidden + You do not have permissions to edit the channel. + HTTPException + Editing the channel failed. + """ + + payload = await self._edit(options, reason=reason) + if payload is not None: + # the payload will always be the proper channel payload + return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore + async def create_thread( self, name: str, @@ -912,6 +1154,7 @@ async def create_thread( nonce=None, allowed_mentions=None, view=None, + applied_tags=None, auto_archive_duration: ThreadArchiveDuration = MISSING, slowmode_delay: int = MISSING, reason: str | None = None, @@ -955,6 +1198,8 @@ async def create_thread( are used instead. view: :class:`discord.ui.View` A Discord UI View to add to the message. + applied_tags: List[:class:`discord.ForumTag`] + A list of tags to apply to the new thread. auto_archive_duration: :class:`int` The duration in minutes before a thread is automatically archived for inactivity. If not provided, the channel's default auto archive duration is used. @@ -1019,6 +1264,9 @@ async def create_thread( else: components = None + if applied_tags is not None: + applied_tags = [str(tag.id) for tag in applied_tags] + if file is not None and files is not None: raise InvalidArgument("cannot pass both file and files parameter to send()") @@ -1078,6 +1326,7 @@ async def create_thread( auto_archive_duration=auto_archive_duration or self.default_auto_archive_duration, rate_limit_per_user=slowmode_delay or self.slowmode_delay, + applied_tags=applied_tags, reason=reason, ) ret = Thread(guild=self.guild, state=self._state, data=data) diff --git a/discord/enums.py b/discord/enums.py index 1a991a481a..322a408489 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -309,6 +309,16 @@ def __str__(self): return self.name +class SortOrder(Enum): + """Forum Channel Sort Order""" + + latest_activity = 0 + creation_date = 1 + + def __str__(self): + return self.name + + class ContentFilter(Enum, comparable=True): """Content Filter""" diff --git a/discord/flags.py b/discord/flags.py index 2241e90d2c..a378cd2437 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -1441,3 +1441,12 @@ class ChannelFlags(BaseFlags): def pinned(self): """:class:`bool`: Returns ``True`` if the thread is pinned to the top of its parent forum channel.""" return 1 << 1 + + @flag_value + def require_tag(self): + """:class:`bool`: Returns ``True`` if a tag is required to be specified when creating a thread in a + :class:`ForumChannel`. + + .. versionadded:: 2.2 + """ + return 1 << 4 diff --git a/discord/http.py b/discord/http.py index 7368296a84..79455a0879 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1043,6 +1043,12 @@ def edit_channel( "locked", "invitable", "default_auto_archive_duration", + "flags", + "default_thread_rate_limit_per_user", + "default_reaction_emoji", + "available_tags", + "applied_tags", + "default_sort_order", ) payload = {k: v for k, v in options.items() if k in valid_keys} return self.request(r, reason=reason, json=payload) @@ -1157,6 +1163,7 @@ def start_forum_thread( auto_archive_duration: threads.ThreadArchiveDuration, rate_limit_per_user: int, invitable: bool = True, + applied_tags: SnowflakeList | None = None, reason: str | None = None, embed: embed.Embed | None = None, embeds: list[embed.Embed] | None = None, @@ -1173,6 +1180,9 @@ def start_forum_thread( if content: payload["content"] = content + if applied_tags: + payload["applied_tags"] = applied_tags + if embed: payload["embeds"] = [embed] diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index a3ee406156..30832622ff 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -26,7 +26,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, TypedDict, TypeVar from . import utils from .asset import Asset, AssetMixin @@ -160,6 +160,17 @@ def to_dict(self) -> dict[str, Any]: def _to_partial(self) -> PartialEmoji: return self + def _to_forum_tag_payload( + self, + ) -> ( + TypedDict("TagPayload", {"emoji_id": int, "emoji_name": None}) + | TypedDict("TagPayload", {"emoji_id": None, "emoji_name": str}) + ): + if self.id is None: + return {"emoji_id": None, "emoji_name": self.name} + else: + return {"emoji_id": self.id, "emoji_name": None} + @classmethod def with_state( cls: type[PE], @@ -174,11 +185,12 @@ def with_state( return self def __str__(self) -> str: + # Emoji won't render if the name is empty + name = self.name or "_" if self.id is None: - return self.name - if self.animated: - return f"" - return f"<:{self.name}:{self.id}>" + return name + animated_tag = "a" if self.animated else "" + return f"<{animated_tag}:{name}:{self.id}>" def __repr__(self): return f"<{self.__class__.__name__} animated={self.animated} name={self.name!r} id={self.id}>" diff --git a/discord/threads.py b/discord/threads.py index 12a1285ece..96eee779ec 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -41,7 +41,7 @@ if TYPE_CHECKING: from .abc import Snowflake, SnowflakeTime - from .channel import CategoryChannel, ForumChannel, TextChannel + from .channel import CategoryChannel, ForumChannel, ForumTag, TextChannel from .guild import Guild from .member import Member from .message import Message, PartialMessage @@ -128,6 +128,12 @@ class Thread(Messageable, Hashable): Extra features of the thread. .. versionadded:: 2.0 + total_message_sent: :class:`int` + Number of messages ever sent in a thread. + It's similar to message_count on message creation, + but will not decrement the number when a message is deleted. + + .. versionadded:: 2.3 """ __slots__ = ( @@ -137,6 +143,7 @@ class Thread(Messageable, Hashable): "_type", "_state", "_members", + "_applied_tags", "owner_id", "parent_id", "last_message_id", @@ -151,6 +158,7 @@ class Thread(Messageable, Hashable): "archive_timestamp", "created_at", "flags", + "total_message_sent", ) def __init__(self, *, guild: Guild, state: ConnectionState, data: ThreadPayload): @@ -189,6 +197,8 @@ def _from_data(self, data: ThreadPayload): self.message_count = data.get("message_count", None) self.member_count = data.get("member_count", None) self.flags: ChannelFlags = ChannelFlags._from_value(data.get("flags", 0)) + self.total_message_sent = data.get("total_message_sent", None) + self._applied_tags: list[int] = data.get("applied_tags", []) # Here, we try to fill in potentially missing data if thread := self.guild.get_thread(self.id) and data.pop("_invoke_flag", False): @@ -203,6 +213,11 @@ def _from_data(self, data: ThreadPayload): if self.message_count is None else self.message_count ) + self.total_message_sent = ( + thread.total_message_sent + if self.total_message_sent is None + else self.total_message_sent + ) self.member_count = ( thread.member_count if self.member_count is None else self.member_count ) @@ -276,6 +291,22 @@ def members(self) -> list[ThreadMember]: """ return list(self._members.values()) + @property + def applied_tags(self) -> list[ForumTag]: + """List[:class:`ForumTag`]: A list of tags applied to this thread. + + This is only available for threads in forum channels. + """ + from .channel import ForumChannel # to prevent circular import + + if isinstance(self.parent, ForumChannel): + return [ + tag + for tag_id in self._applied_tags + if (tag := self.parent.get_tag(tag_id)) is not None + ] + return [] + @property def last_message(self) -> Message | None: """Returns the last message from this thread in cache. @@ -357,6 +388,13 @@ def starting_message(self) -> Message | None: """ return self._state._get_message(self.id) + def is_pinned(self) -> bool: + """Whether the thread is pinned to the top of its parent forum channel. + + .. versionadded:: 2.3 + """ + return self.flags.pinned + def is_private(self) -> bool: """Whether the thread is a private thread. @@ -561,6 +599,7 @@ async def edit( slowmode_delay: int = MISSING, auto_archive_duration: ThreadArchiveDuration = MISSING, pinned: bool = MISSING, + applied_tags: list[ForumTag] = MISSING, reason: str | None = None, ) -> Thread: """|coro| @@ -595,6 +634,10 @@ async def edit( The reason for editing this thread. Shows up on the audit log. pinned: :class:`bool` Whether to pin the thread or not. This only works if the thread is part of a forum. + applied_tags: List[:class:`ForumTag`] + The set of tags to apply to the thread. Each tag object should have an ID set. + + .. versionadded:: 2.3 Returns ------- @@ -626,6 +669,8 @@ async def edit( flags = ChannelFlags._from_value(self.flags.value) flags.pinned = pinned payload["flags"] = flags.value + if applied_tags is not MISSING: + payload["applied_tags"] = [tag.id for tag in applied_tags] data = await self._state.http.edit_channel(self.id, **payload, reason=reason) # The data payload will always be a Thread payload diff --git a/discord/types/channel.py b/discord/types/channel.py index ee3384e9f3..bc825a3f38 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -27,6 +27,8 @@ from typing import Literal, Union from .._typed_dict import NotRequired, TypedDict +from ..enums import SortOrder +from ..flags import ChannelFlags from .snowflake import Snowflake from .threads import ThreadArchiveDuration, ThreadMember, ThreadMetadata from .user import PartialUser @@ -67,14 +69,32 @@ class _TextChannelOptional(TypedDict, total=False): last_pin_timestamp: str rate_limit_per_user: int default_auto_archive_duration: ThreadArchiveDuration + default_thread_rate_limit_per_user: int class TextChannel(_BaseGuildChannel, _TextChannelOptional): type: Literal[0] +class DefaultReaction(TypedDict): + emoji_id: NotRequired[Snowflake | None] + emoji_name: NotRequired[str | None] + + +class ForumTag(TypedDict): + id: Snowflake + name: str + moderated: bool + emoji_id: NotRequired[Snowflake | None] + emoji_name: NotRequired[str | None] + + class ForumChannel(_BaseGuildChannel, _TextChannelOptional): type: Literal[15] + available_tags: NotRequired[list[ForumTag] | None] + default_reaction_emoji: NotRequired[DefaultReaction | None] + default_sort_order: NotRequired[SortOrder | None] + flags: ChannelFlags class NewsChannel(_BaseGuildChannel, _TextChannelOptional): @@ -117,6 +137,9 @@ class ThreadChannel(_BaseChannel): message_count: int member_count: int thread_metadata: ThreadMetadata + applied_tags: NotRequired[list[Snowflake] | None] + flags: ChannelFlags + total_message_sent: int GuildChannel = Union[ diff --git a/discord/types/threads.py b/discord/types/threads.py index 87abe9af4b..aab6d95794 100644 --- a/discord/types/threads.py +++ b/discord/types/threads.py @@ -28,6 +28,7 @@ from typing import Literal from .._typed_dict import NotRequired, TypedDict +from ..flags import ChannelFlags from .snowflake import Snowflake ThreadType = Literal[10, 11, 12] @@ -64,6 +65,8 @@ class Thread(TypedDict): message_count: int rate_limit_per_user: int thread_metadata: ThreadMetadata + flags: ChannelFlags + total_message_sent: int class ThreadPaginationPayload(TypedDict):