From 23f68764929e3ea57477866c234a5ad116df4f4e Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 6 Apr 2022 22:42:18 -0400 Subject: [PATCH] Add initial support for forum channels Closes #7652 --- discord/abc.py | 10 +- discord/channel.py | 355 +++++++++++++++++++++++++++++++++++++++ discord/enums.py | 1 + discord/flags.py | 37 ++++ discord/guild.py | 4 +- discord/http.py | 17 ++ discord/interactions.py | 6 +- discord/state.py | 3 + discord/threads.py | 14 +- discord/types/channel.py | 9 +- discord/types/threads.py | 1 + docs/api.rst | 24 +++ 12 files changed, 467 insertions(+), 14 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 76ed86931..6eb6ed546 100644 --- a/discord/abc.py +++ b/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``, diff --git a/discord/channel.py b/discord/channel.py index e241c0e73..12286a399 100644 --- a/discord/channel.py +++ b/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 diff --git a/discord/enums.py b/discord/enums.py index be5902ddd..d0b69c560 100644 --- a/discord/enums.py +++ b/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 diff --git a/discord/flags.py b/discord/flags.py index d2cc8d148..3f7b6bef6 100644 --- a/discord/flags.py +++ b/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 diff --git a/discord/guild.py b/discord/guild.py index c780c9aea..26353d973 100644 --- a/discord/guild.py +++ b/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]] diff --git a/discord/http.py b/discord/http.py index 1f7f548cc..0efd55005 100644 --- a/discord/http.py +++ b/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)) diff --git a/discord/interactions.py b/discord/interactions.py index a72aff301..99deba0a1 100644 --- a/discord/interactions.py +++ b/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 diff --git a/discord/state.py b/discord/state.py index 6e542adb2..8599065c5 100644 --- a/discord/state.py +++ b/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) diff --git a/discord/threads.py b/discord/threads.py index 006f3c3b5..6123dbbb6 100644 --- a/discord/threads.py +++ b/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.""" diff --git a/discord/types/channel.py b/discord/types/channel.py index 3c02259f4..65f16f80a 100644 --- a/discord/types/channel.py +++ b/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): diff --git a/discord/types/threads.py b/discord/types/threads.py index 802354f3e..a0471c0df 100644 --- a/discord/types/threads.py +++ b/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): diff --git a/docs/api.rst b/docs/api.rst index a215e3c8a..92fd7ca00 100644 --- a/docs/api.rst +++ b/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 ~~~~~