diff --git a/interactions/api/http/channel.py b/interactions/api/http/channel.py index 67ddfa17e..52112c273 100644 --- a/interactions/api/http/channel.py +++ b/interactions/api/http/channel.py @@ -307,3 +307,72 @@ async def delete_stage_instance(self, channel_id: int, reason: Optional[str] = N return await self._req.request( Route("DELETE", f"/stage-instances/{channel_id}"), reason=reason ) + + async def create_tag( + self, + channel_id: int, + name: str, + emoji_id: Optional[int] = None, + emoji_name: Optional[str] = None, + ) -> dict: + """ + Create a new tag. + + .. note:: + Can either have an emoji_id or an emoji_name, but not both. + emoji_id is meant for custom emojis, emoji_name is meant for unicode emojis. + + :param channel_id: Channel ID snowflake. + :param name: The name of the tag + :param emoji_id: The ID of the emoji to use for the tag + :param emoji_name: The name of the emoji to use for the tag + """ + + _dct = {"name": name} + if emoji_id: + _dct["emoji_id"] = emoji_id + if emoji_name: + _dct["emoji_name"] = emoji_name + + return await self._req.request(Route("POST", f"/channels/{channel_id}/tags"), json=_dct) + + async def edit_tag( + self, + channel_id: int, + tag_id: int, + name: str, + emoji_id: Optional[int] = None, + emoji_name: Optional[str] = None, + ) -> dict: + """ + Update a tag. + + .. note:: + Can either have an emoji_id or an emoji_name, but not both. + emoji_id is meant for custom emojis, emoji_name is meant for unicode emojis. + + :param channel_id: Channel ID snowflake. + :param tag_id: The ID of the tag to update. + :param name: The new name of the tag + :param emoji_id: The ID of the emoji to use for the tag + :param emoji_name: The name of the emoji to use for the tag + """ + + _dct = {"name": name} + if emoji_id: + _dct["emoji_id"] = emoji_id + if emoji_name: + _dct["emoji_name"] = emoji_name + + return await self._req.request( + Route("PUT", f"/channels/{channel_id}/tags/{tag_id}"), json=_dct + ) + + async def delete_tag(self, channel_id: int, tag_id: int) -> dict: + """ + Delete a forum tag. + + :param channel_id: Channel ID snowflake. + :param tag_id: The ID of the tag to delete + """ + return await self._req.request(Route("DELETE", f"/channels/{channel_id}/tags/{tag_id}")) diff --git a/interactions/api/http/thread.py b/interactions/api/http/thread.py index 6523e187c..ecf193c87 100644 --- a/interactions/api/http/thread.py +++ b/interactions/api/http/thread.py @@ -1,7 +1,11 @@ from typing import Dict, List, Optional +from aiohttp import MultipartWriter + from ...api.cache import Cache +from ...utils.missing import MISSING from ..models.channel import Channel +from ..models.misc import File from .request import _Request from .route import Route @@ -189,3 +193,62 @@ async def create_thread( self.cache[Channel].add(Channel(**request)) return request + + async def create_thread_in_forum( + self, + channel_id: int, + name: str, + auto_archive_duration: int, + message_payload: dict, + applied_tags: List[str] = None, + files: Optional[List[File]] = MISSING, + rate_limit_per_user: Optional[int] = None, + reason: Optional[str] = None, + ) -> dict: + """ + From a given Forum channel, create a Thread with a message to start with. + + :param channel_id: The ID of the channel to create this thread in + :param name: The name of the thread + :param auto_archive_duration: duration in minutes to automatically archive the thread after recent activity, + can be set to: 60, 1440, 4320, 10080 + :param message_payload: The payload/dictionary contents of the first message in the forum thread. + :param applied_tags: List of tag ids that can be applied to the forum, if any. + :param files: An optional list of files to send attached to the message. + :param rate_limit_per_user: Seconds a user has to wait before sending another message (0 to 21600), if given. + :param reason: An optional reason for the audit log + :return: Returns a Thread in a Forum object with a starting Message. + """ + query = {"use_nested_fields": 1} + + payload = {"name": name, "auto_archive_duration": auto_archive_duration} + if rate_limit_per_user: + payload["rate_limit_per_user"] = rate_limit_per_user + if applied_tags: + payload["applied_tags"] = applied_tags + + data = None + if files is not MISSING and len(files) > 0: + + data = MultipartWriter("form-data") + part = data.append_json(payload) + part.set_content_disposition("form-data", name="payload_json") + payload = None + + for id, file in enumerate(files): + part = data.append( + file._fp, + ) + part.set_content_disposition( + "form-data", name=f"files[{str(id)}]", filename=file._filename + ) + else: + payload.update(message_payload) + + return await self._req.request( + Route("POST", f"/channels/{channel_id}/threads"), + json=payload, + data=data, + params=query, + reason=reason, + ) diff --git a/interactions/api/models/channel.py b/interactions/api/models/channel.py index 0c4dfc0ef..fdb01e1ac 100644 --- a/interactions/api/models/channel.py +++ b/interactions/api/models/channel.py @@ -17,6 +17,7 @@ ) from ...utils.missing import MISSING from ..error import LibraryException +from .emoji import Emoji from .flags import Permissions from .misc import AllowedMentions, File, IDMixin, Overwrite, Snowflake from .user import User @@ -36,6 +37,8 @@ "ThreadMember", "ThreadMetadata", "AsyncHistoryIterator", + "AsyncTypingContextManager", + "Tags", ) @@ -53,6 +56,7 @@ class ChannelType(IntEnum): PUBLIC_THREAD = 11 PRIVATE_THREAD = 12 GUILD_STAGE_VOICE = 13 + GUILD_DIRECTORY = 14 GUILD_FORUM = 15 @@ -280,6 +284,30 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): self.__task.cancel() +@define() +class Tags(DictSerializerMixin): + """ + An object denoting a tag object within a forum channel. + + .. note:: + If the emoji is custom, it won't have name information. + + :ivar str name: Name of the tag. The limit is up to 20 characters. + :ivar int id: ID of the tag. Can also be 0 if manually created. + :ivar bool moderated: A boolean denoting whether this tag can be removed/added by moderators with ``manage_threads`` permissions. + :ivar Optional[Emoji] emoji?: The emoji to represent the tag, if any. + + """ + + # TODO: Rename these to discord-docs + name: str = field() + id: int = field() + moderated: bool = field() + emoji: Optional[Emoji] = field(converter=Emoji, default=None) + + # Maybe on post_attrs_init replace emoji object with one from cache for name population? + + @define() class Channel(ClientSerializerMixin, IDMixin): """ @@ -311,14 +339,20 @@ class Channel(ClientSerializerMixin, IDMixin): :ivar Optional[int] video_quality_mode?: The set quality mode for video streaming in the channel. :ivar int message_count: The amount of messages in the channel. :ivar Optional[int] member_count?: The amount of members in the channel. + :ivar Optional[bool] newly_created?: Boolean representing if a thread is created. :ivar Optional[ThreadMetadata] thread_metadata?: The thread metadata of the channel. :ivar Optional[ThreadMember] member?: The member of the thread in the channel. :ivar Optional[int] default_auto_archive_duration?: The set auto-archive time for all threads to naturally follow in the channel. :ivar Optional[str] permissions?: The permissions of the channel. :ivar Optional[int] flags?: The flags of the channel. :ivar Optional[int] total_message_sent?: Number of messages ever sent in a thread. + :ivar Optional[int] default_thread_slowmode_delay?: The default slowmode delay in seconds for threads, if this channel is a forum. + :ivar Optional[List[Tags]] available_tags: Tags in a forum channel, if any. + :ivar Optional[Emoji] default_reaction_emoji: Default reaction emoji for threads created in a forum, if any. """ + # Template attribute isn't live/documented, this line exists as a placeholder 'TODO' of sorts + __slots__ = ( # TODO: Document banner when Discord officially documents them. "banner", @@ -351,6 +385,7 @@ class Channel(ClientSerializerMixin, IDMixin): video_quality_mode: Optional[int] = field(default=None, repr=False) message_count: Optional[int] = field(default=None, repr=False) member_count: Optional[int] = field(default=None, repr=False) + newly_created: Optional[int] = field(default=None, repr=False) thread_metadata: Optional[ThreadMetadata] = field(converter=ThreadMetadata, default=None) member: Optional[ThreadMember] = field( converter=ThreadMember, default=None, add_client=True, repr=False @@ -359,6 +394,9 @@ class Channel(ClientSerializerMixin, IDMixin): permissions: Optional[str] = field(default=None, repr=False) flags: Optional[int] = field(default=None, repr=False) total_message_sent: Optional[int] = field(default=None, repr=False) + default_thread_slowmode_delay: Optional[int] = field(default=None, repr=False) + tags: Optional[List[Tags]] = field(converter=convert_list(Tags), default=None, repr=False) + default_reaction_emoji: Optional[Emoji] = field(converter=Emoji, default=None) def __attrs_post_init__(self): # sourcery skip: last-if-guard if self._client: @@ -1505,7 +1543,6 @@ async def get_permissions_for(self, member: "Member") -> Permissions: @define() class Thread(Channel): """An object representing a thread. - .. note:: This is a derivation of the base Channel, since a thread can be its own event. diff --git a/interactions/api/models/gw.py b/interactions/api/models/gw.py index d2220ade2..ee62ff293 100644 --- a/interactions/api/models/gw.py +++ b/interactions/api/models/gw.py @@ -517,6 +517,10 @@ class MessageReactionRemove(MessageReaction): # todo see if the missing member attribute affects anything +# Thread object typically used for ``THREAD_X`` is found in the channel models instead, as its identical. +# and all attributes of Thread are in Channel. + + @define() class ThreadList(DictSerializerMixin): """