diff --git a/discord/__init__.py b/discord/__init__.py index dcf421ff6..b2c5a078d 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -58,6 +58,7 @@ from .sticker import * from .stage_instance import * from .interactions import * from .components import * +from .threads import * VersionInfo = namedtuple('VersionInfo', 'major minor micro releaselevel serial') diff --git a/discord/channel.py b/discord/channel.py index 743619f4e..5179c8c4b 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -173,6 +173,14 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): """List[:class:`Member`]: Returns all members that can see this channel.""" return [m for m in self.guild.members if self.permissions_for(m).read_messages] + @property + def threads(self): + """List[:class:`Thread`]: Returns all the threads that you can see. + + .. versionadded:: 2.0 + """ + return [thread for thread in self.guild.threads if thread.parent_id == self.id] + def is_nsfw(self): """:class:`bool`: Checks if the channel is NSFW.""" return self.nsfw diff --git a/discord/enums.py b/discord/enums.py index d716fa6f5..c145c8291 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -155,14 +155,16 @@ else: return value class ChannelType(Enum): - text = 0 - private = 1 - voice = 2 - group = 3 - category = 4 - news = 5 - store = 6 - stage_voice = 13 + text = 0 + private = 1 + voice = 2 + group = 3 + category = 4 + news = 5 + store = 6 + public_thread = 11 + private_thread = 12 + stage_voice = 13 def __str__(self): return self.name @@ -186,8 +188,10 @@ class MessageType(Enum): guild_discovery_requalified = 15 guild_discovery_grace_period_initial_warning = 16 guild_discovery_grace_period_final_warning = 17 + thread_created = 18 reply = 19 application_command = 20 + thread_starter_message = 21 guild_invite_reminder = 22 class VoiceRegion(Enum): diff --git a/discord/flags.py b/discord/flags.py index d6a892c83..0ca6e34db 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -279,6 +279,13 @@ class MessageFlags(BaseFlags): """ return 16 + @flag_value + def has_thread(self): + """:class:`bool`: Returns ``True`` if the source message is associated with a thread. + + .. versionadded:: 2.0 + """ + return 32 @fill_with_flags() class PublicUserFlags(BaseFlags): diff --git a/discord/guild.py b/discord/guild.py index e97569057..c373cb528 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -47,6 +47,7 @@ from .asset import Asset from .flags import SystemChannelFlags from .integrations import Integration, _integration_factory from .stage_instance import StageInstance +from .threads import Thread __all__ = ( 'Guild', @@ -182,7 +183,7 @@ class Guild(Hashable): 'description', 'max_presences', 'max_members', 'max_video_channel_users', 'premium_tier', 'premium_subscription_count', '_system_channel_flags', 'preferred_locale', '_discovery_splash', '_rules_channel_id', - '_public_updates_channel_id', '_stage_instances', 'nsfw_level') + '_public_updates_channel_id', '_stage_instances', 'nsfw_level', '_threads') _PREMIUM_GUILD_LIMITS = { None: _GuildLimit(emoji=50, bitrate=96e3, filesize=8388608), @@ -196,6 +197,7 @@ class Guild(Hashable): self._channels = {} self._members = {} self._voice_states = {} + self._threads = {} self._state = state self._from_data(data) @@ -214,6 +216,12 @@ class Guild(Hashable): def _remove_member(self, member): self._members.pop(member.id, None) + def _add_thread(self, thread): + self._threads[thread.id] = thread + + def _remove_thread(self, thread): + self._threads.pop(thread.id, None) + def __str__(self): return self.name or '' @@ -360,11 +368,24 @@ class Guild(Hashable): if factory: self._add_channel(factory(guild=self, data=c, state=self._state)) + if 'threads' in data: + threads = data['threads'] + for thread in threads: + self._add_thread(Thread(guild=self, data=thread)) + @property def channels(self): """List[:class:`abc.GuildChannel`]: A list of channels that belongs to this guild.""" return list(self._channels.values()) + @property + def threads(self): + """List[:class:`Thread`]: A list of threads that you have permission to view. + + .. versionadded:: 2.0 + """ + return list(self._threads.values()) + @property def large(self): """:class:`bool`: Indicates if the guild is a 'large' guild. @@ -484,6 +505,23 @@ class Guild(Hashable): """ return self._channels.get(channel_id) + def get_thread(self, thread_id): + """Returns a thread with the given ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + thread_id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`Thread`] + The returned thread or ``None`` if not found. + """ + return self._threads.get(thread_id) + @property def system_channel(self): """Optional[:class:`TextChannel`]: Returns the guild's channel used for system messages. @@ -2377,7 +2415,7 @@ class Guild(Hashable): data = await self._state.http.get_widget(self.id) return Widget(state=self._state, data=data) - + async def edit_widget(self, *, enabled: bool = utils.MISSING, channel: Optional[abc.Snowflake] = utils.MISSING) -> None: """|coro| diff --git a/discord/http.py b/discord/http.py index 3c4d8400a..ee93d1a92 100644 --- a/discord/http.py +++ b/discord/http.py @@ -695,6 +695,8 @@ class HTTPClient: 'type', 'rtc_region', 'video_quality_mode', + 'archived', + 'auto_archive_duration', ) payload = {k: v for k, v in options.items() if k in valid_keys} return self.request(r, reason=reason, json=payload) @@ -720,6 +722,7 @@ class HTTPClient: 'rate_limit_per_user', 'rtc_region', 'video_quality_mode', + 'auto_archive_duration', ) payload.update({k: v for k, v in options.items() if k in valid_keys and v is not None}) @@ -728,6 +731,80 @@ class HTTPClient: def delete_channel(self, channel_id, *, reason=None): return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id), reason=reason) + # Thread management + + def start_public_thread( + self, + channel_id: int, + message_id: int, + *, + name: str, + auto_archive_duration: int, + type: int, + ): + payload = { + 'name': name, + 'auto_archive_duration': auto_archive_duration, + 'type': type, + } + + route = Route( + 'POST', '/channels/{channel_id}/messages/{message_id}/threads', channel_id=channel_id, message_id=message_id + ) + return self.request(route, json=payload) + + def start_private_thread( + self, + channel_id: int, + *, + name: str, + auto_archive_duration: int, + type: int, + ): + payload = { + 'name': name, + 'auto_archive_duration': auto_archive_duration, + 'type': type, + } + + route = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id) + return self.request(route, json=payload) + + def join_thread(self, channel_id: int): + return self.request(Route('POST', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id)) + + def add_user_to_thread(self, channel_id: int, user_id: int): + return self.request( + Route('POST', '/channels/{channel_id}/thread-members/{user_id}', channel_id=channel_id, user_id=user_id) + ) + + def leave_thread(self, channel_id: int): + return self.request(Route('DELETE', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id)) + + def remove_user_from_thread(self, channel_id: int, user_id: int): + route = Route('DELETE', '/channels/{channel_id}/thread-members/{user_id}', channel_id=channel_id, user_id=user_id) + return self.request(route) + + def get_archived_threads(self, channel_id: int, before=None, limit: int = 50, public: bool = True): + if public: + route = Route('GET', '/channels/{channel_id}/threads/archived/public', channel_id=channel_id) + else: + route = Route('GET', '/channels/{channel_id}/threads/archived/private', channel_id=channel_id) + + params = {} + if before: + params['before'] = before + params['limit'] = limit + return self.request(route, params=params) + + def get_joined_private_archived_threads(self, channel_id, before=None, limit: int = 50): + route = Route('GET', '/channels/{channel_id}/users/@me/threads/archived/private', channel_id=channel_id) + params = {} + if before: + params['before'] = before + params['limit'] = limit + return self.request(route, params=params) + # Webhook management def create_webhook(self, channel_id, *, name, avatar=None, reason=None): diff --git a/discord/state.py b/discord/state.py index f9c028567..7c1183d7a 100644 --- a/discord/state.py +++ b/discord/state.py @@ -55,6 +55,7 @@ from .integrations import _integration_factory from .interactions import Interaction from .ui.view import ViewStore from .stage_instance import StageInstance +from .threads import Thread, ThreadMember class ChunkRequest: def __init__(self, guild_id, loop, resolver, *, cache=True): @@ -483,7 +484,7 @@ class ConnectionState: self.dispatch('message', message) if self._messages is not None: self._messages.append(message) - if channel and channel.__class__ is TextChannel: + if channel and channel.__class__ in (TextChannel, Thread): channel.last_message_id = message.id def parse_message_delete(self, data): @@ -704,6 +705,44 @@ class ConnectionState: else: self.dispatch('guild_channel_pins_update', channel, last_pin) + def parse_thread_create(self, data): + guild_id = int(data['guild_id']) + guild = self._get_guild(guild_id) + if guild is None: + log.debug('THREAD_CREATE referencing an unknown guild ID: %s. Discarding', guild_id) + return + + thread = Thread(guild=guild, data=data) + guild._add_thread(thread) + self.dispatch('thread_create', thread) + + def parse_thread_update(self, data): + guild_id = int(data['guild_id']) + guild = self._get_guild(guild_id) + if guild is None: + log.debug('THREAD_UPDATE referencing an unknown guild ID: %s. Discarding', guild_id) + return + + thread_id = int(data['id']) + thread = guild._get_thread(thread_id) + if thread is not None: + old = copy.copy(thread) + thread._update(data) + self.dispatch('thread_update', old, thread) + + def parse_thread_delete(self, data): + guild_id = int(data['guild_id']) + guild = self._get_guild(guild_id) + if guild is None: + log.debug('THREAD_UPDATE referencing an unknown guild ID: %s. Discarding', guild_id) + return + + thread_id = int(data['id']) + thread = guild._get_thread(thread_id) + if thread is not None: + guild._remove_thread(thread) + self.dispatch('thread_delete', thread) + def parse_guild_member_add(self, data): guild = self._get_guild(int(data['guild_id'])) if guild is None: diff --git a/discord/threads.py b/discord/threads.py new file mode 100644 index 000000000..cde4a3299 --- /dev/null +++ b/discord/threads.py @@ -0,0 +1,244 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations +from typing import Optional, TYPE_CHECKING + +from .mixins import Hashable +from .abc import Messageable +from .enums import ChannelType, try_enum +from . import utils + +__all__ = ( + 'Thread', + 'ThreadMember', +) + +if TYPE_CHECKING: + from .types.threads import ( + Thread as ThreadPayload, + ThreadMember as ThreadMemberPayload, + ThreadMetadata, + ) + from .guild import Guild + from .channel import TextChannel + from .member import Member + + +class Thread(Messageable, Hashable): + """Represents a Discord thread. + + .. container:: operations + + .. describe:: x == y + + Checks if two threads are equal. + + .. describe:: x != y + + Checks if two threads are not equal. + + .. describe:: hash(x) + + Returns the thread's hash. + + .. describe:: str(x) + + Returns the thread's name. + + .. versionadded:: 2.0 + + Attributes + ----------- + name: :class:`str` + The thread name. + guild: :class:`Guild` + The guild the thread belongs to. + id: :class:`int` + The thread ID. + parent_id: :class:`int` + The parent :class:`TextChannel` ID this thread belongs to. + owner_id: :class:`int` + The user's ID that created this thread. + last_message_id: Optional[:class:`int`] + The last message ID of the message sent to this thread. It may + *not* point to an existing or valid message. + message_count: :class:`int` + An approximate number of messages in this thread. This caps at 50. + member_count: :class:`int` + An approximate number of members in this thread. This caps at 50. + me: Optional[:class:`ThreadMember`] + A thread member representing yourself, if you've joined the thread. + This could not be available. + archived: :class:`bool` + Whether the thread is archived. + archiver_id: Optional[:class:`int`] + The user's ID that archived this thread. + auto_archive_duration: :class:`int` + The duration in minutes until the thread is automatically archived due to inactivity. + Usually a value of 60, 1440, 4320 and 10080. + archive_timestamp: :class:`datetime.datetime` + An aware timestamp of when the thread's archived status was last updated in UTC. + """ + + __slots__ = ( + 'name', + 'id', + 'guild', + '_type', + '_state', + 'owner_id', + 'last_message_id', + 'message_count', + 'member_count', + 'me', + 'archived', + 'archiver_id', + 'auto_archive_duration', + 'archive_timestamp', + ) + + def __init__(self, *, guild: Guild, data: ThreadPayload): + self._state = guild._state + self.guild = guild + self._from_data(data) + + async def _get_channel(self): + return self + + def _from_data(self, data: ThreadPayload): + self.id = int(data['id']) + self.parent_id = int(data['parent_id']) + self.owner_id = int(data['owner_id']) + self.name = data['name'] + self.type = try_enum(ChannelType, data['type']) + self.last_message_id = utils._get_as_snowflake(data, 'last_message_id') + self._unroll_metadata(data['thread_metadata']) + + try: + member = data['member'] + except KeyError: + self.me = None + else: + self.me = ThreadMember(member, self._state) + + def _unroll_metadata(self, data: ThreadMetadata): + self.archived = data['archived'] + self.archiver_id = utils._get_as_snowflake(data, 'archiver_id') + self.auto_archive_duration = data['auto_archive_duration'] + self.archive_timestamp = utils.parse_time(data['archive_timestamp']) + + def _update(self, data): + try: + self.name = data['name'] + except KeyError: + pass + + try: + self._unroll_metadata(data['thread_metadata']) + except KeyError: + pass + + @property + def parent(self) -> Optional[TextChannel]: + """Optional[:class:`TextChannel`]: The parent channel this thread belongs to.""" + return self.guild.get_channel(self.parent_id) + + @property + def owner(self) -> Optional[Member]: + """Optional[:class:`Member`]: The member this thread belongs to.""" + return self.guild.get_member(self.owner_id) + + @property + def last_message(self): + """Fetches the last message from this channel in cache. + + The message might not be valid or point to an existing message. + + .. admonition:: Reliable Fetching + :class: helpful + + For a slightly more reliable method of fetching the + last message, consider using either :meth:`history` + or :meth:`fetch_message` with the :attr:`last_message_id` + attribute. + + Returns + --------- + Optional[:class:`Message`] + The last message in this channel or ``None`` if not found. + """ + return self._state._get_message(self.last_message_id) if self.last_message_id else None + + +class ThreadMember(Hashable): + """Represents a Discord thread member. + + .. container:: operations + + .. describe:: x == y + + Checks if two thread members are equal. + + .. describe:: x != y + + Checks if two thread members are not equal. + + .. describe:: hash(x) + + Returns the thread member's hash. + + .. describe:: str(x) + + Returns the thread member's name. + + .. versionadded:: 2.0 + + Attributes + ----------- + id: :class:`int` + The thread member's ID. + thread_id: :class:`int` + The thread's ID. + joined_at: :class:`datetime.datetime` + The time the member joined the thread in UTC. + """ + + __slots__ = ( + 'id', + 'thread_id', + 'joined_at', + 'flags', + '_state', + ) + + def __init__(self, data: ThreadMemberPayload, state): + self._state = state + self._from_data(data) + + def _from_data(self, data: ThreadMemberPayload): + self.id = int(data['user_id']) + self.thread_id = int(data['id']) + self.joined_at = utils.parse_time(data['join_timestamp']) + self.flags = data['flags'] diff --git a/docs/api.rst b/docs/api.rst index 68b055f41..c0c60d8c6 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -658,6 +658,33 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param last_pin: The latest message that was pinned as an aware datetime in UTC. Could be ``None``. :type last_pin: Optional[:class:`datetime.datetime`] +.. function:: on_thread_delete(thread) + on_thread_create(thread) + + Called whenever a thread is deleted or created. + + Note that you can get the guild from :attr:`Thread.guild`. + + This requires :attr:`Intents.guilds` to be enabled. + + .. versionadded:: 2.0 + + :param thread: The thread that got created or deleted. + :type thread: :class:`Thread` + +.. function:: on_thread_update(before, after) + + Called whenever a thread is updated. + + This requires :attr:`Intents.guilds` to be enabled. + + .. versionadded:: 2.0 + + :param before: The updated thread's old info. + :type before: :class:`Thread` + :param after: The updated thread's new info. + :type after: :class:`Thread` + .. function:: on_guild_integrations_update(guild) Called whenever an integration is created, modified, or removed from a guild. @@ -1038,6 +1065,18 @@ of :class:`enum.Enum`. .. versionadded:: 1.7 + .. attribute:: public_thread + + A public thread + + .. versionadded:: 2.0 + + .. attribute:: private_thread + + A private thread + + .. versionadded:: 1.8 + .. class:: MessageType Specifies the type of :class:`Message`. This is used to denote if a message @@ -1129,9 +1168,14 @@ of :class:`enum.Enum`. Discovery requirements for 3 weeks in a row. .. versionadded:: 1.7 + .. attribute:: thread_created + + The system message denoting that a thread has been created + + .. versionadded:: 2.0 .. attribute:: reply - The message type denoting that the author is replying to a message. + The system message denoting that the author is replying to a message. .. versionadded:: 2.0 .. attribute:: application_command @@ -1143,6 +1187,12 @@ of :class:`enum.Enum`. The system message sent as a reminder to invite people to the guild. + .. versionadded:: 2.0 + .. attribute:: thread_starter_message + + The system message denoting that this message is the one that started a thread's + conversation topic. + .. versionadded:: 2.0 .. class:: UserFlags @@ -3197,6 +3247,30 @@ TextChannel .. automethod:: typing :async-with: +Thread +~~~~~~~~ + +.. attributetable:: Thread + +.. autoclass:: Thread() + :members: + :inherited-members: + :exclude-members: history, typing + + .. automethod:: history + :async-for: + + .. automethod:: typing + :async-with: + +ThreadMember +~~~~~~~~~~~~~ + +.. attributetable:: ThreadMember + +.. autoclass:: ThreadMember() + :members: + StoreChannel ~~~~~~~~~~~~~