Browse Source

First pass at preliminary thread support

This is missing a lot of functionality right now, such as two gateway
events and all the HTTP CRUD endpoints.
feature/threads
Rapptz 4 years ago
parent
commit
68c7c538f5
  1. 1
      discord/__init__.py
  2. 8
      discord/channel.py
  3. 20
      discord/enums.py
  4. 7
      discord/flags.py
  5. 42
      discord/guild.py
  6. 77
      discord/http.py
  7. 41
      discord/state.py
  8. 244
      discord/threads.py
  9. 76
      docs/api.rst

1
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')

8
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

20
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):

7
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):

42
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|

77
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):

41
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:

244
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']

76
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
~~~~~~~~~~~~~

Loading…
Cancel
Save