From 2aca705b95ba5be24e34efa2b76b260a8e66ab8d Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 2 Apr 2022 11:10:14 -0400 Subject: [PATCH] Add support for sending messages and managing webhooks in VoiceChannel --- discord/abc.py | 69 +++++++++- discord/channel.py | 323 +++++++++++++++++++++++++++++++++++++-------- discord/message.py | 4 +- discord/state.py | 8 +- discord/threads.py | 62 ++------- 5 files changed, 355 insertions(+), 111 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 6867505f1..3730b024d 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -25,6 +25,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import copy +import time import asyncio from datetime import datetime from typing import ( @@ -32,6 +33,7 @@ from typing import ( AsyncIterator, Callable, Dict, + Iterable, List, Optional, TYPE_CHECKING, @@ -81,7 +83,7 @@ if TYPE_CHECKING: from .channel import CategoryChannel from .embeds import Embed from .message import Message, MessageReference, PartialMessage - from .channel import TextChannel, DMChannel, GroupChannel, PartialMessageable + from .channel import TextChannel, DMChannel, GroupChannel, PartialMessageable, VoiceChannel from .threads import Thread from .enums import InviteTarget from .ui.view import View @@ -95,7 +97,7 @@ if TYPE_CHECKING: SnowflakeList, ) - PartialMessageableChannel = Union[TextChannel, Thread, DMChannel, PartialMessageable] + PartialMessageableChannel = Union[TextChannel, VoiceChannel, Thread, DMChannel, PartialMessageable] MessageableChannel = Union[PartialMessageableChannel, GroupChannel] SnowflakeTime = Union["Snowflake", datetime] @@ -110,6 +112,69 @@ class _Undefined: _undefined: Any = _Undefined() +async def _single_delete_strategy(messages: Iterable[Message], *, reason: Optional[str] = None): + for m in messages: + await m.delete() + + +async def _purge_helper( + channel: Union[Thread, TextChannel, VoiceChannel], + *, + limit: Optional[int] = 100, + check: Callable[[Message], bool] = MISSING, + before: Optional[SnowflakeTime] = None, + after: Optional[SnowflakeTime] = None, + around: Optional[SnowflakeTime] = None, + oldest_first: Optional[bool] = False, + bulk: bool = True, + reason: Optional[str] = None, +) -> List[Message]: + if check is MISSING: + check = lambda m: True + + iterator = channel.history(limit=limit, before=before, after=after, oldest_first=oldest_first, around=around) + ret: List[Message] = [] + count = 0 + + minimum_time = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 + strategy = channel.delete_messages if bulk else _single_delete_strategy + + async for message in iterator: + if count == 100: + to_delete = ret[-100:] + await strategy(to_delete, reason=reason) + count = 0 + await asyncio.sleep(1) + + if not check(message): + continue + + if message.id < minimum_time: + # older than 14 days old + if count == 1: + await ret[-1].delete() + elif count >= 2: + to_delete = ret[-count:] + await strategy(to_delete, reason=reason) + + count = 0 + strategy = _single_delete_strategy + + count += 1 + ret.append(message) + + # Some messages remaining to poll + if count >= 2: + # more than 2 messages -> bulk delete + to_delete = ret[-count:] + await strategy(to_delete, reason=reason) + elif count == 1: + # delete a single message + await ret[-1].delete() + + return ret + + @runtime_checkable class Snowflake(Protocol): """An ABC that details the common operations on a Discord model. diff --git a/discord/channel.py b/discord/channel.py index 6d11ddc30..707b749a7 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -24,8 +24,6 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -import time -import asyncio from typing import ( Any, AsyncIterator, @@ -87,11 +85,6 @@ if TYPE_CHECKING: from .types.snowflake import SnowflakeList -async def _single_delete_strategy(messages: Iterable[Message], *, reason: Optional[str] = None): - for m in messages: - await m.delete() - - class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): """Represents a Discord guild text channel. @@ -493,51 +486,17 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): List[:class:`.Message`] The list of messages that were deleted. """ - - if check is MISSING: - check = lambda m: True - - iterator = self.history(limit=limit, before=before, after=after, oldest_first=oldest_first, around=around) - ret: List[Message] = [] - count = 0 - - minimum_time = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 - strategy = self.delete_messages if bulk else _single_delete_strategy - - async for message in iterator: - if count == 100: - to_delete = ret[-100:] - await strategy(to_delete, reason=reason) - count = 0 - await asyncio.sleep(1) - - if not check(message): - continue - - if message.id < minimum_time: - # older than 14 days old - if count == 1: - await ret[-1].delete() - elif count >= 2: - to_delete = ret[-count:] - await strategy(to_delete, reason=reason) - - count = 0 - strategy = _single_delete_strategy - - count += 1 - ret.append(message) - - # Some messages remaining to poll - if count >= 2: - # more than 2 messages -> bulk delete - to_delete = ret[-count:] - await strategy(to_delete, reason=reason) - elif count == 1: - # delete a single message - await ret[-1].delete() - - return ret + return await discord.abc._purge_helper( + self, + limit=limit, + check=check, + before=before, + after=after, + around=around, + oldest_first=oldest_first, + bulk=bulk, + reason=reason, + ) async def webhooks(self) -> List[Webhook]: """|coro| @@ -892,6 +851,7 @@ class VocalGuildChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hasha 'category_id', 'rtc_region', 'video_quality_mode', + 'last_message_id', ) def __init__(self, *, state: ConnectionState, guild: Guild, data: Union[VoiceChannelPayload, StageChannelPayload]): @@ -911,6 +871,7 @@ class VocalGuildChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hasha self.rtc_region: Optional[str] = data.get('rtc_region') self.video_quality_mode: VideoQualityMode = try_enum(VideoQualityMode, data.get('video_quality_mode', 1)) self.category_id: Optional[int] = utils._get_as_snowflake(data, 'parent_id') + self.last_message_id: Optional[int] = utils._get_as_snowflake(data, 'last_message_id') self.position: int = data['position'] self.bitrate: int = data['bitrate'] self.user_limit: int = data['user_limit'] @@ -976,7 +937,7 @@ class VocalGuildChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hasha return base -class VoiceChannel(VocalGuildChannel): +class VoiceChannel(discord.abc.Messageable, VocalGuildChannel): """Represents a Discord guild voice channel. .. container:: operations @@ -1025,6 +986,11 @@ class VoiceChannel(VocalGuildChannel): video_quality_mode: :class:`VideoQualityMode` The camera video quality for the voice channel's participants. + .. versionadded:: 2.0 + last_message_id: Optional[:class:`int`] + The last message ID of the message sent to this channel. It may + *not* point to an existing or valid message. + .. versionadded:: 2.0 """ @@ -1044,11 +1010,262 @@ class VoiceChannel(VocalGuildChannel): joined = ' '.join('%s=%r' % t for t in attrs) return f'<{self.__class__.__name__} {joined}>' + async def _get_channel(self) -> Self: + return self + @property def type(self) -> ChannelType: """:class:`ChannelType`: The channel's Discord type.""" return ChannelType.voice + @property + def last_message(self) -> Optional[Message]: + """Fetches the last message from this channel in cache. + + The message might not be valid or point to an existing message. + + .. versionadded:: 2.0 + + .. 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 + + def get_partial_message(self, message_id: int, /) -> PartialMessage: + """Creates a :class:`PartialMessage` from the message ID. + + This is useful if you want to work with a message and only have its ID without + doing an unnecessary API call. + + .. versionadded:: 2.0 + + Parameters + ------------ + message_id: :class:`int` + The message ID to create a partial message for. + + Returns + --------- + :class:`PartialMessage` + The partial message. + """ + + from .message import PartialMessage + + return PartialMessage(channel=self, id=message_id) + + async def delete_messages(self, messages: Iterable[Snowflake], *, reason: Optional[str] = None) -> None: + """|coro| + + Deletes a list of messages. This is similar to :meth:`Message.delete` + except it bulk deletes multiple messages. + + As a special case, if the number of messages is 0, then nothing + is done. If the number of messages is 1 then single message + delete is done. If it's more than two, then bulk delete is used. + + You cannot bulk delete more than 100 messages or messages that + are older than 14 days old. + + You must have the :attr:`~Permissions.manage_messages` permission to + use this. + + .. versionadded:: 2.0 + + Parameters + ----------- + messages: Iterable[:class:`abc.Snowflake`] + An iterable of messages denoting which ones to bulk delete. + reason: Optional[:class:`str`] + The reason for deleting the messages. Shows up on the audit log. + + Raises + ------ + ClientException + The number of messages to delete was more than 100. + Forbidden + You do not have proper permissions to delete the messages. + NotFound + If single delete, then the message was already deleted. + HTTPException + Deleting the messages failed. + """ + if not isinstance(messages, (list, tuple)): + messages = list(messages) + + if len(messages) == 0: + return # do nothing + + if len(messages) == 1: + message_id: int = messages[0].id + await self._state.http.delete_message(self.id, message_id) + return + + if len(messages) > 100: + raise ClientException('Can only bulk delete messages up to 100 messages') + + message_ids: SnowflakeList = [m.id for m in messages] + await self._state.http.delete_messages(self.id, message_ids, reason=reason) + + async def purge( + self, + *, + limit: Optional[int] = 100, + check: Callable[[Message], bool] = MISSING, + before: Optional[SnowflakeTime] = None, + after: Optional[SnowflakeTime] = None, + around: Optional[SnowflakeTime] = None, + oldest_first: Optional[bool] = False, + bulk: bool = True, + reason: Optional[str] = None, + ) -> List[Message]: + """|coro| + + Purges a list of messages that meet the criteria given by the predicate + ``check``. If a ``check`` is not provided then all messages are deleted + without discrimination. + + You must have the :attr:`~Permissions.manage_messages` permission to + delete messages even if they are your own. + The :attr:`~Permissions.read_message_history` permission is + also needed to retrieve message history. + + .. versionadded:: 2.0 + + Examples + --------- + + Deleting bot's messages :: + + def is_me(m): + return m.author == client.user + + deleted = await channel.purge(limit=100, check=is_me) + await channel.send(f'Deleted {len(deleted)} message(s)') + + Parameters + ----------- + limit: Optional[:class:`int`] + The number of messages to search through. This is not the number + of messages that will be deleted, though it can be. + check: Callable[[:class:`Message`], :class:`bool`] + The function used to check if a message should be deleted. + It must take a :class:`Message` as its sole parameter. + before: Optional[Union[:class:`abc.Snowflake`, :class:`datetime.datetime`]] + Same as ``before`` in :meth:`history`. + after: Optional[Union[:class:`abc.Snowflake`, :class:`datetime.datetime`]] + Same as ``after`` in :meth:`history`. + around: Optional[Union[:class:`abc.Snowflake`, :class:`datetime.datetime`]] + Same as ``around`` in :meth:`history`. + oldest_first: Optional[:class:`bool`] + Same as ``oldest_first`` in :meth:`history`. + bulk: :class:`bool` + If ``True``, use bulk delete. Setting this to ``False`` is useful for mass-deleting + a bot's own messages without :attr:`Permissions.manage_messages`. When ``True``, will + fall back to single delete if messages are older than two weeks. + reason: Optional[:class:`str`] + The reason for purging the messages. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have proper permissions to do the actions required. + HTTPException + Purging the messages failed. + + Returns + -------- + List[:class:`.Message`] + The list of messages that were deleted. + """ + + return await discord.abc._purge_helper( + self, + limit=limit, + check=check, + before=before, + after=after, + around=around, + oldest_first=oldest_first, + bulk=bulk, + reason=reason, + ) + + async def webhooks(self) -> List[Webhook]: + """|coro| + + Gets the list of webhooks from this channel. + + Requires :attr:`~.Permissions.manage_webhooks` permissions. + + .. versionadded:: 2.0 + + Raises + ------- + Forbidden + You don't have permissions to get the webhooks. + + Returns + -------- + List[:class:`Webhook`] + The webhooks for this channel. + """ + + from .webhook import Webhook + + data = await self._state.http.channel_webhooks(self.id) + return [Webhook.from_state(d, state=self._state) for d in data] + + async def create_webhook(self, *, name: str, avatar: Optional[bytes] = None, reason: Optional[str] = None) -> Webhook: + """|coro| + + Creates a webhook for this channel. + + Requires :attr:`~.Permissions.manage_webhooks` permissions. + + .. versionadded:: 2.0 + + Parameters + ------------- + name: :class:`str` + The webhook's name. + avatar: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the webhook's default avatar. + This operates similarly to :meth:`~ClientUser.edit`. + reason: Optional[:class:`str`] + The reason for creating this webhook. Shows up in the audit logs. + + Raises + ------- + HTTPException + Creating the webhook failed. + Forbidden + You do not have permissions to create a webhook. + + Returns + -------- + :class:`Webhook` + The created webhook. + """ + + from .webhook import Webhook + + if avatar is not None: + avatar = utils._bytes_to_base64_data(avatar) # type: ignore # Silence reassignment error + + data = await self._state.http.create_webhook(self.id, name=str(name), avatar=avatar, reason=reason) + return Webhook.from_state(data, state=self._state) + @utils.copy_doc(discord.abc.GuildChannel.clone) async def clone(self, *, name: Optional[str] = None, reason: Optional[str] = None) -> VoiceChannel: return await self._clone_impl({'bitrate': self.bitrate, 'user_limit': self.user_limit}, name=name, reason=reason) diff --git a/discord/message.py b/discord/message.py index 5e8081e3b..0f0121c4d 100644 --- a/discord/message.py +++ b/discord/message.py @@ -641,7 +641,9 @@ class PartialMessage(Hashable): ChannelType.public_thread, ChannelType.private_thread, ): - raise TypeError(f'Expected PartialMessageable, TextChannel, DMChannel or Thread not {type(channel)!r}') + raise TypeError( + f'expected PartialMessageable, TextChannel, VoiceChannel, DMChannel or Thread not {type(channel)!r}' + ) self.channel: MessageableChannel = channel self._state: ConnectionState = channel._state diff --git a/discord/state.py b/discord/state.py index 1a2e58b66..f1c899263 100644 --- a/discord/state.py +++ b/discord/state.py @@ -592,8 +592,8 @@ class ConnectionState: self.dispatch('message', message) if self._messages is not None: self._messages.append(message) - # we ensure that the channel is either a TextChannel or Thread - if channel and channel.__class__ in (TextChannel, Thread): + # we ensure that the channel is either a TextChannel, VoiceChannel, or Thread + if channel and channel.__class__ in (TextChannel, VoiceChannel, Thread): channel.last_message_id = message.id # type: ignore def parse_message_delete(self, data: gw.MessageDeleteEvent) -> None: @@ -1497,9 +1497,7 @@ class ConnectionState: if channel is not None: return channel - def create_message( - self, *, channel: Union[TextChannel, Thread, DMChannel, GroupChannel, PartialMessageable], data: MessagePayload - ) -> Message: + def create_message(self, *, channel: MessageableChannel, data: MessagePayload) -> Message: return Message(state=self, channel=channel, data=data) diff --git a/discord/threads.py b/discord/threads.py index 68023d4d7..d1b3cc3e0 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -30,7 +30,7 @@ import time import asyncio from .mixins import Hashable -from .abc import Messageable +from .abc import Messageable, _purge_helper from .enums import ChannelType, try_enum from .errors import ClientException from .utils import MISSING, parse_time, _get_as_snowflake @@ -489,55 +489,17 @@ class Thread(Messageable, Hashable): The list of messages that were deleted. """ - if check is MISSING: - check = lambda m: True - - iterator = self.history(limit=limit, before=before, after=after, oldest_first=oldest_first, around=around) - ret: List[Message] = [] - count = 0 - - minimum_time = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 - - async def _single_delete_strategy(messages: Iterable[Message], *, reason: Optional[str] = None): - for m in messages: - await m.delete() - - strategy = self.delete_messages if bulk else _single_delete_strategy - - async for message in iterator: - if count == 100: - to_delete = ret[-100:] - await strategy(to_delete, reason=reason) - count = 0 - await asyncio.sleep(1) - - if not check(message): - continue - - if message.id < minimum_time: - # older than 14 days old - if count == 1: - await ret[-1].delete() - elif count >= 2: - to_delete = ret[-count:] - await strategy(to_delete, reason=reason) - - count = 0 - strategy = _single_delete_strategy - - count += 1 - ret.append(message) - - # SOme messages remaining to poll - if count >= 2: - # more than 2 messages -> bulk delete - to_delete = ret[-count:] - await strategy(to_delete, reason=reason) - elif count == 1: - # delete a single message - await ret[-1].delete() - - return ret + return await _purge_helper( + self, + limit=limit, + check=check, + before=before, + after=after, + around=around, + oldest_first=oldest_first, + bulk=bulk, + reason=reason, + ) async def edit( self,