Browse Source

Add support for newest ForumChannel changes

This adds the following:

- Forum tag support
- Default reaction support
- Default slowmode for newly created threads
pull/8429/head
Rapptz 3 years ago
parent
commit
ab265dcb7c
  1. 5
      discord/abc.py
  2. 134
      discord/audit_logs.py
  3. 266
      discord/channel.py
  4. 9
      discord/flags.py
  5. 5
      discord/http.py
  6. 13
      discord/partial_emoji.py
  7. 109
      discord/threads.py
  8. 31
      discord/types/audit_log.py
  9. 18
      discord/types/channel.py
  10. 32
      docs/api.rst

5
discord/abc.py

@ -418,6 +418,11 @@ class GuildChannel:
except KeyError:
pass
try:
options['default_thread_rate_limit_per_user'] = options.pop('default_thread_slowmode_delay')
except KeyError:
pass
try:
rtc_region = options.pop('rtc_region')
except KeyError:

134
discord/audit_logs.py

@ -36,13 +36,14 @@ from .permissions import PermissionOverwrite, Permissions
from .automod import AutoModTrigger, AutoModRuleAction, AutoModPresets, AutoModRule
from .role import Role
from .emoji import Emoji
from .partial_emoji import PartialEmoji
from .member import Member
from .scheduled_event import ScheduledEvent
from .stage_instance import StageInstance
from .sticker import GuildSticker
from .threads import Thread
from .integrations import PartialIntegration
from .channel import StageChannel
from .channel import ForumChannel, StageChannel, ForumTag
__all__ = (
'AuditLogDiff',
@ -63,6 +64,8 @@ if TYPE_CHECKING:
)
from .types.channel import (
PermissionOverwrite as PermissionOverwritePayload,
ForumTag as ForumTagPayload,
DefaultReaction as DefaultReactionPayload,
)
from .types.invite import Invite as InvitePayload
from .types.role import Role as RolePayload
@ -130,6 +133,44 @@ def _transform_roles(entry: AuditLogEntry, data: List[Snowflake]) -> List[Union[
return [entry.guild.get_role(int(role_id)) or Object(role_id, type=Role) for role_id in data]
def _transform_applied_forum_tags(entry: AuditLogEntry, data: List[Snowflake]) -> List[Union[ForumTag, Object]]:
thread = entry.target
if isinstance(thread, Thread) and isinstance(thread.parent, ForumChannel):
return [thread.parent.get_tag(tag_id) or Object(id=tag_id, type=ForumTag) for tag_id in map(int, data)]
return [Object(id=tag_id, type=ForumTag) for tag_id in data]
def _transform_overloaded_flags(entry: AuditLogEntry, data: int) -> Union[int, flags.ChannelFlags]:
# The `flags` key is definitely overloaded. Right now it's for channels and threads but
# I am aware of `member.flags` and `user.flags` existing. However, this does not impact audit logs
# at the moment but better safe than sorry.
channel_audit_log_types = (
enums.AuditLogAction.channel_create,
enums.AuditLogAction.channel_update,
enums.AuditLogAction.channel_delete,
enums.AuditLogAction.thread_create,
enums.AuditLogAction.thread_update,
enums.AuditLogAction.thread_delete,
)
if entry.action in channel_audit_log_types:
return flags.ChannelFlags._from_value(data)
return data
def _transform_forum_tags(entry: AuditLogEntry, data: List[ForumTagPayload]) -> List[ForumTag]:
return [ForumTag.from_data(state=entry._state, data=d) for d in data]
def _transform_default_reaction(entry: AuditLogEntry, data: DefaultReactionPayload) -> Optional[PartialEmoji]:
if data is None:
return None
emoji_name = data.get('emoji_name') or ''
emoji_id = utils._get_as_snowflake(data, 'emoji_id') or None # Coerce 0 -> None
return PartialEmoji.with_state(state=entry._state, name=emoji_name, id=emoji_id)
def _transform_overwrites(
entry: AuditLogEntry, data: List[PermissionOverwritePayload]
) -> List[Tuple[Object, PermissionOverwrite]]:
@ -272,49 +313,54 @@ Transformer = Callable[["AuditLogEntry", Any], Any]
class AuditLogChanges:
# fmt: off
TRANSFORMERS: ClassVar[Dict[str, Tuple[Optional[str], Optional[Transformer]]]] = {
'verification_level': (None, _enum_transformer(enums.VerificationLevel)),
'explicit_content_filter': (None, _enum_transformer(enums.ContentFilter)),
'allow': (None, _flag_transformer(Permissions)),
'deny': (None, _flag_transformer(Permissions)),
'permissions': (None, _flag_transformer(Permissions)),
'id': (None, _transform_snowflake),
'color': ('colour', _transform_color),
'owner_id': ('owner', _transform_member_id),
'inviter_id': ('inviter', _transform_member_id),
'channel_id': ('channel', _transform_channel),
'afk_channel_id': ('afk_channel', _transform_channel),
'system_channel_id': ('system_channel', _transform_channel),
'system_channel_flags': (None, _flag_transformer(flags.SystemChannelFlags)),
'widget_channel_id': ('widget_channel', _transform_channel),
'rules_channel_id': ('rules_channel', _transform_channel),
'public_updates_channel_id': ('public_updates_channel', _transform_channel),
'permission_overwrites': ('overwrites', _transform_overwrites),
'splash_hash': ('splash', _guild_hash_transformer('splashes')),
'banner_hash': ('banner', _guild_hash_transformer('banners')),
'discovery_splash_hash': ('discovery_splash', _guild_hash_transformer('discovery-splashes')),
'icon_hash': ('icon', _transform_icon),
'avatar_hash': ('avatar', _transform_avatar),
'rate_limit_per_user': ('slowmode_delay', None),
'guild_id': ('guild', _transform_guild_id),
'tags': ('emoji', None),
'default_message_notifications': ('default_notifications', _enum_transformer(enums.NotificationLevel)),
'video_quality_mode': (None, _enum_transformer(enums.VideoQualityMode)),
'privacy_level': (None, _enum_transformer(enums.PrivacyLevel)),
'format_type': (None, _enum_transformer(enums.StickerFormatType)),
'type': (None, _transform_type),
'communication_disabled_until': ('timed_out_until', _transform_timestamp),
'expire_behavior': (None, _enum_transformer(enums.ExpireBehaviour)),
'mfa_level': (None, _enum_transformer(enums.MFALevel)),
'status': (None, _enum_transformer(enums.EventStatus)),
'entity_type': (None, _enum_transformer(enums.EntityType)),
'preferred_locale': (None, _enum_transformer(enums.Locale)),
'image_hash': ('cover_image', _transform_cover_image),
'trigger_type': (None, _enum_transformer(enums.AutoModRuleTriggerType)),
'event_type': (None, _enum_transformer(enums.AutoModRuleEventType)),
'trigger_metadata': ('trigger', _transform_automod_trigger_metadata),
'actions': (None, _transform_automod_actions),
'exempt_channels': (None, _transform_channels_or_threads),
'exempt_roles': (None, _transform_roles),
'verification_level': (None, _enum_transformer(enums.VerificationLevel)),
'explicit_content_filter': (None, _enum_transformer(enums.ContentFilter)),
'allow': (None, _flag_transformer(Permissions)),
'deny': (None, _flag_transformer(Permissions)),
'permissions': (None, _flag_transformer(Permissions)),
'id': (None, _transform_snowflake),
'color': ('colour', _transform_color),
'owner_id': ('owner', _transform_member_id),
'inviter_id': ('inviter', _transform_member_id),
'channel_id': ('channel', _transform_channel),
'afk_channel_id': ('afk_channel', _transform_channel),
'system_channel_id': ('system_channel', _transform_channel),
'system_channel_flags': (None, _flag_transformer(flags.SystemChannelFlags)),
'widget_channel_id': ('widget_channel', _transform_channel),
'rules_channel_id': ('rules_channel', _transform_channel),
'public_updates_channel_id': ('public_updates_channel', _transform_channel),
'permission_overwrites': ('overwrites', _transform_overwrites),
'splash_hash': ('splash', _guild_hash_transformer('splashes')),
'banner_hash': ('banner', _guild_hash_transformer('banners')),
'discovery_splash_hash': ('discovery_splash', _guild_hash_transformer('discovery-splashes')),
'icon_hash': ('icon', _transform_icon),
'avatar_hash': ('avatar', _transform_avatar),
'rate_limit_per_user': ('slowmode_delay', None),
'default_thread_rate_limit_per_user': ('default_thread_slowmode_delay', None),
'guild_id': ('guild', _transform_guild_id),
'tags': ('emoji', None),
'default_message_notifications': ('default_notifications', _enum_transformer(enums.NotificationLevel)),
'video_quality_mode': (None, _enum_transformer(enums.VideoQualityMode)),
'privacy_level': (None, _enum_transformer(enums.PrivacyLevel)),
'format_type': (None, _enum_transformer(enums.StickerFormatType)),
'type': (None, _transform_type),
'communication_disabled_until': ('timed_out_until', _transform_timestamp),
'expire_behavior': (None, _enum_transformer(enums.ExpireBehaviour)),
'mfa_level': (None, _enum_transformer(enums.MFALevel)),
'status': (None, _enum_transformer(enums.EventStatus)),
'entity_type': (None, _enum_transformer(enums.EntityType)),
'preferred_locale': (None, _enum_transformer(enums.Locale)),
'image_hash': ('cover_image', _transform_cover_image),
'trigger_type': (None, _enum_transformer(enums.AutoModRuleTriggerType)),
'event_type': (None, _enum_transformer(enums.AutoModRuleEventType)),
'trigger_metadata': ('trigger', _transform_automod_trigger_metadata),
'actions': (None, _transform_automod_actions),
'exempt_channels': (None, _transform_channels_or_threads),
'exempt_roles': (None, _transform_roles),
'applied_tags': (None, _transform_applied_forum_tags),
'available_tags': (None, _transform_forum_tags),
'flags': (None, _transform_overloaded_flags),
'default_reaction_emoji': (None, _transform_default_reaction),
}
# fmt: on

266
discord/channel.py

@ -55,6 +55,8 @@ from .asset import Asset
from .errors import ClientException
from .stage_instance import StageInstance
from .threads import Thread
from .partial_emoji import _EmojiTag, PartialEmoji
from .flags import ChannelFlags
from .http import handle_message_parameters
__all__ = (
@ -63,6 +65,7 @@ __all__ = (
'StageChannel',
'DMChannel',
'CategoryChannel',
'ForumTag',
'ForumChannel',
'GroupChannel',
'PartialMessageable',
@ -77,7 +80,7 @@ if TYPE_CHECKING:
from .member import Member, VoiceState
from .abc import Snowflake, SnowflakeTime
from .embeds import Embed
from .message import Message, PartialMessage
from .message import Message, PartialMessage, EmojiInputType
from .mentions import AllowedMentions
from .webhook import Webhook
from .state import ConnectionState
@ -95,6 +98,7 @@ if TYPE_CHECKING:
CategoryChannel as CategoryChannelPayload,
GroupDMChannel as GroupChannelPayload,
ForumChannel as ForumChannelPayload,
ForumTag as ForumTagPayload,
)
from .types.snowflake import SnowflakeList
@ -156,6 +160,10 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable):
The default auto archive duration in minutes for threads created in this channel.
.. versionadded:: 2.0
default_thread_slowmode_delay: :class:`int`
The default slowmode delay in seconds for threads created in this channel.
.. versionadded:: 2.1
"""
__slots__ = (
@ -172,6 +180,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable):
'_type',
'last_message_id',
'default_auto_archive_duration',
'default_thread_slowmode_delay',
)
def __init__(self, *, state: ConnectionState, guild: Guild, data: Union[TextChannelPayload, NewsChannelPayload]):
@ -202,6 +211,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable):
# Does this need coercion into `int`? No idea yet.
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.default_thread_slowmode_delay: int = data.get('default_thread_rate_limit_per_user', 0)
self._type: Literal[0, 5] = data.get('type', self._type)
self.last_message_id: Optional[int] = utils._get_as_snowflake(data, 'last_message_id')
self._fill_overwrites(data)
@ -296,6 +306,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable):
category: Optional[CategoryChannel] = ...,
slowmode_delay: int = ...,
default_auto_archive_duration: ThreadArchiveDuration = ...,
default_thread_slowmode_delay: int = ...,
type: ChannelType = ...,
overwrites: Mapping[OverwriteKeyT, PermissionOverwrite] = ...,
) -> TextChannel:
@ -354,6 +365,12 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable):
The new default auto archive duration in minutes for threads created in this channel.
Must be one of ``60``, ``1440``, ``4320``, or ``10080``.
.. versionadded:: 2.0
default_thread_slowmode_delay: :class:`int`
The new default slowmode delay in seconds for threads created in this channel.
.. versionadded:: 2.1
Raises
------
ValueError
@ -1967,6 +1984,89 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable):
return await self.guild.create_forum(name, category=self, **options)
class ForumTag(Hashable):
"""Represents a forum tag that can be applied to a thread within a :class:`ForumChannel`.
.. versionadded:: 2.1
.. 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 ID of the tag. If this was manually created then the ID 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 not {emoji.__class__!r}')
@classmethod
def from_data(cls, *, state: ConnectionState, data: ForumTagPayload) -> Self:
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 # Coerce 0 -> 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,
}
payload.update(self.emoji._to_forum_tag_payload())
if self.id:
payload['id'] = self.id
return payload
def __repr__(self) -> str:
return f'<ForumTag id={self.id} name={self.name!r} emoji={self.emoji!r} moderated={self.moderated}>'
def __str__(self) -> str:
return self.name
class ForumChannel(discord.abc.GuildChannel, Hashable):
"""Represents a Discord guild forum channel.
@ -2001,7 +2101,8 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
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.
The forum's topic. ``None`` if it doesn't exist. Called "Guidelines" in the UI.
Can be up to 4096 characters long.
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.
@ -2018,6 +2119,15 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
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.
default_thread_slowmode_delay: :class:`int`
The default slowmode delay in seconds for threads created in this forum.
.. versionadded:: 2.1
default_reaction_emoji: Optional[:class:`PartialEmoji`]
The default reaction emoji for threads created in this forum to show in the
add reaction button.
.. versionadded:: 2.1
"""
__slots__ = (
@ -2034,6 +2144,10 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
'_overwrites',
'last_message_id',
'default_auto_archive_duration',
'default_thread_slowmode_delay',
'default_reaction_emoji',
'_available_tags',
'_flags',
)
def __init__(self, *, state: ConnectionState, guild: Guild, data: ForumChannelPayload):
@ -2062,6 +2176,21 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
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')
# This takes advantage of the fact that dicts are ordered since Python 3.7
tags = [ForumTag.from_data(state=self._state, data=tag) for tag in data.get('available_tags', [])]
self.default_thread_slowmode_delay: int = data.get('default_thread_slowmode_delay', 0)
self._available_tags: Dict[int, ForumTag] = {tag.id: tag for tag in tags}
self.default_reaction_emoji: Optional[PartialEmoji] = None
default_reaction_emoji = data.get('default_reaction_emoji')
if default_reaction_emoji:
self.default_reaction_emoji = PartialEmoji.with_state(
state=self._state,
id=utils._get_as_snowflake(default_reaction_emoji, 'emoji_id') or None, # Coerce 0 -> None
name=default_reaction_emoji.get('emoji_name') or '',
)
self._flags: int = data.get('flags', 0)
self._fill_overwrites(data)
@property
@ -2091,6 +2220,39 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
"""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]
@property
def flags(self) -> ChannelFlags:
""":class:`ChannelFlags`: The flags associated with this thread.
.. versionadded:: 2.1
"""
return ChannelFlags._from_value(self._flags)
@property
def available_tags(self) -> Sequence[ForumTag]:
"""Sequence[:class:`ForumTag`]: Returns all the available tags for this forum.
.. versionadded:: 2.1
"""
return utils.SequenceProxy(self._available_tags.values())
def get_tag(self, tag_id: int, /) -> Optional[ForumTag]:
"""Returns the tag with the given ID.
.. versionadded:: 2.1
Parameters
----------
tag_id: :class:`int`
The ID to search for.
Returns
-------
Optional[:class:`ForumTag`]
The tag with the given ID, or ``None`` if not found.
"""
return self._available_tags.get(tag_id)
def is_nsfw(self) -> bool:
""":class:`bool`: Checks if the forum is NSFW."""
return self.nsfw
@ -2124,6 +2286,10 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
default_auto_archive_duration: ThreadArchiveDuration = ...,
type: ChannelType = ...,
overwrites: Mapping[OverwriteKeyT, PermissionOverwrite] = ...,
available_tags: Sequence[ForumTag] = ...,
default_thread_slowmode_delay: int = ...,
default_reaction_emoji: Optional[EmojiInputType] = ...,
require_tag: bool = ...,
) -> ForumChannel:
...
@ -2166,6 +2332,22 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
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``.
available_tags: Sequence[:class:`ForumTag`]
The new available tags for this forum.
.. versionadded:: 2.1
default_thread_slowmode_delay: :class:`int`
The new default slowmode delay for threads in this channel.
.. versionadded:: 2.1
default_reaction_emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]]
The new default reaction emoji for threads in this channel.
.. versionadded:: 2.1
require_tag: :class:`bool`
Whether to require a tag for threads in this channel or not.
.. versionadded:: 2.1
Raises
------
@ -2185,11 +2367,91 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
then ``None`` is returned instead.
"""
try:
tags: Sequence[ForumTag] = options.pop('available_tags')
except KeyError:
pass
else:
options['available_tags'] = [tag.to_dict() for tag in tags]
try:
default_reaction_emoji: Optional[EmojiInputType] = options.pop('default_reaction_emoji')
except KeyError:
pass
else:
if default_reaction_emoji is None:
options['default_reaction_emoji'] = None
elif isinstance(default_reaction_emoji, _EmojiTag):
options['default_reaction_emoji'] = default_reaction_emoji._to_partial()._to_forum_tag_payload()
elif isinstance(default_reaction_emoji, str):
options['default_reaction_emoji'] = PartialEmoji.from_str(default_reaction_emoji)._to_forum_tag_payload()
try:
require_tag = options.pop('require_tag')
except KeyError:
pass
else:
flags = self.flags
flags.require_tag = require_tag
options['flags'] = flags.value
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_tag(
self,
*,
name: str,
emoji: PartialEmoji,
moderated: bool = False,
reason: Optional[str] = None,
) -> ForumTag:
"""|coro|
Creates a new tag in this forum.
You must have the :attr:`~Permissions.manage_channels` permission to
use this.
Parameters
----------
name: :class:`str`
The name of the tag. Can only be up to 20 characters.
emoji: Union[:class:`str`, :class:`PartialEmoji`]
The emoji to use for the tag.
moderated: :class:`bool`
Whether the tag can only be applied by moderators.
reason: Optional[:class:`str`]
The reason for creating this tag. Shows up on the audit log.
Raises
------
Forbidden
You do not have permissions to create a tag in this forum.
HTTPException
Creating the tag failed.
Returns
-------
:class:`ForumTag`
The newly created tag.
"""
prior = list(self._available_tags.values())
result = ForumTag(name=name, emoji=emoji, moderated=moderated)
prior.append(result)
payload = await self._state.http.edit_channel(
self.id, reason=reason, available_tags=[tag.to_dict() for tag in prior]
)
try:
result.id = int(payload['available_tags'][-1]['id']) # type: ignore
except (KeyError, IndexError, ValueError):
pass
return result
async def create_thread(
self,
*,

9
discord/flags.py

@ -1490,6 +1490,15 @@ class ChannelFlags(BaseFlags):
""":class:`bool`: Returns ``True`` if the thread is pinned to the 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.1
"""
return 1 << 4
class ArrayFlags(BaseFlags):
@classmethod

5
discord/http.py

@ -1143,7 +1143,12 @@ class HTTPClient:
'invitable',
'default_auto_archive_duration',
'flags',
'default_thread_rate_limit_per_user',
'default_reaction_emoji',
'available_tags',
'applied_tags',
)
payload = {k: v for k, v in options.items() if k in valid_keys}
return self.request(r, reason=reason, json=payload)

13
discord/partial_emoji.py

@ -162,6 +162,11 @@ class PartialEmoji(_EmojiTag, AssetMixin):
def _to_partial(self) -> PartialEmoji:
return self
def _to_forum_tag_payload(self) -> Dict[str, Any]:
if self.id is not None:
return {'emoji_id': self.id, 'emoji_name': None}
return {'emoji_id': None, 'emoji_name': self.name}
@classmethod
def with_state(
cls,
@ -176,11 +181,13 @@ class PartialEmoji(_EmojiTag, AssetMixin):
return self
def __str__(self) -> str:
# Coerce empty names to _ so it renders in the client regardless of having no name
name = self.name or '_'
if self.id is None:
return self.name
return name
if self.animated:
return f'<a:{self.name}:{self.id}>'
return f'<:{self.name}:{self.id}>'
return f'<a:{name}:{self.id}>'
return f'<:{name}:{self.id}>'
def __repr__(self) -> str:
return f'<{self.__class__.__name__} animated={self.animated} name={self.name!r} id={self.id}>'

109
discord/threads.py

@ -24,15 +24,16 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
from typing import Callable, Dict, Iterable, List, Literal, Optional, Union, TYPE_CHECKING
from typing import Callable, Dict, Iterable, List, Literal, Optional, Sequence, Union, TYPE_CHECKING
from datetime import datetime
import array
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
from .utils import MISSING, parse_time, _get_as_snowflake, _unique
__all__ = (
'Thread',
@ -50,7 +51,7 @@ if TYPE_CHECKING:
)
from .types.snowflake import SnowflakeList
from .guild import Guild
from .channel import TextChannel, CategoryChannel, ForumChannel
from .channel import TextChannel, CategoryChannel, ForumChannel, ForumTag
from .member import Member
from .message import Message, PartialMessage
from .abc import Snowflake, SnowflakeTime
@ -149,6 +150,7 @@ class Thread(Messageable, Hashable):
'archive_timestamp',
'_created_at',
'_flags',
'_applied_tags',
)
def __init__(self, *, guild: Guild, state: ConnectionState, data: ThreadPayload) -> None:
@ -180,6 +182,8 @@ class Thread(Messageable, Hashable):
self.message_count: int = data['message_count']
self.member_count: int = data['member_count']
self._flags: int = data.get('flags', 0)
# SnowflakeList is sorted, but this would not be proper for applied tags, where order actually matters.
self._applied_tags: array.array[int] = array.array('Q', map(int, data.get('applied_tags', [])))
self._unroll_metadata(data['thread_metadata'])
self.me: Optional[ThreadMember]
@ -255,6 +259,24 @@ class Thread(Messageable, Hashable):
"""
return list(self._members.values())
@property
def applied_tags(self) -> List[ForumTag]:
"""List[:class:`ForumTag`]: A list of tags applied to this thread.
.. versionadded:: 2.1
"""
tags = []
if self.parent is None or self.parent.type != ChannelType.forum:
return tags
parent = self.parent
for tag_id in self._applied_tags:
tag = parent.get_tag(tag_id)
if tag is not None:
tags.append(tag)
return tags
@property
def starter_message(self) -> Optional[Message]:
"""Returns the thread starter message from the cache.
@ -542,6 +564,7 @@ class Thread(Messageable, Hashable):
pinned: bool = MISSING,
slowmode_delay: int = MISSING,
auto_archive_duration: ThreadArchiveDuration = MISSING,
applied_tags: Sequence[ForumTag] = MISSING,
reason: Optional[str] = None,
) -> Thread:
"""|coro|
@ -574,6 +597,10 @@ class Thread(Messageable, Hashable):
slowmode_delay: :class:`int`
Specifies the slowmode rate limit for user in this thread, in seconds.
A value of ``0`` disables slowmode. The maximum value possible is ``21600``.
applied_tags: Sequence[:class:`ForumTag`]
The new tags to apply to the thread. There can only be up to 5 tags applied to a thread.
.. versionadded:: 2.1
reason: Optional[:class:`str`]
The reason for editing this thread. Shows up on the audit log.
@ -606,11 +633,87 @@ class Thread(Messageable, Hashable):
flags = self.flags
flags.pinned = pinned
payload['flags'] = flags.value
if applied_tags is not MISSING:
payload['applied_tags'] = [str(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
return Thread(data=data, state=self._state, guild=self.guild) # type: ignore
async def add_tags(self, *tags: Snowflake, reason: Optional[str] = None) -> None:
r"""|coro|
Adds the given forum tags to a thread.
You must have the :attr:`~Permissions.manage_threads` permission to
use this or the thread must be owned by you.
Tags that have :attr:`ForumTag.moderated` set to ``True`` require the
:attr:`~Permissions.manage_threads` permissions to be added.
The maximum number of tags that can be added to a thread is 5.
The parent channel must be a :class:`ForumChannel`.
.. versionadded:: 2.1
Parameters
-----------
\*tags: :class:`abc.Snowflake`
An argument list of :class:`abc.Snowflake` representing a :class:`ForumTag`
to add to the thread.
reason: Optional[:class:`str`]
The reason for adding these tags.
Raises
-------
Forbidden
You do not have permissions to add these tags.
HTTPException
Adding tags failed.
"""
applied_tags = [str(tag) for tag in self._applied_tags]
applied_tags.extend(str(tag.id) for tag in tags)
await self._state.http.edit_channel(self.id, applied_tags=_unique(applied_tags), reason=reason)
async def remove_tags(self, *tags: Snowflake, reason: Optional[str] = None) -> None:
r"""|coro|
Remove the given forum tags to a thread.
You must have the :attr:`~Permissions.manage_threads` permission to
use this or the thread must be owned by you.
The parent channel must be a :class:`ForumChannel`.
.. versionadded:: 2.1
Parameters
-----------
\*tags: :class:`abc.Snowflake`
An argument list of :class:`abc.Snowflake` representing a :class:`ForumTag`
to remove to the thread.
reason: Optional[:class:`str`]
The reason for removing these tags.
Raises
-------
Forbidden
You do not have permissions to remove these tags.
HTTPException
Removing tags failed.
"""
# Once again, taking advantage of the fact that dicts are ordered since 3.7
applied_tags: Dict[str, Literal[None]] = {str(tag): None for tag in self._applied_tags}
for tag in tags:
applied_tags.pop(str(tag.id), None)
await self._state.http.edit_channel(self.id, applied_tags=list(applied_tags.keys()), reason=reason)
async def join(self) -> None:
"""|coro|

31
discord/types/audit_log.py

@ -34,7 +34,7 @@ from .user import User
from .scheduled_event import EntityType, EventStatus, GuildScheduledEvent
from .snowflake import Snowflake
from .role import Role
from .channel import ChannelType, PrivacyLevel, VideoQualityMode, PermissionOverwrite
from .channel import ChannelType, DefaultReaction, PrivacyLevel, VideoQualityMode, PermissionOverwrite, ForumTag
from .threads import Thread
from .command import ApplicationCommand, ApplicationCommandPermissions
@ -87,6 +87,12 @@ AuditLogEvent = Literal[
111,
112,
121,
140,
141,
142,
143,
144,
145,
]
@ -166,7 +172,9 @@ class _AuditLogChange_Int(TypedDict):
'user_limit',
'auto_archive_duration',
'default_auto_archive_duration',
'default_thread_rate_limit_per_user',
'communication_disabled_until',
'flags',
]
new_value: int
old_value: int
@ -250,6 +258,24 @@ class _AuditLogChange_AppCommandPermissions(TypedDict):
old_value: ApplicationCommandPermissions
class _AuditLogChange_AppliedTags(TypedDict):
key: Literal['applied_tags']
new_value: List[Snowflake]
old_value: List[Snowflake]
class _AuditLogChange_AvailableTags(TypedDict):
key: Literal['available_tags']
new_value: List[ForumTag]
old_value: List[ForumTag]
class _AuditLogChange_DefaultReactionEmoji(TypedDict):
key: Literal['default_reaction_emoji']
new_value: Optional[DefaultReaction]
old_value: Optional[DefaultReaction]
AuditLogChange = Union[
_AuditLogChange_Str,
_AuditLogChange_AssetHash,
@ -269,6 +295,9 @@ AuditLogChange = Union[
_AuditLogChange_Status,
_AuditLogChange_EntityType,
_AuditLogChange_AppCommandPermissions,
_AuditLogChange_AppliedTags,
_AuditLogChange_AvailableTags,
_AuditLogChange_DefaultReactionEmoji,
]

18
discord/types/channel.py

@ -66,6 +66,7 @@ class _BaseTextChannel(_BaseGuildChannel, total=False):
last_message_id: Optional[Snowflake]
last_pin_timestamp: str
rate_limit_per_user: int
default_thread_rate_limit_per_user: int
default_auto_archive_duration: ThreadArchiveDuration
@ -117,10 +118,27 @@ class ThreadChannel(_BaseChannel):
last_message_id: NotRequired[Optional[Snowflake]]
last_pin_timestamp: NotRequired[str]
flags: NotRequired[int]
applied_tags: NotRequired[List[Snowflake]]
class DefaultReaction(TypedDict):
emoji_id: Optional[Snowflake]
emoji_name: Optional[str]
class ForumTag(TypedDict):
id: Snowflake
name: str
moderated: bool
emoji_id: Optional[Snowflake]
emoji_name: Optional[str]
class ForumChannel(_BaseTextChannel):
type: Literal[15]
available_tags: List[ForumTag]
default_reaction_emoji: Optional[DefaultReaction]
flags: NotRequired[int]
GuildChannel = Union[TextChannel, NewsChannel, VoiceChannel, CategoryChannel, StageChannel, ThreadChannel, ForumChannel]

32
docs/api.rst

@ -4397,6 +4397,14 @@ GuildSticker
.. autoclass:: GuildSticker()
:members:
ShardInfo
~~~~~~~~~~~
.. attributetable:: ShardInfo
.. autoclass:: ShardInfo()
:members:
RawMessageDeleteEvent
~~~~~~~~~~~~~~~~~~~~~~~
@ -4710,20 +4718,12 @@ PermissionOverwrite
.. autoclass:: PermissionOverwrite
:members:
ShardInfo
~~~~~~~~~~~
.. attributetable:: ShardInfo
.. autoclass:: ShardInfo()
:members:
SystemChannelFlags
~~~~~~~~~~~~~~~~~~~~
.. attributetable:: SystemChannelFlags
.. autoclass:: SystemChannelFlags()
.. autoclass:: SystemChannelFlags
:members:
MessageFlags
@ -4731,7 +4731,7 @@ MessageFlags
.. attributetable:: MessageFlags
.. autoclass:: MessageFlags()
.. autoclass:: MessageFlags
:members:
PublicUserFlags
@ -4739,9 +4739,19 @@ PublicUserFlags
.. attributetable:: PublicUserFlags
.. autoclass:: PublicUserFlags()
.. autoclass:: PublicUserFlags
:members:
ForumTag
~~~~~~~~~
.. attributetable:: ForumTag
.. autoclass:: ForumTag
:members:
Exceptions
------------

Loading…
Cancel
Save