Browse Source

Add initial support for forum channels

Closes #7652
pull/7855/head
Rapptz 3 years ago
parent
commit
23f6876492
  1. 10
      discord/abc.py
  2. 355
      discord/channel.py
  3. 1
      discord/enums.py
  4. 37
      discord/flags.py
  5. 4
      discord/guild.py
  6. 17
      discord/http.py
  7. 6
      discord/interactions.py
  8. 3
      discord/state.py
  9. 14
      discord/threads.py
  10. 9
      discord/types/channel.py
  11. 1
      discord/types/threads.py
  12. 24
      docs/api.rst

10
discord/abc.py

@ -1377,6 +1377,10 @@ class Messageable:
Indicates if the message should be sent using text-to-speech.
embed: :class:`~discord.Embed`
The rich embed for the content.
embeds: List[:class:`~discord.Embed`]
A list of embeds to upload. Must be a maximum of 10.
.. versionadded:: 2.0
file: :class:`~discord.File`
The file to upload.
files: List[:class:`~discord.File`]
@ -1412,10 +1416,6 @@ class Messageable:
.. versionadded:: 1.6
view: :class:`discord.ui.View`
A Discord UI View to add to the message.
embeds: List[:class:`~discord.Embed`]
A list of embeds to upload. Must be a maximum of 10.
.. versionadded:: 2.0
stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]]
A list of stickers to upload. Must be a maximum of 3.
@ -1432,7 +1432,7 @@ class Messageable:
~discord.Forbidden
You do not have the proper permissions to send the message.
ValueError
The ``files`` list is not of the appropriate size.
The ``files`` or ``embeds`` list is not of the appropriate size.
TypeError
You specified both ``file`` and ``files``,
or you specified both ``embed`` and ``embeds``,

355
discord/channel.py

@ -34,6 +34,7 @@ from typing import (
Mapping,
Optional,
TYPE_CHECKING,
Sequence,
Tuple,
Union,
overload,
@ -51,6 +52,7 @@ from .asset import Asset
from .errors import ClientException
from .stage_instance import StageInstance
from .threads import Thread
from .http import handle_message_parameters
__all__ = (
'TextChannel',
@ -58,6 +60,7 @@ __all__ = (
'StageChannel',
'DMChannel',
'CategoryChannel',
'ForumChannel',
'GroupChannel',
'PartialMessageable',
)
@ -69,11 +72,16 @@ if TYPE_CHECKING:
from .role import Role
from .member import Member, VoiceState
from .abc import Snowflake, SnowflakeTime
from .embeds import Embed
from .message import Message, PartialMessage
from .mentions import AllowedMentions
from .webhook import Webhook
from .state import ConnectionState
from .sticker import GuildSticker, StickerItem
from .file import File
from .user import ClientUser, User, BaseUser
from .guild import Guild, GuildChannel as GuildChannelType
from .ui.view import View
from .types.channel import (
TextChannel as TextChannelPayload,
VoiceChannel as VoiceChannelPayload,
@ -81,6 +89,7 @@ if TYPE_CHECKING:
DMChannel as DMChannelPayload,
CategoryChannel as CategoryChannelPayload,
GroupDMChannel as GroupChannelPayload,
ForumChannel as ForumChannelPayload,
)
from .types.snowflake import SnowflakeList
@ -1893,6 +1902,350 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable):
return await self.guild.create_stage_channel(name, category=self, **options)
class ForumChannel(discord.abc.GuildChannel, Hashable):
"""Represents a Discord guild forum channel.
.. versionadded:: 2.0
.. container:: operations
.. describe:: x == y
Checks if two forums are equal.
.. describe:: x != y
Checks if two forums are not equal.
.. describe:: hash(x)
Returns the forum's hash.
.. describe:: str(x)
Returns the forum's name.
Attributes
-----------
name: :class:`str`
The forum name.
guild: :class:`Guild`
The guild the forum belongs to.
id: :class:`int`
The forum ID.
category_id: Optional[:class:`int`]
The category channel ID this forum belongs to, if applicable.
topic: Optional[:class:`str`]
The forum's topic. ``None`` if it doesn't exist.
position: :class:`int`
The position in the channel list. This is a number that starts at 0. e.g. the
top channel is position 0.
last_message_id: Optional[:class:`int`]
The last thread ID that was created on this forum. This technically also
coincides with the message ID that started the thread that was created.
It may *not* point to an existing or valid thread or message.
slowmode_delay: :class:`int`
The number of seconds a member must wait between creating threads
in this forum. A value of `0` denotes that it is disabled.
Bots and users with :attr:`~Permissions.manage_channels` or
:attr:`~Permissions.manage_messages` bypass slowmode.
nsfw: :class:`bool`
If the forum is marked as "not safe for work" or "age restricted".
default_auto_archive_duration: :class:`int`
The default auto archive duration in minutes for threads created in this forum.
"""
__slots__ = (
'name',
'id',
'guild',
'topic',
'_state',
'_flags',
'nsfw',
'category_id',
'position',
'slowmode_delay',
'_overwrites',
'last_message_id',
'default_auto_archive_duration',
)
def __init__(self, *, state: ConnectionState, guild: Guild, data: ForumChannelPayload):
self._state: ConnectionState = state
self.id: int = int(data['id'])
self._update(guild, data)
def __repr__(self) -> str:
attrs = [
('id', self.id),
('name', self.name),
('position', self.position),
('nsfw', self.nsfw),
('category_id', self.category_id),
]
joined = ' '.join('%s=%r' % t for t in attrs)
return f'<{self.__class__.__name__} {joined}>'
def _update(self, guild: Guild, data: ForumChannelPayload) -> None:
self.guild: Guild = guild
self.name: str = data['name']
self.category_id: Optional[int] = utils._get_as_snowflake(data, 'parent_id')
self.topic: Optional[str] = data.get('topic')
self.position: int = data['position']
self.nsfw: bool = data.get('nsfw', False)
self.slowmode_delay: int = data.get('rate_limit_per_user', 0)
self.default_auto_archive_duration: ThreadArchiveDuration = data.get('default_auto_archive_duration', 1440)
self.last_message_id: Optional[int] = utils._get_as_snowflake(data, 'last_message_id')
self._fill_overwrites(data)
@property
def type(self) -> ChannelType:
""":class:`ChannelType`: The channel's Discord type."""
return ChannelType.forum
@property
def _sorting_bucket(self) -> int:
return ChannelType.text.value
@utils.copy_doc(discord.abc.GuildChannel.permissions_for)
def permissions_for(self, obj: Union[Member, Role], /) -> Permissions:
base = super().permissions_for(obj)
# text channels do not have voice related permissions
denied = Permissions.voice()
base.value &= ~denied.value
return base
@property
def threads(self) -> List[Thread]:
"""List[:class:`Thread`]: Returns all the threads that you can see."""
return [thread for thread in self.guild._threads.values() if thread.parent_id == self.id]
def is_nsfw(self) -> bool:
""":class:`bool`: Checks if the forum is NSFW."""
return self.nsfw
@utils.copy_doc(discord.abc.GuildChannel.clone)
async def clone(self, *, name: Optional[str] = None, reason: Optional[str] = None) -> ForumChannel:
return await self._clone_impl(
{'topic': self.topic, 'nsfw': self.nsfw, 'rate_limit_per_user': self.slowmode_delay}, name=name, reason=reason
)
@overload
async def edit(
self,
*,
reason: Optional[str] = ...,
name: str = ...,
topic: str = ...,
position: int = ...,
nsfw: bool = ...,
sync_permissions: bool = ...,
category: Optional[CategoryChannel] = ...,
slowmode_delay: int = ...,
default_auto_archive_duration: ThreadArchiveDuration = ...,
type: ChannelType = ...,
overwrites: Mapping[Union[Role, Member, Snowflake], PermissionOverwrite] = ...,
) -> Optional[ForumChannel]:
...
@overload
async def edit(self) -> Optional[ForumChannel]:
...
async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optional[ForumChannel]:
"""|coro|
Edits the forum.
You must have the :attr:`~Permissions.manage_channels` permission to
use this.
Parameters
----------
name: :class:`str`
The new forum name.
topic: :class:`str`
The new forum's topic.
position: :class:`int`
The new forum's position.
nsfw: :class:`bool`
To mark the forum as NSFW or not.
sync_permissions: :class:`bool`
Whether to sync permissions with the forum's new or pre-existing
category. Defaults to ``False``.
category: Optional[:class:`CategoryChannel`]
The new category for this forum. Can be ``None`` to remove the
category.
slowmode_delay: :class:`int`
Specifies the slowmode rate limit for user in this forum, in seconds.
A value of `0` disables slowmode. The maximum value possible is `21600`.
type: :class:`ChannelType`
Change the type of this text forum. 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 forum. Shows up on the audit log.
overwrites: :class:`Mapping`
A :class:`Mapping` of target (either a role or a member) to
:class:`PermissionOverwrite` to apply to the forum.
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``.
Raises
------
ValueError
The new ``position`` is less than 0 or greater than the number of channels.
TypeError
The permission overwrite information is not in proper form.
Forbidden
You do not have permissions to edit the forum.
HTTPException
Editing the forum failed.
Returns
--------
Optional[:class:`.ForumChannel`]
The newly edited forum channel. If the edit was only positional
then ``None`` is returned instead.
"""
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,
auto_archive_duration: ThreadArchiveDuration = MISSING,
slowmode_delay: Optional[int] = None,
content: Optional[str] = None,
tts: bool = False,
embed: Embed = MISSING,
embeds: Sequence[Embed] = MISSING,
file: File = MISSING,
files: Sequence[File] = MISSING,
stickers: Sequence[Union[GuildSticker, StickerItem]] = MISSING,
allowed_mentions: AllowedMentions = MISSING,
mention_author: bool = MISSING,
view: View = MISSING,
suppress_embeds: bool = False,
reason: Optional[str] = None,
) -> Thread:
"""|coro|
Creates a thread in this forum.
This thread is a public thread with the initial message given. Currently in order
to start a thread in this forum, the user needs :attr:`~discord.Permissions.send_messages`.
Parameters
-----------
name: :class:`str`
The name of the 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.
slowmode_delay: Optional[:class:`int`]
Specifies the slowmode rate limit for user in this channel, in seconds.
The maximum value possible is `21600`. By default no slowmode rate limit
if this is ``None``.
content: Optional[:class:`str`]
The content of the message to send with the thread.
tts: :class:`bool`
Indicates if the message should be sent using text-to-speech.
embed: :class:`~discord.Embed`
The rich embed for the content.
embeds: List[:class:`~discord.Embed`]
A list of embeds to upload. Must be a maximum of 10.
file: :class:`~discord.File`
The file to upload.
files: List[:class:`~discord.File`]
A list of files to upload. Must be a maximum of 10.
allowed_mentions: :class:`~discord.AllowedMentions`
Controls the mentions being processed in this message. If this is
passed, then the object is merged with :attr:`~discord.Client.allowed_mentions`.
The merging behaviour only overrides attributes that have been explicitly passed
to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`.
If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions`
are used instead.
mention_author: :class:`bool`
If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``.
view: :class:`discord.ui.View`
A Discord UI View to add to the message.
stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]]
A list of stickers to upload. Must be a maximum of 3.
suppress_embeds: :class:`bool`
Whether to suppress embeds for the message. This sends the message without any embeds if set to ``True``.
reason: :class:`str`
The reason for creating a new thread. Shows up on the audit log.
Raises
-------
Forbidden
You do not have permissions to create a thread.
HTTPException
Starting the thread failed.
ValueError
The ``files`` or ``embeds`` list is not of the appropriate size.
TypeError
You specified both ``file`` and ``files``,
or you specified both ``embed`` and ``embeds``.
Returns
--------
:class:`Thread`
The created thread
"""
state = self._state
previous_allowed_mention = state.allowed_mentions
if stickers is MISSING:
sticker_ids = MISSING
else:
sticker_ids: SnowflakeList = [s.id for s in stickers]
if view and not hasattr(view, '__discord_ui_view__'):
raise TypeError(f'view parameter must be View not {view.__class__!r}')
if suppress_embeds:
from .message import MessageFlags # circular import
flags = MessageFlags._from_value(4)
else:
flags = MISSING
content = str(content) if content else MISSING
extras = {
'name': name,
'auto_archive_duration': auto_archive_duration or self.default_auto_archive_duration,
'rate_limit_per_user': slowmode_delay,
}
with handle_message_parameters(
content=content,
tts=tts,
file=file,
files=files,
embed=embed,
embeds=embeds,
allowed_mentions=allowed_mentions,
previous_allowed_mentions=previous_allowed_mention,
mention_author=None if mention_author is MISSING else mention_author,
stickers=sticker_ids,
view=view,
flags=flags,
extras=extras,
) as params:
data = await state.http.start_thread_in_forum(self.id, params=params, reason=reason)
return Thread(guild=self.guild, state=self._state, data=data)
class DMChannel(discord.abc.Messageable, Hashable):
"""Represents a Discord direct message channel.
@ -2251,6 +2604,8 @@ def _guild_channel_factory(channel_type: int):
return TextChannel, value
elif value is ChannelType.stage_voice:
return StageChannel, value
elif value is ChannelType.forum:
return ForumChannel, value
else:
return None, value

1
discord/enums.py

@ -195,6 +195,7 @@ class ChannelType(Enum):
public_thread = 11
private_thread = 12
stage_voice = 13
forum = 15
def __str__(self) -> str:
return self.name

37
discord/flags.py

@ -39,6 +39,7 @@ __all__ = (
'Intents',
'MemberCacheFlags',
'ApplicationFlags',
'ChannelFlags',
)
BF = TypeVar('BF', bound='BaseFlags')
@ -1175,3 +1176,39 @@ class ApplicationFlags(BaseFlags):
""":class:`bool`: Returns ``True`` if the application is unverified and is allowed to
read message content in guilds."""
return 1 << 19
@fill_with_flags()
class ChannelFlags(BaseFlags):
r"""Wraps up the Discord :class:`~discord.abc.GuildChannel` or :class:`Thread` flags.
.. container:: operations
.. describe:: x == y
Checks if two channel flags are equal.
.. describe:: x != y
Checks if two channel flags are not equal.
.. describe:: hash(x)
Return the flag's hash.
.. describe:: iter(x)
Returns an iterator of ``(name, value)`` pairs. This allows it
to be, for example, constructed as a dict or a list of pairs.
Note that aliases are not shown.
.. versionadded:: 2.0
Attributes
-----------
value: :class:`int`
The raw value. You should query flags via the properties
rather than using this raw value.
"""
@flag_value
def pinned(self):
""":class:`bool`: Returns ``True`` if the"""
return 1 << 1

4
discord/guild.py

@ -109,7 +109,7 @@ if TYPE_CHECKING:
)
from .types.voice import GuildVoiceState
from .permissions import Permissions
from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel
from .channel import VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel
from .template import Template
from .webhook import Webhook
from .state import ConnectionState
@ -127,7 +127,7 @@ if TYPE_CHECKING:
from .types.widget import EditWidgetSettings
VocalGuildChannel = Union[VoiceChannel, StageChannel]
GuildChannel = Union[VocalGuildChannel, TextChannel, CategoryChannel]
GuildChannel = Union[VocalGuildChannel, ForumChannel, TextChannel, CategoryChannel]
ByCategoryItem = Tuple[Optional[CategoryChannel], List[GuildChannel]]

17
discord/http.py

@ -147,6 +147,7 @@ def handle_message_parameters(
stickers: Optional[SnowflakeList] = MISSING,
previous_allowed_mentions: Optional[AllowedMentions] = None,
mention_author: Optional[bool] = None,
extras: Dict[str, Any] = MISSING,
) -> MultipartParameters:
if files is not MISSING and file is not MISSING:
raise TypeError('Cannot mix file and files keyword arguments.')
@ -234,6 +235,9 @@ def handle_message_parameters(
payload['attachments'] = attachments_payload
if extras is not MISSING:
payload.update(extras)
multipart = []
if files:
multipart.append({'name': 'payload_json', 'value': utils._to_json(payload)})
@ -976,6 +980,19 @@ class HTTPClient:
route = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id)
return self.request(route, json=payload, reason=reason)
def start_thread_in_forum(
self,
channel_id: Snowflake,
*,
params: MultipartParameters,
reason: Optional[str] = None,
) -> Response[threads.Thread]:
r = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id)
if params.files:
return self.request(r, files=params.files, form=params.multipart, reason=reason)
else:
return self.request(r, json=params.payload, reason=reason)
def join_thread(self, channel_id: Snowflake) -> Response[None]:
return self.request(Route('POST', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id))

6
discord/interactions.py

@ -69,11 +69,13 @@ if TYPE_CHECKING:
from .ui.view import View
from .app_commands.models import Choice, ChoiceT
from .ui.modal import Modal
from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel
from .channel import VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel
from .threads import Thread
from .app_commands.commands import Command, ContextMenu
InteractionChannel = Union[VoiceChannel, StageChannel, TextChannel, CategoryChannel, Thread, PartialMessageable]
InteractionChannel = Union[
VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel, Thread, PartialMessageable
]
MISSING: Any = utils.MISSING

3
discord/state.py

@ -851,6 +851,9 @@ class ConnectionState:
guild._add_thread(thread)
if not has_thread:
if data.get('newly_created'):
if thread.parent.__class__ is ForumChannel:
thread.parent.last_message_id = thread.id # type: ignore
self.dispatch('thread_create', thread)
else:
self.dispatch('thread_join', thread)

14
discord/threads.py

@ -31,6 +31,7 @@ from .mixins import Hashable
from .abc import Messageable, _purge_helper
from .enums import ChannelType, try_enum
from .errors import ClientException
from .flags import ChannelFlags
from .utils import MISSING, parse_time, _get_as_snowflake
__all__ = (
@ -49,7 +50,7 @@ if TYPE_CHECKING:
)
from .types.snowflake import SnowflakeList
from .guild import Guild
from .channel import TextChannel, CategoryChannel
from .channel import TextChannel, CategoryChannel, ForumChannel
from .member import Member
from .message import Message, PartialMessage
from .abc import Snowflake, SnowflakeTime
@ -145,6 +146,7 @@ class Thread(Messageable, Hashable):
'auto_archive_duration',
'archive_timestamp',
'_created_at',
'_flags',
)
def __init__(self, *, guild: Guild, state: ConnectionState, data: ThreadPayload) -> None:
@ -175,6 +177,7 @@ class Thread(Messageable, Hashable):
self.slowmode_delay: int = data.get('rate_limit_per_user', 0)
self.message_count: int = data['message_count']
self.member_count: int = data['member_count']
self._flags: int = data.get('flags', 0)
self._unroll_metadata(data['thread_metadata'])
self.me: Optional[ThreadMember]
@ -213,10 +216,15 @@ class Thread(Messageable, Hashable):
return self._type
@property
def parent(self) -> Optional[TextChannel]:
"""Optional[:class:`TextChannel`]: The parent channel this thread belongs to."""
def parent(self) -> Optional[Union[ForumChannel, TextChannel]]:
"""Optional[Union[:class:`ForumChannel`, :class:`TextChannel`]]: The parent channel this thread belongs to."""
return self.guild.get_channel(self.parent_id) # type: ignore
@property
def flags(self) -> ChannelFlags:
""":class:`ChannelFlags`: The flags associated with this thread."""
return ChannelFlags._from_value(self._flags)
@property
def owner(self) -> Optional[Member]:
"""Optional[:class:`Member`]: The member this thread belongs to."""

9
discord/types/channel.py

@ -40,7 +40,7 @@ class PermissionOverwrite(TypedDict):
deny: str
ChannelTypeWithoutThread = Literal[0, 1, 2, 3, 4, 5, 6, 13]
ChannelTypeWithoutThread = Literal[0, 1, 2, 3, 4, 5, 6, 13, 15]
ChannelType = Union[ChannelTypeWithoutThread, ThreadType]
@ -116,9 +116,14 @@ class ThreadChannel(_BaseChannel):
rate_limit_per_user: NotRequired[int]
last_message_id: NotRequired[Optional[Snowflake]]
last_pin_timestamp: NotRequired[str]
flags: NotRequired[int]
GuildChannel = Union[TextChannel, NewsChannel, VoiceChannel, CategoryChannel, StageChannel, ThreadChannel]
class ForumChannel(_BaseTextChannel):
type: Literal[15]
GuildChannel = Union[TextChannel, NewsChannel, VoiceChannel, CategoryChannel, StageChannel, ThreadChannel, ForumChannel]
class DMChannel(_BaseChannel):

1
discord/types/threads.py

@ -65,6 +65,7 @@ class Thread(TypedDict):
last_message_id: NotRequired[Optional[Snowflake]]
last_pin_timestamp: NotRequired[Optional[Snowflake]]
newly_created: NotRequired[bool]
flags: NotRequired[int]
class ThreadPaginationPayload(TypedDict):

24
docs/api.rst

@ -1310,6 +1310,12 @@ of :class:`enum.Enum`.
.. versionadded:: 2.0
.. attribute:: forum
A forum channel.
.. versionadded:: 2.0
.. class:: MessageType
Specifies the type of :class:`Message`. This is used to denote if a message
@ -3736,6 +3742,15 @@ TextChannel
.. automethod:: typing
:async-with:
ForumChannel
~~~~~~~~~~~~~
.. attributetable:: ForumChannel
.. autoclass:: ForumChannel()
:members:
:inherited-members:
Thread
~~~~~~~~
@ -4069,6 +4084,15 @@ ApplicationFlags
.. autoclass:: ApplicationFlags
:members:
ChannelFlags
~~~~~~~~~~~~~~
.. attributetable:: ChannelFlags
.. autoclass:: ChannelFlags
:members:
File
~~~~~

Loading…
Cancel
Save