48 Commits

Author SHA1 Message Date
Zomatree efce2aedac add archived notice 2025-12-11 01:22:41 +00:00
green. ca37846df7 fix: dont compare offset-naive and offset-aware datetimes (#89) 2024-11-29 21:54:44 +00:00
Zomatree 86c07a6d6b fix: cleanup docs to include missing things 2024-10-29 17:02:40 +00:00
Zomatree a8be358339 fix: various bugs
Closes #85
Closes #82
2024-10-29 15:57:26 +00:00
Zomatree e34ca0afda Greedy support, closes #77 2024-06-11 21:33:11 +01:00
IAmTomahawkx 060e6ea32a fix: prevent new space from appearing if buffer is depleted 2024-05-29 21:00:20 +01:00
Zomatree 2dabb0dbb8 fix patch for older python versions 2024-05-29 19:11:56 +01:00
Zomatree 48e0f0fb2e better handling of optional args 2024-05-29 00:18:00 +01:00
Zomatree bc8d650659 bump aiohttp version, fixes #73 2024-05-13 13:36:12 +01:00
Zomatree d8fde3d0a6 fix converters not catching errors 2024-05-13 13:32:21 +01:00
Zomatree d4568c02ea fix create_role 2024-05-13 13:27:11 +01:00
Zomatree ff49f8e3e5 fix pyright changing generics in metaclasses 2024-05-13 13:23:27 +01:00
Zomatree eed960b9de chore: bump version 2024-02-20 17:15:01 +00:00
Zomatree 185bd00eb9 fix: typing coverage 2024-02-15 20:30:34 +00:00
Zomatree 41be16477d feat: Command cooldowns 2024-02-15 20:25:37 +00:00
Zomatree 5a22d6063d Fix typing bugs 2023-12-29 20:21:48 +00:00
Zomatree 7b95ec1b6b Simple reconnection logic 2023-12-29 20:11:55 +00:00
Zomatree dffbc1f665 Add an Object class to make mocking data with ids easier 2023-12-29 20:11:27 +00:00
Zomatree 1cfc21d4ff Undo on failed args when the default exists 2023-12-29 20:10:57 +00:00
Zomatree 9bf443e36b Fix channel.history not fetching members to go along with the messages 2023-12-29 20:09:38 +00:00
Zomatree 5d3250bcce keep waiting when wait_for check fails 2023-09-13 21:30:49 +01:00
Zomatree 1a697457b7 Fix member.edit 2023-09-11 15:53:54 +01:00
Zomatree 51c7e45821 provide autocomplete for events 2023-09-11 15:53:36 +01:00
Zomatree 1c041d1e9f re-export types 2023-09-11 15:53:05 +01:00
Zomatree 04b2bfbc40 Merge branch 'master' of github.com:revoltchat/revolt.py 2023-08-28 18:57:10 +01:00
Zomatree 02cefeab46 Fix member.edit 2023-08-28 18:57:05 +01:00
William M 5aa3c2c00a Fix wrong query param types for remove_reaction (#65) 2023-07-27 15:51:27 +01:00
Zomatree 11663d493a Fix circular imports 2023-07-24 20:00:13 +01:00
Zomatree 411a0f8203 Merge branch 'master' of github.com:revoltchat/revolt.py 2023-07-20 00:30:30 +01:00
William M 7ae2a56610 Fix the 'user' and 'remove_all' parameters not working in Message#remove_reaction() (#63)
* Fix parameters not being passed in remove_reaction

* Update revolt/http.py

---------

Co-authored-by: Angelo Kontaxis <angelokontaxis@hotmail.com>
2023-07-20 00:30:07 +01:00
MysticMia 04e63e9468 Edit function and class docstrings to fix copy-paste error and improve english (#62)
* Update context.py

* Update asset.py

spelling / grammar errors

* Fix missing 'not' in docstring in Context.server
2023-07-20 00:19:10 +01:00
Zomatree c8c11b5394 Fix nameerror
fixes #61
2023-07-20 00:15:37 +01:00
Zomatree 2749822782 Add missing type-hint 2023-07-02 19:58:21 +01:00
Zomatree 71a33c16b4 Fix mentions for users who dont share a server
fixes #60
2023-07-02 19:56:59 +01:00
Zomatree 5ee3c60ba5 Add listeners 2023-07-01 01:41:17 +01:00
Zomatree 8524ff4036 rename typevars to be more informative 2023-06-30 16:20:46 +01:00
Zomatree c97085cd55 provide original message in message_update event 2023-06-30 16:17:28 +01:00
Zomatree 85910a703a fix subcommands in cogs 2023-06-26 17:19:37 +01:00
Zomatree 042a7da85e fix help command argument parsing 2023-06-24 01:19:34 +01:00
Zomatree 1841d21bee fix subcommands being shown as base commands 2023-06-24 01:18:55 +01:00
Zomatree 69602fc812 make created_at a property 2023-06-24 01:18:01 +01:00
Zomatree a52febcb4e Fix incorrect docs, closes #59 2023-06-22 02:54:03 +01:00
Zomatree da6fb64b89 Fix trying to send a DM to yourself 2023-06-22 02:53:21 +01:00
Zomatree 072a4b1f2d fix for older python versions 2023-06-21 00:45:12 +01:00
Zomatree 826e0f8c85 Fix varies bugs, fixes #55, fixes #56 2023-06-21 00:41:15 +01:00
Zomatree a23615a5ae Fix bug for older python versions 2023-06-15 01:58:46 +01:00
Zomatree 2c30502b50 Raise an error if the token is invalid 2023-06-13 04:17:14 +01:00
Zomatree e23effd605 add missing annotations 2023-06-13 04:14:49 +01:00
37 changed files with 1295 additions and 308 deletions
+2
View File
@@ -1,5 +1,7 @@
# Revolt.py
> # This project is archived and is no longer receiving updates.
An async library to interact with the https://revolt.chat API.
You can join the support server [here](https://rvlt.gg/FDXER6hr) and find the library's documentation [here](https://revoltpy.readthedocs.io/en/latest/).
View File
+8 -2
View File
@@ -148,7 +148,7 @@ API Reference
:members:
Enums
======
------
The api uses enums to say what variant of something is,
these represent those enums
@@ -278,7 +278,7 @@ All enums subclass `aenum.Enum`.
The embed is unknown
Utils
======
------
.. currentmodule:: revolt.utils
@@ -287,3 +287,9 @@ A collection a utility functions and classes to aid in making your bot
.. autofunction:: get
.. autofunction:: client_session
.. autoclass:: Ulid
:members:
.. autoclass:: Object
:members:
+91 -1
View File
@@ -19,6 +19,11 @@ Command
.. autoclass:: revolt.ext.commands.Command
:members:
Group
~~~~~~~~
.. autoclass:: revolt.ext.commands.Group
:members:
Cog
~~~~
.. autoclass:: revolt.ext.commands.Cog
@@ -28,6 +33,13 @@ command
~~~~~~~~
.. autodecorator:: revolt.ext.commands.command
group
~~~~~~~~
.. autodecorator:: revolt.ext.commands.group
Checks
-------
check
~~~~~~
.. autodecorator:: revolt.ext.commands.check
@@ -40,9 +52,62 @@ is_server_owner
~~~~~~~~~~~~~~~~
.. autodecorator:: revolt.ext.commands.is_server_owner
has_permissions
~~~~~~~~~~~~~~~~
.. autodecorator:: revolt.ext.commands.has_permissions
has_channel_permissions
~~~~~~~~~~~~~~~~~~~~~~~~
.. autodecorator:: revolt.ext.commands.has_channel_permissions
Converters
-----------
IntConverter
~~~~~~~~~~~~~
Converts the parameter to an int
BoolConverter
~~~~~~~~~~~~~~
Converts the parameter to a bool
CategoryConverter
~~~~~~~~~~~~~~~~~~
Converts the parameter to a category
UserConverter
~~~~~~~~~~~~~~
Converts the parameter to a category
MemberConverter
~~~~~~~~~~~~~~~~
Converts the parameter to a category
ChannelConverter
~~~~~~~~~~~~~~~~~
Converts the parameter to a category
Greedy
~~~~~~
Converts the parameter to a greedy parameter which will take as many arguments which convert successfully.
Allows you to have var-args in the middle of a signature.
Help Commands
--------------
HelpCommand
~~~~~~~~~~~~
.. autoclass:: revolt.ext.commands.HelpCommand
:members:
DefaultHelpCommand
~~~~~~~~~~~~~~~~~~~
.. autoclass:: revolt.ext.commands.DefaultHelpCommand
:members:
Exceptions
===========
-----------
CommandError
~~~~~~~~~~~~~
@@ -79,6 +144,11 @@ ServerOnly
.. autoexception:: revolt.ext.commands.ServerOnly
:members:
MissingPermissionsError
~~~~~~~~~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.MissingPermissionsError
:members:
ConverterError
~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.ConverterError
@@ -99,6 +169,11 @@ CategoryConverterError
.. autoexception:: revolt.ext.commands.CategoryConverterError
:members:
ChannelConverterError
~~~~~~~~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.ChannelConverterError
:members:
UserConverterError
~~~~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.UserConverterError
@@ -108,3 +183,18 @@ MemberConverterError
~~~~~~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.MemberConverterError
:members:
UnionConverterError
~~~~~~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.UnionConverterError
:members:
MissingSetup
~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.MissingSetup
:members:
CommandOnCooldown
~~~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.CommandOnCooldown
:members:
+1 -1
View File
@@ -16,7 +16,7 @@ classifiers = [
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"aiohttp==3.8.*",
"aiohttp==3.10.*",
"ulid-py==1.1.*",
"aenum==3.1.*",
"typing_extensions>=4.4.0"
+2 -1
View File
@@ -1,4 +1,5 @@
from . import utils as utils
from . import types as types
from .asset import *
from .category import *
from .channel import *
@@ -18,4 +19,4 @@ from .role import *
from .server import *
from .user import *
__version__ = "0.1.11"
__version__ = "0.2.0"
+3 -3
View File
@@ -23,7 +23,7 @@ class Asset(Ulid):
id: :class:`str`
The id of the asset
tag: :class:`str`
The tag of the asset, this corrasponds to where the asset is used
The tag of the asset, this corresponds to where the asset is used
size: :class:`int`
Amount of bytes in the file
filename: :class:`str`
@@ -37,7 +37,7 @@ class Asset(Ulid):
type: :class:`AssetType`
The type of asset it is
url: :class:`str`
The assets url
The asset's url
"""
__slots__ = ("state", "id", "tag", "size", "filename", "content_type", "width", "height", "type", "url")
@@ -76,7 +76,7 @@ class Asset(Ulid):
Parameters
-----------
fp: IOBase
The file to write too.
The file to write to
"""
fp.write(await self.read())
+10 -4
View File
@@ -53,7 +53,8 @@ class EditableChannel:
if kwargs.get("icon", Missing) == None:
remove.append("Icon")
elif kwargs.get("description", Missing) == None:
if kwargs.get("description", Missing) == None:
remove.append("Description")
if icon := kwargs.get("icon"):
@@ -133,7 +134,8 @@ class DMChannel(Channel, Messageable):
def __init__(self, data: DMChannelPayload, state: State):
super().__init__(data, state)
self.recipient_ids: tuple[str, str] = tuple(data["recipients"])
self.recipient_ids: list[str] = data["recipients"]
self.last_message_id: str | None = data.get("last_message_id")
@property
@@ -265,7 +267,7 @@ class ServerChannel(Channel):
def __init__(self, data: ServerChannelPayload, state: State):
super().__init__(data, state)
self.server_id: str = data["server"]
self.server_id: Optional[str] = data["server"]
self.name: str = data["name"]
self.description: Optional[str] = data.get("description")
self.nsfw: bool = data.get("nsfw", False)
@@ -289,9 +291,10 @@ class ServerChannel(Channel):
async def set_default_permissions(self, permissions: PermissionsOverwrite) -> None:
"""Sets the default permissions for the channel.
Parameters
-----------
permissions: :class:`ChannelPermissions`
permissions: :class:`PermissionsOverwrite`
The new default channel permissions
"""
allow, deny = permissions.to_pair()
@@ -299,8 +302,11 @@ class ServerChannel(Channel):
async def set_role_permissions(self, role: Role, permissions: PermissionsOverwrite) -> None:
"""Sets the permissions for a role in the channel.
Parameters
-----------
role: :class:`Role`
The role to set permissions for
permissions: :class:`ChannelPermissions`
The new channel permissions
"""
+185 -17
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union, cast
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Literal, Optional, TypeVar, Union, cast, overload
from typing_extensions import ParamSpec
import aiohttp
from .errors import RevoltError
from .channel import (DMChannel, GroupDMChannel, SavedMessageChannel,
TextChannel, VoiceChannel, channel_factory)
from .http import HttpClient
@@ -14,6 +16,9 @@ from .message import Message
from .state import State
from .utils import Missing, Ulid
from .websocket import WebsocketHandler
from .emoji import Emoji
from .server import Server
from .user import User
try:
import ujson as json
@@ -22,16 +27,18 @@ except ImportError:
if TYPE_CHECKING:
from .channel import Channel
from .emoji import Emoji
from .file import File
from .server import Server
from .types import ApiInfo
from .user import User
import revolt
__all__ = ("Client",)
logger: logging.Logger = logging.getLogger("revolt")
P = ParamSpec("P")
R = TypeVar("R")
class Client:
"""The client for interacting with revolt
@@ -45,6 +52,8 @@ class Client:
The api url for the revolt instance you are connecting to, by default it uses the offical instance hosted at revolt.chat
max_messages: :class:`int`
The max amount of messages stored in the cache, by default this is 5k
bot: :class:`bool`
Denotes whether the account used is a bot account or user account, by default this it assumes a bot account
"""
def __init__(self, session: aiohttp.ClientSession, token: str, *, api_url: str = "https://api.revolt.chat", max_messages: int = 5000, bot: bool = True):
@@ -59,7 +68,8 @@ class Client:
self.state: State
self.websocket: WebsocketHandler
self.listeners: dict[str, list[tuple[Callable[..., bool], asyncio.Future[Any]]]] = {}
self.temp_listeners: dict[str, list[tuple[Callable[..., bool], asyncio.Future[Any]]]] = {}
self.listeners: dict[str, list[Callable[..., Coroutine[Any, Any, Any]]]] = {}
super().__init__()
@@ -73,22 +83,33 @@ class Client:
args: :class:`Any`
The arguments passed to the event
"""
for check, future in self.listeners.pop(event, []):
if check(*args):
if len(args) == 1:
future.set_result(args[0])
else:
future.set_result(args)
func = getattr(self, f"on_{event}", None)
if func:
if temp_listeners := self.temp_listeners.get(event, None):
for check, future in temp_listeners:
if check(*args):
if len(args) == 1:
future.set_result(args[0])
else:
future.set_result(args)
self.temp_listeners[event] = [(c, f) for c, f in temp_listeners if not f.done()]
for listener in self.listeners.get(event, []):
asyncio.create_task(listener(*args))
if func := getattr(self, f"on_{event}", None):
asyncio.create_task(func(*args))
async def get_api_info(self) -> ApiInfo:
async with self.session.get(self.api_url) as resp:
return json.loads(await resp.text())
text = await resp.text()
async def start(self) -> None:
try:
return json.loads(text)
except:
raise RevoltError(f"Cant fetch api info:\n{text}")
async def start(self, *, reconnect: bool = True) -> None:
"""Starts the client"""
api_info = await self.get_api_info()
@@ -96,7 +117,8 @@ class Client:
self.http = HttpClient(self.session, self.token, self.api_url, self.api_info, self.bot)
self.state = State(self.http, api_info, self.max_messages)
self.websocket = WebsocketHandler(self.session, self.token, api_info["ws"], self.dispatch, self.state)
await self.websocket.start()
await self.websocket.start(reconnect)
async def stop(self) -> None:
await self.websocket.websocket.close()
@@ -172,10 +194,64 @@ class Client:
check = lambda *_: True
future = asyncio.get_running_loop().create_future()
self.listeners.setdefault(event, []).append((check, future))
self.temp_listeners.setdefault(event, []).append((check, future))
return await asyncio.wait_for(future, timeout)
def listen(self, name: str | None = None) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
"""Registers a listener for an event, multiple listeners can be registered to the same event without conflict
Parameters
-----------
name: Optional[:class:`str`]
The name of the event to register this under, this defaults to the function's name
"""
def inner(func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Coroutine[Any, Any, R]]:
nonlocal name
if not name:
if not func.__name__.startswith("on_"):
raise RevoltError("listener name must begin with `on_`")
name = func.__name__[3:]
self.listeners.setdefault(name, []).append(func)
return func
return inner
@overload
def remove_listener(self, func: Callable[P, Coroutine[Any, Any, R]], *, event: str = ...) -> Callable[..., Coroutine[Any, Any, R]] | None:
...
@overload
def remove_listener(self, func: Callable[P, Coroutine[Any, Any, Any]], *, event: None = ...) -> None:
...
def remove_listener(self, func: Callable[P, Coroutine[Any, Any, R]], *, event: str | None = None) -> Callable[..., Coroutine[Any, Any, R]] | None:
"""Removes a listener registered, if the `event` parameter is passed, the listener will only be removed from that event, this can be used if the same listener is registed to multiple events at once.
Parameters
-----------
func: Callable
The function for the listener to be removed
event: Optional[:class:`str`]
The name of the event to remove this from, passing `None` will make this remove the listener from all events this is registered under
"""
if event is None:
for listeners in self.listeners.values():
try:
listeners.remove(func)
except ValueError:
pass
else:
try:
self.listeners[event].remove(func)
return func
except ValueError:
pass
@property
def user(self) -> User:
""":class:`User` the user corrasponding to the client"""
@@ -397,3 +473,95 @@ class Client:
ulid.id = asset["id"]
return ulid
# events
async def on_ready(self) -> None:
pass
async def on_message(self, message: revolt.Message) -> None:
pass
async def on_raw_message_update(self, payload: revolt.types.MessageUpdateEventPayload) -> None:
pass
async def on_message_update(self, before: revolt.Message, after: revolt.Message) -> None:
pass
async def on_raw_message_delete(self, payload: revolt.types.MessageDeleteEventPayload) -> None:
pass
async def on_message_delete(self, message: revolt.Message) -> None:
pass
async def on_channel_create(self, channel: revolt.Channel) -> None:
pass
async def on_channel_update(self, before: revolt.Channel, after: revolt.Channel) -> None:
pass
async def on_channel_delete(self, channel: revolt.Channel) -> None:
pass
async def on_typing_start(self, channel: revolt.Channel, user: revolt.User) -> None:
pass
async def on_typing_stop(self, channel: revolt.Channel, user: revolt.User) -> None:
pass
async def on_server_update(self, before: revolt.Server, after: revolt.Server) -> None:
pass
async def on_server_delete(self, server: revolt.Server) -> None:
pass
async def on_server_join(self, server: revolt.Server) -> None:
pass
async def on_member_update(self, before: revolt.Member, after: revolt.Member) -> None:
pass
async def on_member_join(self, member: revolt.Member) -> None:
pass
async def on_member_leave(self, member: revolt.Member) -> None:
pass
async def on_role_create(self, role: revolt.Role) -> None:
pass
async def on_role_update(self, before: revolt.Role, after: revolt.Role) -> None:
pass
async def on_role_delete(self, role: revolt.Role) -> None:
pass
async def on_user_update(self, before: revolt.User, after: revolt.User) -> None:
pass
async def on_user_relationship_update(self, user: revolt.User, before: revolt.RelationshipType, after: revolt.RelationshipType) -> None:
pass
async def on_raw_reaction_add(self, payload: revolt.types.MessageReactEventPayload) -> None:
pass
async def on_reaction_add(self, message: revolt.Message, user: revolt.User, emoji_id: str) -> None:
pass
async def on_raw_reaction_remove(self, payload: revolt.types.MessageUnreactEventPayload) -> None:
pass
async def on_reaction_remove(self, message: revolt.Message, user: revolt.User, emoji_id: str) -> None:
pass
async def on_raw_reaction_clear(self, payload: revolt.types.MessageRemoveReactionEventPayload) -> None:
pass
async def on_reaction_clear(self, message: revolt.Message, user: revolt.User, emoji_id: str) -> None:
pass
async def raw_bulk_message_delete(self, payload: revolt.types.BulkMessageDeleteEventPayload) -> None:
pass
async def bulk_message_delete(self, messages: list[revolt.Message]) -> None:
pass
+1
View File
@@ -4,6 +4,7 @@ from .cog import *
from .command import *
from .context import *
from .converters import *
from .cooldown import *
from .errors import *
from .group import *
from .help import *
+15 -11
View File
@@ -9,15 +9,15 @@ from .command import Command
from .context import Context
from .errors import (MissingPermissionsError, NotBotOwner, NotServerOwner,
ServerOnly)
from .utils import ClientT
from .utils import ClientT_D
__all__ = ("check", "Check", "is_bot_owner", "is_server_owner", "has_permissions", "has_channel_permissions")
T = TypeVar("T", Callable[..., Any], Command, default=Command)
Check = Callable[[Context[ClientT]], Union[Any, Coroutine[Any, Any, Any]]]
Check = Callable[[Context[ClientT_D]], Union[Any, Coroutine[Any, Any, Any]]]
def check(check: Check[ClientT]) -> Callable[[T], T]:
def check(check: Check[ClientT_D]) -> Callable[[T], T]:
"""A decorator for adding command checks
Parameters
@@ -27,23 +27,27 @@ def check(check: Check[ClientT]) -> Callable[[T], T]:
"""
def inner(func: T) -> T:
if isinstance(func, Command):
command = cast(Command[ClientT], func) # cant verify generic at runtime so must cast
command = cast(Command[ClientT_D], func) # cant verify generic at runtime so must cast
command.checks.append(check)
else:
checks = getattr(func, "_checks", [])
checks.append(check)
func._checks = checks # type: ignore
return func
return func # type: ignore
return inner
def is_bot_owner() -> Callable[[T], T]:
"""A command check for limiting the command to only the bot's owner"""
@check
def inner(context: Context[ClientT]):
if context.author.id == context.client.user.owner_id:
return True
def inner(context: Context[ClientT_D]):
if user_id := context.client.user.owner_id:
if context.author.id == user_id:
return True
else:
if context.author.id == context.client.user.id:
return True
raise NotBotOwner
@@ -52,7 +56,7 @@ def is_bot_owner() -> Callable[[T], T]:
def is_server_owner() -> Callable[[T], T]:
"""A command check for limiting the command to only a server's owner"""
@check
def inner(context: Context[ClientT]) -> bool:
def inner(context: Context[ClientT_D]) -> bool:
if not context.server_id:
raise ServerOnly
@@ -65,7 +69,7 @@ def is_server_owner() -> Callable[[T], T]:
def has_permissions(**permissions: bool) -> Callable[[T], T]:
@check
def inner(context: Context[ClientT]) -> bool:
def inner(context: Context[ClientT_D]) -> bool:
author = context.author
if not author.has_permissions(**permissions):
@@ -77,7 +81,7 @@ def has_permissions(**permissions: bool) -> Callable[[T], T]:
def has_channel_permissions(**permissions: bool) -> Callable[[T], T]:
@check
def inner(context: Context[ClientT]) -> bool:
def inner(context: Context[ClientT_D]) -> bool:
author = context.author
if not isinstance(author, revolt.Member):
+69 -16
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import sys
import traceback
from importlib import import_module
from typing import (TYPE_CHECKING, Any, Optional, Protocol, TypeVar, Union,
from typing import (TYPE_CHECKING, Any, Coroutine, Optional, Protocol, TypeVar, Union,
overload, runtime_checkable)
from typing_extensions import Self
@@ -13,6 +13,8 @@ import revolt
if TYPE_CHECKING:
from .help import HelpCommand
import aiohttp
from .cog import Cog
from .command import Command
from .context import Context
@@ -36,13 +38,14 @@ class ExtensionProtocol(Protocol):
class CommandsMeta(type):
_commands: list[Command[Any]]
def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any]) -> Self:
def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any]) -> Any:
commands: list[Command[Any]] = []
self = super().__new__(cls, name, bases, attrs)
for base in reversed(self.__mro__):
for value in base.__dict__.values():
if isinstance(value, Command):
commands.append(value)
if isinstance(value, Command) and value.parent is None: # type: ignore
commands.append(value) # type: ignore
self._commands = commands
@@ -78,14 +81,42 @@ class CaseInsensitiveDict(dict[str, V]):
class CommandsClient(revolt.Client, metaclass=CommandsMeta):
"""Main class that adds commands, this class should be subclassed along with `revolt.Client`."""
"""A subclass of :class:`~revolt.Client` which has support for commands.
Parameters
-----------
session: :class:`~aiohttp.ClientSession`
The aiohttp session to use for http request and the websocket
token: :class:`str`
The bots token
api_url: :class:`str`
The api url for the revolt instance you are connecting to, by default it uses the offical instance hosted at revolt.chat
max_messages: :class:`int`
The max amount of messages stored in the cache, by default this is 5k
bot: :class:`bool`
Denotes whether the account used is a bot account or user account, by default this it assumes a bot account
help_command: Optional[:class:`~revolt.ext.commands.HelpCommand`]
Sets the custom help command, or remove it if passed ``None``
case_insensitive: :class:`bool`
Whether or not commands should be case insensitive
"""
_commands: list[Command[Self]]
def __init__(self, *args: Any, help_command: Union[HelpCommand[Self], None, revolt.utils._Missing] = revolt.utils.Missing, case_insensitive: bool = False, **kwargs: Any):
def __init__(
self,
session: aiohttp.ClientSession,
token: str,
*,
api_url: str = "https://api.revolt.chat",
max_messages: int = 5000,
bot: bool = True,
help_command: Union[HelpCommand[Self], None, revolt.utils._Missing] = revolt.utils.Missing,
case_insensitive: bool = False
):
from .help import DefaultHelpCommand, HelpCommandImpl
self.all_commands: dict[str, Command[Self]] = {} if not case_insensitive else CaseInsensitiveDict()
self.all_commands: dict[str, Command[Self]] | CaseInsensitiveDict[Command[Self]] = {} if not case_insensitive else CaseInsensitiveDict()
self.cogs: dict[str, Cog[Self]] = {}
self.extensions: dict[str, ExtensionProtocol] = {}
@@ -103,10 +134,17 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
else:
self.help_command = None
super().__init__(*args, **kwargs)
super().__init__(session, token, api_url=api_url, max_messages=max_messages, bot=bot)
@property
def commands(self) -> list[Command[Self]]:
"""Gets all commands registered
Returns
--------
list[:class:`Command`]
The registered commands
"""
return list(set(self.all_commands.values()))
async def get_prefix(self, message: revolt.Message) -> Union[str, list[str]]:
@@ -176,9 +214,23 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
return command
def get_view(self, message: revolt.Message) -> type[StringView]:
"""Returns the StringView class to use, this can be overwritten to customize how arguments are parsed
Returns
--------
type[:class:`StringView`]
The string view class to use
"""
return StringView
def get_context(self, message: revolt.Message) -> type[Context[Self]]:
"""Returns the Context class to use, this can be overwritten to add extra features to context
Returns
--------
type[:class:`Context`]
The context class to use
"""
return Context[Self]
async def process_commands(self, message: revolt.Message) -> Any:
@@ -231,7 +283,7 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
try:
self.dispatch("command", context)
if not await self.bot_check(context):
if not await self.global_check(context):
raise CheckError(f"the global check for the command failed")
if not await context.can_run():
@@ -248,10 +300,11 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
async def on_command_error(self, ctx: Context[Self], error: Exception, /) -> None:
traceback.print_exception(type(error), error, error.__traceback__)
on_message = process_commands
def on_message(self, message: revolt.Message) -> Coroutine[Any, Any, Any]:
return self.process_commands(message)
async def bot_check(self, context: Context[Self]) -> bool:
"""A global check for the bot that stops commands from running on certain criteria.
async def global_check(self, context: Context[Self]) -> bool:
"""A global check that stops commands from running on certain criteria.
Parameters
-----------
@@ -266,7 +319,7 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
return True
def add_cog(self, cog: Cog[Self]) -> None:
"""Adds a cog to the bot, this cog must subclass `Cog`.
"""Adds a cog, this cog must subclass `Cog`.
Parameters
-----------
@@ -276,7 +329,7 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
cog._inject(self)
def remove_cog(self, cog_name: str) -> Cog[Self]:
"""Removes a cog from the bot.
"""Removes a cog.
Parameters
-----------
@@ -336,7 +389,7 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
self.load_extension(name)
def get_cog(self, name: str) -> Cog[Self]:
"""Gets a cog from the bot.
"""Gets a cog.
Parameters
-----------
@@ -351,7 +404,7 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
return self.cogs[name]
def get_extension(self, name: str) -> ExtensionProtocol:
"""Gets an extension from the bot.
"""Gets an extension.
Parameters
-----------
+70 -21
View File
@@ -1,33 +1,49 @@
from __future__ import annotations
from typing import Any, Generic, Optional, cast
from typing_extensions import Self
from typing import Any, Callable, Coroutine, Generic, Optional, TypeVar
from typing_extensions import ParamSpec
from revolt.errors import RevoltError
from .command import Command
from .utils import ClientT
from .utils import ClientT_D
P = ParamSpec("P")
R = TypeVar("R")
__all__ = ("Cog", "CogMeta")
class CogMeta(type, Generic[ClientT]):
_commands: list[Command[ClientT]]
class CogMeta(type):
_cog_commands: list[Command[Any]]
_cog_listeners: dict[str, list[str]]
qualified_name: str
def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any], *, qualified_name: Optional[str] = None) -> Self:
commands: list[Command[ClientT]] = []
def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any], *, qualified_name: Optional[str] = None, extras: dict[str, Any] | None = None) -> Any:
commands: list[Command[Any]] = []
listeners: dict[str, list[str]] = {}
self = super().__new__(cls, name, bases, attrs)
extras = extras or {}
for base in reversed(self.__mro__):
for value in base.__dict__.values():
for key, value in base.__dict__.items():
if isinstance(value, Command):
commands.append(cast(Command[ClientT], value)) # cant verify generic at runtime so must cast
for extra_key, extra_value in extras.items():
setattr(value, extra_key, extra_value) # type: ignore
commands.append(value) # type: ignore
self._commands = commands
elif event_name := getattr(value, "__listener_name", None):
listeners.setdefault(event_name, []).append(key)
self._cog_commands = commands
self._cog_listeners = listeners
self.qualified_name = qualified_name or name
return self
class Cog(Generic[ClientT], metaclass=CogMeta):
_commands: list[Command[ClientT]]
class Cog(Generic[ClientT_D], metaclass=CogMeta):
_cog_commands: list[Command[ClientT_D]]
_cog_listeners: dict[str, list[str]]
qualified_name: str
def cog_load(self) -> None:
@@ -38,22 +54,55 @@ class Cog(Generic[ClientT], metaclass=CogMeta):
"""A special method that is called when the cog gets removed."""
pass
def _inject(self, client: ClientT) -> None:
def _inject(self, client: ClientT_D) -> None:
client.cogs[self.qualified_name] = self
for command in self._commands:
command.cog = self
client.add_command(command)
try:
for command in self._cog_commands:
command.cog = self
if command.parent is None:
client.add_command(command)
for key, listeners in self._cog_listeners.items():
for listener_name in listeners:
client.listeners.setdefault(key, []).append(getattr(self, listener_name))
except Exception as e:
self._uninject(client)
raise e
self.cog_load()
def _uninject(self, client: ClientT) -> None:
def _uninject(self, client: ClientT_D) -> None:
for name, command in client.all_commands.copy().items():
if command in self._commands:
del client.all_commands[name]
if command in self._cog_commands:
try:
del client.all_commands[name]
except KeyError:
pass
for key, listeners in self._cog_listeners.items():
for listener_name in listeners:
try:
client.listeners[key].remove(getattr(self, listener_name))
except ValueError:
pass
self.cog_unload()
@property
def commands(self) -> list[Command[ClientT]]:
return self._commands
def commands(self) -> list[Command[ClientT_D]]:
return self._cog_commands
@staticmethod
def listen(name: str | None = None) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
def inner(func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Coroutine[Any, Any, R]]:
if not func.__name__.startswith("on_"):
raise RevoltError("event name must start with `on_`")
setattr(func, "__listener_name", name or func.__name__[3:])
return func
return inner
+99 -25
View File
@@ -6,11 +6,20 @@ from contextlib import suppress
from typing import (TYPE_CHECKING, Annotated, Any, Callable, Coroutine,
Generic, Literal, Optional, Union, get_args, get_origin)
from typing_extensions import ParamSpec
import sys
from revolt.utils import copy_doc, maybe_coroutine
if sys.version_info >= (3, 10):
from types import UnionType
from .errors import InvalidLiteralArgument, UnionConverterError
from .utils import ClientCoT, evaluate_parameters
UnionTypes: tuple[Any, ...] = (Union, UnionType)
else:
UnionTypes = (Union,)
from ...utils import maybe_coroutine
from .errors import CommandOnCooldown, InvalidLiteralArgument, UnionConverterError
from .utils import ClientT_Co_D, evaluate_parameters, ClientT_Co
from .cooldown import BucketType, CooldownMapping
if TYPE_CHECKING:
from .checks import Check
@@ -26,7 +35,7 @@ __all__: tuple[str, ...] = (
NoneType: type[None] = type(None)
P = ParamSpec("P")
class Command(Generic[ClientCoT]):
class Command(Generic[ClientT_Co_D]):
"""Class for holding info about a command.
Parameters
@@ -43,23 +52,46 @@ class Command(Generic[ClientCoT]):
The cog the command is apart of.
usage: Optional[:class:`str`]
The usage string for the command
checks: Optional[list[Callable]]
The list of checks the command has
cooldown: Optional[:class:`Cooldown`]
The cooldown for the command to restrict how often the command can be used
description: Optional[:class:`str`]
The commands description if it has one
hidden: :class:`bool`
Whether or not the command should be hidden from the help command
"""
__slots__ = ("callback", "name", "aliases", "signature", "checks", "parent", "_error_handler", "cog", "description", "usage", "parameters")
__slots__ = ("callback", "name", "aliases", "signature", "checks", "parent", "_error_handler", "cog", "description", "usage", "parameters", "hidden", "cooldown", "cooldown_bucket")
def __init__(self, callback: Callable[..., Coroutine[Any, Any, Any]], name: str, aliases: list[str], usage: Optional[str] = None):
def __init__(
self,
callback: Callable[..., Coroutine[Any, Any, Any]],
name: str,
*,
aliases: list[str] | None = None,
usage: Optional[str] = None,
checks: list[Check[ClientT_Co_D]] | None = None,
cooldown: Optional[CooldownMapping] | None = None,
bucket: Optional[BucketType | Callable[[Context[ClientT_Co_D]], Coroutine[Any, Any, str]]] = None,
description: str | None = None,
hidden: bool = False,
):
self.callback: Callable[..., Coroutine[Any, Any, Any]] = callback
self.name: str = name
self.aliases: list[str] = aliases
self.aliases: list[str] = aliases or []
self.usage: str | None = usage
self.signature: inspect.Signature = inspect.signature(self.callback)
self.parameters: list[inspect.Parameter] = evaluate_parameters(self.signature.parameters.values(), getattr(callback, "__globals__", {}))
self.checks: list[Check[ClientCoT]] = getattr(callback, "_checks", [])
self.parent: Optional[Group[ClientCoT]] = None
self.cog: Optional[Cog[ClientCoT]] = None
self._error_handler: Callable[[Any, Context[ClientCoT], Exception], Coroutine[Any, Any, Any]] = type(self)._default_error_handler
self.description: str | None = callback.__doc__
self.checks: list[Check[ClientT_Co_D]] = checks or getattr(callback, "_checks", [])
self.cooldown: CooldownMapping | None = cooldown or getattr(callback, "_cooldown", None)
self.cooldown_bucket: BucketType | Callable[[Context[ClientT_Co_D]], Coroutine[Any, Any, str]] = bucket or getattr(callback, "_bucket", BucketType.default)
self.parent: Optional[Group[ClientT_Co_D]] = None
self.cog: Optional[Cog[ClientT_Co_D]] = None
self._error_handler: Callable[[Any, Context[ClientT_Co_D], Exception], Coroutine[Any, Any, Any]] = type(self)._default_error_handler
self.description: str | None = description or callback.__doc__
self.hidden: bool = hidden
async def invoke(self, context: Context[ClientCoT], *args: Any, **kwargs: Any) -> Any:
async def invoke(self, context: Context[ClientT_Co_D], *args: Any, **kwargs: Any) -> Any:
"""Runs the command and calls the error handler if the command errors.
Parameters
@@ -74,8 +106,7 @@ class Command(Generic[ClientCoT]):
except Exception as err:
return await self._error_handler(self.cog or context.client, context, err)
@copy_doc(invoke)
def __call__(self, context: Context[ClientCoT], *args: Any, **kwargs: Any) -> Any:
def __call__(self, context: Context[ClientT_Co_D], *args: Any, **kwargs: Any) -> Any:
return self.invoke(context, *args, **kwargs)
def error(self, func: Callable[..., Coroutine[Any, Any, Any]]) -> Callable[..., Coroutine[Any, Any, Any]]:
@@ -98,12 +129,12 @@ class Command(Generic[ClientCoT]):
self._error_handler = func
return func
async def _default_error_handler(self, ctx: Context[ClientCoT], error: Exception):
async def _default_error_handler(self, ctx: Context[ClientT_Co_D], error: Exception):
traceback.print_exception(type(error), error, error.__traceback__)
@classmethod
async def handle_origin(cls, context: Context[ClientCoT], origin: Any, annotation: Any, arg: str) -> Any:
if origin is Union:
async def handle_origin(cls, context: Context[ClientT_Co_D], origin: Any, annotation: Any, arg: str) -> Any:
if origin in UnionTypes:
for converter in get_args(annotation):
try:
return await cls.convert_argument(arg, converter, context)
@@ -117,8 +148,20 @@ class Command(Generic[ClientCoT]):
elif origin is Annotated:
annotated_args = get_args(annotation)
if origin := get_origin(annotated_args[0]):
return await cls.handle_origin(context, origin, annotated_args[1], arg)
if annotated_args[1] == "_revolt_greedy_marker":
real_annotation = get_args(annotated_args[0])[0]
converted_args: list[Any] = []
converted_args.append(await cls.convert_argument(arg, real_annotation, context))
for arg in context.view:
try:
converted_args.append(await cls.convert_argument(arg, real_annotation, context))
except:
context.view.undo()
break
return converted_args
else:
return await cls.convert_argument(arg, annotated_args[1], context)
@@ -129,7 +172,7 @@ class Command(Generic[ClientCoT]):
raise InvalidLiteralArgument(arg)
@classmethod
async def convert_argument(cls, arg: str, annotation: Any, context: Context[ClientCoT]) -> Any:
async def convert_argument(cls, arg: str, annotation: Any, context: Context[ClientT_Co_D]) -> Any:
if annotation is not inspect.Signature.empty:
if annotation is str: # no converting is needed - its already a string
return arg
@@ -142,7 +185,7 @@ class Command(Generic[ClientCoT]):
else:
return arg
async def parse_arguments(self, context: Context[ClientCoT]) -> None:
async def parse_arguments(self, context: Context[ClientT_Co_D]) -> None:
# please pr if you can think of a better way to do this
for parameter in self.parameters[2:]:
@@ -152,6 +195,10 @@ class Command(Generic[ClientCoT]):
except StopIteration:
if parameter.default is not parameter.empty:
arg = parameter.default
elif is_optional(parameter.annotation):
arg = None
else:
raise
@@ -169,11 +216,27 @@ class Command(Generic[ClientCoT]):
except StopIteration:
if parameter.default is not parameter.empty:
arg = parameter.default
elif is_optional(parameter.annotation):
arg = None
else:
raise
context.args.append(arg)
async def run_cooldown(self, context: Context[ClientT_Co_D]) -> None:
if mapping := self.cooldown:
if isinstance(self.cooldown_bucket, BucketType):
key = self.cooldown_bucket.resolve(context)
else:
key = await self.cooldown_bucket(context)
cooldown = mapping.get_bucket(key)
if retry_after := cooldown.update_cooldown():
raise CommandOnCooldown(retry_after)
def __repr__(self) -> str:
return f"<{self.__class__.__name__} name=\"{self.name}\">"
@@ -215,7 +278,16 @@ class Command(Generic[ClientCoT]):
return f"{' '.join(parents[::-1])} {self.name} {' '.join(parameters)}"
def command(*, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: type[Command[ClientCoT]] = Command, usage: Optional[str] = None) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], Command[ClientCoT]]:
def is_optional(arg: Any) -> bool:
return get_origin(arg) in UnionTypes and any(arg is NoneType for arg in get_args(arg))
def command(
*,
name: Optional[str] = None,
aliases: Optional[list[str]] = None,
cls: type[Command[ClientT_Co]] = Command,
usage: Optional[str] = None
) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], Command[ClientT_Co]]:
"""A decorator that turns a function into a :class:`Command`.n
Parameters
@@ -226,13 +298,15 @@ def command(*, name: Optional[str] = None, aliases: Optional[list[str]] = None,
The aliases of the command, defaults to no aliases
cls: type[:class:`Command`]
The class used for creating the command, this defaults to :class:`Command` but can be used to use a custom command subclass
usage: Optional[:class:`str`]
The signature for how the command should be called
Returns
--------
Callable[Callable[..., Coroutine], :class:`Command`]
A function that takes the command callback and returns a :class:`Command`
"""
def inner(func: Callable[..., Coroutine[Any, Any, Any]]):
return cls(func, name or func.__name__, aliases or [], usage)
def inner(func: Callable[..., Coroutine[Any, Any, Any]]) -> Command[ClientT_Co]:
return cls(func, name or func.__name__, aliases=aliases or [], usage=usage)
return inner
+11 -10
View File
@@ -7,7 +7,7 @@ from revolt.utils import maybe_coroutine
from .command import Command
from .group import Group
from .utils import ClientCoT
from .utils import ClientT_Co_D
if TYPE_CHECKING:
from .view import StringView
@@ -17,7 +17,7 @@ __all__ = (
"Context",
)
class Context(revolt.Messageable, Generic[ClientCoT]):
class Context(revolt.Messageable, Generic[ClientT_Co_D]):
"""Stores metadata the commands execution.
Attributes
@@ -46,27 +46,27 @@ class Context(revolt.Messageable, Generic[ClientCoT]):
async def _get_channel_id(self) -> str:
return self.channel.id
def __init__(self, command: Optional[Command[ClientCoT]], invoked_with: str, view: StringView, message: revolt.Message, client: ClientCoT):
self.command: Command[ClientCoT] | None = command
def __init__(self, command: Optional[Command[ClientT_Co_D]], invoked_with: str, view: StringView, message: revolt.Message, client: ClientT_Co_D):
self.command: Command[ClientT_Co_D] | None = command
self.invoked_with: str = invoked_with
self.view: StringView = view
self.message: revolt.Message = message
self.client: ClientCoT = client
self.client: ClientT_Co_D = client
self.args: list[Any] = []
self.kwargs: dict[str, Any] = {}
self.server_id: str | None = message.server_id
self.channel: revolt.TextChannel | revolt.GroupDMChannel | revolt.DMChannel = message.channel
self.channel: revolt.TextChannel | revolt.GroupDMChannel | revolt.DMChannel | revolt.SavedMessageChannel = message.channel
self.author: revolt.Member | revolt.User = message.author
self.state: State = message.state
@property
def server(self) -> revolt.Server:
""":class:`Server` The server this voice channel belongs too
""":class:`Server` The server this context belongs too
Raises
-------
:class:`LookupError`
Raises if the channel is not part of a server
Raises if the context is not from a server
"""
if not self.server_id:
raise LookupError
@@ -97,16 +97,17 @@ class Context(revolt.Messageable, Generic[ClientCoT]):
self.view.undo()
await command.run_cooldown(self)
await command.parse_arguments(self)
return await command.invoke(self, *self.args, **self.kwargs)
async def can_run(self, command: Optional[Command[ClientCoT]] = None) -> bool:
async def can_run(self, command: Optional[Command[ClientT_Co_D]] = None) -> bool:
"""Runs all of the commands checks, and returns true if all of them pass"""
command = command or self.command
return all([await maybe_coroutine(check, self) for check in (command.checks if command else [])])
async def send_help(self, argument: Command[Any] | Group[Any] | ClientCoT | None = None) -> None:
async def send_help(self, argument: Command[Any] | Group[Any] | ClientT_Co_D | None = None) -> None:
argument = argument or self.client
command = self.client.get_command("help")
+39 -7
View File
@@ -13,7 +13,9 @@ from .errors import (BadBoolArgument, CategoryConverterError,
if TYPE_CHECKING:
from .client import CommandsClient
__all__: tuple[str, ...] = ("bool_converter", "category_converter", "channel_converter", "user_converter", "member_converter", "IntConverter", "BoolConverter", "CategoryConverter", "UserConverter", "MemberConverter", "ChannelConverter")
T = TypeVar("T")
__all__: tuple[str, ...] = ("bool_converter", "category_converter", "channel_converter", "user_converter", "member_converter", "IntConverter", "BoolConverter", "CategoryConverter", "UserConverter", "MemberConverter", "ChannelConverter", "Greedy")
channel_regex: re.Pattern[str] = re.compile("<#([A-z0-9]{26})>")
user_regex: re.Pattern[str] = re.compile("<@([A-z0-9]{26})>")
@@ -35,7 +37,7 @@ def category_converter(arg: str, context: Context[ClientT]) -> Category:
try:
return context.server.get_category(arg)
except KeyError:
except LookupError:
try:
return utils.get(context.server.categories, name=arg)
except LookupError:
@@ -50,7 +52,7 @@ def channel_converter(arg: str, context: Context[ClientT]) -> Channel:
try:
return context.server.get_channel(arg)
except KeyError:
except LookupError:
try:
return utils.get(context.server.channels, name=arg)
except LookupError:
@@ -62,9 +64,23 @@ def user_converter(arg: str, context: Context[ClientT]) -> User:
try:
return context.client.get_user(arg)
except KeyError:
except LookupError:
try:
return utils.get(context.client.users, name=arg)
parts = arg.split("#")
if len(parts) == 1:
return (
utils.get(context.client.users, original_name=arg)
or utils.get(context.client.users, display_name=arg)
)
elif len(parts) == 2:
return (
utils.get(context.client.users, original_name=parts[0], discriminator=parts[1])
or utils.get(context.client.users, display_name=parts[0], discriminator=parts[1])
)
else:
raise LookupError
except LookupError:
raise UserConverterError(arg)
@@ -77,9 +93,23 @@ def member_converter(arg: str, context: Context[ClientT]) -> Member:
try:
return context.server.get_member(arg)
except KeyError:
except LookupError:
try:
return utils.get(context.server.members, name=arg)
parts = arg.split("#")
if len(parts) == 1:
return (
utils.get(context.server.members, original_name=arg)
or utils.get(context.server.members, display_name=arg)
)
elif len(parts) == 2:
return (
utils.get(context.server.members, original_name=parts[0], discriminator=parts[1])
or utils.get(context.server.members, display_name=parts[0], discriminator=parts[1])
)
else:
raise LookupError
except LookupError:
raise MemberConverterError(arg)
@@ -92,3 +122,5 @@ CategoryConverter = Annotated[Category, category_converter]
UserConverter = Annotated[User, user_converter]
MemberConverter = Annotated[Member, member_converter]
ChannelConverter = Annotated[Channel, channel_converter]
Greedy = Annotated[list[T], "_revolt_greedy_marker"]
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any, Callable, Coroutine, TypeVar, cast
from .errors import ServerOnly
if TYPE_CHECKING:
from enum import Enum
from .context import Context
from .utils import ClientT_Co_D, ClientT_Co
else:
from aenum import Enum
__all__ = ("Cooldown", "CooldownMapping", "BucketType", "cooldown")
T = TypeVar("T")
class Cooldown:
"""Represent a single cooldown for a single key
Parameters
-----------
rate: :class:`int`
How many times it can be used
per: :class:`int`
How long the window is before the ratelimit resets
"""
def __init__(self, rate: int, per: int):
self.rate: int = rate
self.per: int = per
self.window: float = 0.0
self.tokens: int = rate
self.last: float = 0.0
def get_tokens(self, current: float | None) -> int:
current = current or time.time()
if current > (self.window + self.per):
return self.rate
else:
return self.tokens
def update_cooldown(self) -> float | None:
current = time.time()
self.last = current
self.tokens = self.get_tokens(current)
if self.tokens == 0:
return self.per - (current - self.window)
self.tokens -= 1
if self.tokens == 0:
self.window = current
return None
class CooldownMapping:
"""Holds all cooldowns for every key"""
def __init__(self, rate: int, per: int):
self.rate = rate
self.per = per
self.cache: dict[str, Cooldown] = {}
def verify_cache(self) -> None:
current = time.time()
self.cache = {k: v for k, v in self.cache.items() if current < (v.last + v.per)}
def get_bucket(self, key: str) -> Cooldown:
self.verify_cache()
if not (rl := self.cache.get(key)):
self.cache[key] = rl = Cooldown(self.rate, self.per)
return rl
class BucketType(Enum):
default = 0
user = 1
server = 2
channel = 3
member = 4
def resolve(self, context: Context[ClientT_Co_D]) -> str:
if self == BucketType.default:
return f"{context.author.id}{context.channel.id}"
elif self == BucketType.user:
return context.author.id
elif self == BucketType.server:
if id := context.server_id:
return id
raise ServerOnly
elif self == BucketType.channel:
return context.channel.id
else: # BucketType.member
if server_id := context.server_id:
return f"{context.author.id}{server_id}"
raise ServerOnly
def cooldown(rate: int, per: int, *, bucket: BucketType | Callable[[Context[ClientT_Co]], Coroutine[Any, Any, str]] = BucketType.default) -> Callable[[T], T]:
"""Adds a cooldown to a command
Parameters
-----------
rate: :class:`int`
How many times it can be used
per: :class:`int`
How long the window is before the ratelimit resets
bucket: Optional[Union[:class:`BucketType`, Callable[[Context], str]]]
Controls how the key is generated for the cooldowns
Examples
--------
.. code-block:: python
@commands.command()
@commands.cooldown(1, 5)
async def ping(self, ctx: Context):
await ctx.send("Pong")
"""
def inner(func: T) -> T:
from .command import Command
if isinstance(func, Command):
command = cast(Command[ClientT_Co], func) # cant verify generic at runtime so must cast
command.cooldown = CooldownMapping(rate, per)
command.cooldown_bucket = bucket
else:
func._cooldown = CooldownMapping(rate, per) # type: ignore
func._bucket = bucket # type: ignore
return func # type: ignore
return inner
+17
View File
@@ -8,6 +8,7 @@ __all__ = (
"NotBotOwner",
"NotServerOwner",
"ServerOnly",
"MissingPermissionsError",
"ConverterError",
"InvalidLiteralArgument",
"BadBoolArgument",
@@ -15,7 +16,9 @@ __all__ = (
"ChannelConverterError",
"UserConverterError",
"MemberConverterError",
"UnionConverterError",
"MissingSetup",
"CommandOnCooldown"
)
class CommandError(RevoltError):
@@ -97,3 +100,17 @@ class UnionConverterError(ConverterError):
class MissingSetup(CommandError):
"""Raised when an extension is missing the `setup` function"""
class CommandOnCooldown(CommandError):
"""Raised when a command is on cooldown
Attributes
-----------
retry_after: :class:`float`
How long the user must wait until the cooldown resets
"""
__slots__ = ("retry_after",)
def __init__(self, retry_after: float):
self.retry_after: float = retry_after
+76 -10
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any, Callable, Coroutine, Optional
from .command import Command
from .utils import ClientCoT, ClientT
from .utils import ClientT_Co_D, ClientT_D
__all__ = (
@@ -11,7 +11,7 @@ __all__ = (
"group"
)
class Group(Command[ClientCoT]):
class Group(Command[ClientT_Co_D]):
"""Class for holding info about a group command.
Parameters
@@ -29,10 +29,10 @@ class Group(Command[ClientCoT]):
__slots__: tuple[str, ...] = ("subcommands",)
def __init__(self, callback: Callable[..., Coroutine[Any, Any, Any]], name: str, aliases: list[str]):
self.subcommands: dict[str, Command[ClientCoT]] = {}
super().__init__(callback, name, aliases)
self.subcommands: dict[str, Command[ClientT_Co_D]] = {}
super().__init__(callback, name, aliases=aliases)
def command(self, *, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: type[Command[ClientCoT]] = Command[ClientCoT]) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], Command[ClientCoT]]:
def command(self, *, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: type[Command[ClientT_Co_D]] = Command[ClientT_Co_D]) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], Command[ClientT_Co_D]]:
"""A decorator that turns a function into a :class:`Command` and registers the command as a subcommand.
Parameters
@@ -50,14 +50,18 @@ class Group(Command[ClientCoT]):
A function that takes the command callback and returns a :class:`Command`
"""
def inner(func: Callable[..., Coroutine[Any, Any, Any]]):
command = cls(func, name or func.__name__, aliases or [])
command = cls(func, name or func.__name__, aliases=aliases or [])
command.parent = self
self.subcommands[command.name] = command
for alias in command.aliases:
self.subcommands[alias] = command
return command
return inner
def group(self, *, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: Optional[type[Group[ClientCoT]]] = None) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], Group[ClientCoT]]:
def group(self, *, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: Optional[type[Group[ClientT_Co_D]]] = None) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], Group[ClientT_Co_D]]:
"""A decorator that turns a function into a :class:`Group` and registers the command as a subcommand
Parameters
@@ -80,6 +84,10 @@ class Group(Command[ClientCoT]):
command = cls(func, name or func.__name__, aliases or [])
command.parent = self
self.subcommands[command.name] = command
for alias in command.aliases:
self.subcommands[alias] = command
return command
return inner
@@ -88,10 +96,68 @@ class Group(Command[ClientCoT]):
return f"<Group name=\"{self.name}\">"
@property
def commands(self) -> list[Command[ClientCoT]]:
return list(self.subcommands.values())
def commands(self) -> list[Command[ClientT_Co_D]]:
"""Gets all commands registered
def group(*, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: type[Group[ClientT]] = Group) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], Group[ClientT]]:
Returns
--------
list[:class:`Command`]
The registered commands
"""
return list(set(self.subcommands.values()))
def get_command(self, name: str) -> Command[ClientT_Co_D]:
"""Gets a command.
Parameters
-----------
name: :class:`str`
The name or alias of the command
Returns
--------
:class:`Command`
The command with the name
"""
return self.subcommands[name]
def add_command(self, command: Command[ClientT_Co_D]) -> None:
"""Adds a command, this is typically only used for dynamic commands, you should use the `commands.command` decorator for most usecases.
Parameters
-----------
name: :class:`str`
The name or alias of the command
command: :class:`Command`
The command to be added
"""
self.subcommands[command.name] = command
for alias in command.aliases:
self.subcommands[alias] = command
def remove_command(self, name: str) -> Optional[Command[ClientT_Co_D]]:
"""Removes a command.
Parameters
-----------
name: :class:`str`
The name or alias of the command
Returns
--------
Optional[:class:`Command`]
The command that was removed
"""
command = self.subcommands.pop(name, None)
if command is not None:
for alias in command.aliases:
self.subcommands.pop(alias, None)
return command
def group(*, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: type[Group[ClientT_D]] = Group) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], Group[ClientT_D]]:
"""A decorator that turns a function into a :class:`Group`
Parameters
+60 -50
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Generic, Optional, TypedDict, Union
from typing import TYPE_CHECKING, Generic, Optional, TypedDict, Union, cast
from typing_extensions import NotRequired
@@ -9,11 +9,11 @@ from .cog import Cog
from .command import Command
from .context import Context
from .group import Group
from .utils import ClientCoT, ClientT
from .utils import ClientT_Co_D, ClientT_D
from revolt import File, Message, Messageable, MessageReply, SendableEmbed
if TYPE_CHECKING:
from revolt import File, Message, Messageable, MessageReply, SendableEmbed
from .cog import Cog
__all__ = ("MessagePayload", "HelpCommand", "DefaultHelpCommand", "help_command_impl")
@@ -26,30 +26,33 @@ class MessagePayload(TypedDict):
attachments: NotRequired[list[File]]
replies: NotRequired[list[MessageReply]]
class HelpCommand(ABC, Generic[ClientCoT]):
class HelpCommand(ABC, Generic[ClientT_Co_D]):
@abstractmethod
async def create_bot_help(self, context: Context[ClientCoT], commands: dict[Optional[Cog[ClientCoT]], list[Command[ClientCoT]]]) -> Union[str, SendableEmbed, MessagePayload]:
async def create_global_help(self, context: Context[ClientT_Co_D], commands: dict[Optional[Cog[ClientT_Co_D]], list[Command[ClientT_Co_D]]]) -> Union[str, SendableEmbed, MessagePayload]:
raise NotImplementedError
@abstractmethod
async def create_command_help(self, context: Context[ClientCoT], command: Command[ClientCoT]) -> Union[str, SendableEmbed, MessagePayload]:
async def create_command_help(self, context: Context[ClientT_Co_D], command: Command[ClientT_Co_D]) -> Union[str, SendableEmbed, MessagePayload]:
raise NotImplementedError
@abstractmethod
async def create_group_help(self, context: Context[ClientCoT], group: Group[ClientCoT]) -> Union[str, SendableEmbed, MessagePayload]:
async def create_group_help(self, context: Context[ClientT_Co_D], group: Group[ClientT_Co_D]) -> Union[str, SendableEmbed, MessagePayload]:
raise NotImplementedError
@abstractmethod
async def create_cog_help(self, context: Context[ClientCoT], cog: Cog[ClientCoT]) -> Union[str, SendableEmbed, MessagePayload]:
async def create_cog_help(self, context: Context[ClientT_Co_D], cog: Cog[ClientT_Co_D]) -> Union[str, SendableEmbed, MessagePayload]:
raise NotImplementedError
async def send_help_command(self, context: Context[ClientCoT], message_payload: MessagePayload) -> Message:
async def send_help_command(self, context: Context[ClientT_Co_D], message_payload: MessagePayload) -> Message:
return await context.send(**message_payload)
async def filter_commands(self, context: Context[ClientCoT], commands: list[Command[ClientCoT]]) -> list[Command[ClientCoT]]:
filtered: list[Command[ClientCoT]] = []
async def filter_commands(self, context: Context[ClientT_Co_D], commands: list[Command[ClientT_Co_D]]) -> list[Command[ClientT_Co_D]]:
filtered: list[Command[ClientT_Co_D]] = []
for command in commands:
if command.hidden:
continue
try:
if await context.can_run(command):
filtered.append(command)
@@ -58,34 +61,29 @@ class HelpCommand(ABC, Generic[ClientCoT]):
return filtered
async def group_commands(self, context: Context[ClientCoT], commands: list[Command[ClientCoT]]) -> dict[Optional[Cog[ClientCoT]], list[Command[ClientCoT]]]:
cogs: dict[Optional[Cog[ClientCoT]], list[Command[ClientCoT]]] = {}
async def group_commands(self, context: Context[ClientT_Co_D], commands: list[Command[ClientT_Co_D]]) -> dict[Optional[Cog[ClientT_Co_D]], list[Command[ClientT_Co_D]]]:
cogs: dict[Optional[Cog[ClientT_Co_D]], list[Command[ClientT_Co_D]]] = {}
for command in commands:
cogs.setdefault(command.cog, []).append(command)
return cogs
async def handle_message(self, context: Context[ClientCoT], message: Message) -> None:
async def handle_message(self, context: Context[ClientT_Co_D], message: Message) -> None:
pass
async def get_channel(self, context: Context) -> Messageable:
return context
@abstractmethod
async def handle_no_command_found(self, context: Context[ClientCoT], name: str) -> Any:
async def handle_no_command_found(self, context: Context[ClientT_Co_D], name: str) -> Union[str, SendableEmbed, MessagePayload]:
raise NotImplementedError
@abstractmethod
async def handle_no_cog_found(self, context: Context[ClientCoT], name: str) -> Any:
raise NotImplementedError
class DefaultHelpCommand(HelpCommand[ClientCoT]):
class DefaultHelpCommand(HelpCommand[ClientT_Co_D]):
def __init__(self, default_cog_name: str = "No Cog"):
self.default_cog_name = default_cog_name
async def create_bot_help(self, context: Context[ClientCoT], commands: dict[Optional[Cog[ClientCoT]], list[Command[ClientCoT]]]) -> Union[str, SendableEmbed, MessagePayload]:
async def create_global_help(self, context: Context[ClientT_Co_D], commands: dict[Optional[Cog[ClientT_Co_D]], list[Command[ClientT_Co_D]]]) -> Union[str, SendableEmbed, MessagePayload]:
lines = ["```"]
for cog, cog_commands in commands.items():
@@ -100,7 +98,7 @@ class DefaultHelpCommand(HelpCommand[ClientCoT]):
lines.append("```")
return "\n".join(lines)
async def create_cog_help(self, context: Context[ClientCoT], cog: Cog[ClientCoT]) -> Union[str, SendableEmbed, MessagePayload]:
async def create_cog_help(self, context: Context[ClientT_Co_D], cog: Cog[ClientT_Co_D]) -> Union[str, SendableEmbed, MessagePayload]:
lines = ["```"]
lines.append(f"{cog.qualified_name}:")
@@ -111,7 +109,7 @@ class DefaultHelpCommand(HelpCommand[ClientCoT]):
lines.append("```")
return "\n".join(lines)
async def create_command_help(self, context: Context[ClientCoT], command: Command[ClientCoT]) -> Union[str, SendableEmbed, MessagePayload]:
async def create_command_help(self, context: Context[ClientT_Co_D], command: Command[ClientT_Co_D]) -> Union[str, SendableEmbed, MessagePayload]:
lines = ["```"]
lines.append(f"{command.name}:")
@@ -127,7 +125,7 @@ class DefaultHelpCommand(HelpCommand[ClientCoT]):
lines.append("```")
return "\n".join(lines)
async def create_group_help(self, context: Context[ClientCoT], group: Group[ClientCoT]) -> Union[str, SendableEmbed, MessagePayload]:
async def create_group_help(self, context: Context[ClientT_Co_D], group: Group[ClientT_Co_D]) -> Union[str, SendableEmbed, MessagePayload]:
lines = ["```"]
lines.append(f"{group.name}:")
@@ -145,54 +143,66 @@ class DefaultHelpCommand(HelpCommand[ClientCoT]):
lines.append("```")
return "\n".join(lines)
async def handle_no_command_found(self, context: Context[ClientCoT], name: str) -> None:
channel = await self.get_channel(context)
await channel.send(f"Command `{name}` not found.")
async def handle_no_command_found(self, context: Context[ClientT_Co_D], name: str) -> str:
return f"Command `{name}` not found."
async def handle_no_cog_found(self, context: Context[ClientCoT], name: str) -> None:
channel = await self.get_channel(context)
await channel.send(f"Cog `{name}` not found.")
class HelpCommandImpl(Command[ClientCoT]):
def __init__(self, client: ClientCoT):
class HelpCommandImpl(Command[ClientT_Co_D]):
def __init__(self, client: ClientT_Co_D):
self.client = client
async def callback(_: Union[ClientCoT, Cog[ClientCoT]], context: Context[ClientCoT], *args: str) -> None:
async def callback(_: Union[ClientT_Co_D, Cog[ClientT_Co_D]], context: Context[ClientT_Co_D], *args: str) -> None:
await help_command_impl(context.client, context, *args)
super().__init__(callback=callback, name="help", aliases=[])
self.description: str | None = "Shows help for a command, cog or the entire bot"
async def help_command_impl(self: ClientT, context: Context[ClientT], *arguments: str) -> None:
help_command = self.help_command
async def help_command_impl(client: ClientT_D, context: Context[ClientT_D], *arguments: str) -> None:
help_command = client.help_command
if not help_command:
return
filtered_commands = await help_command.filter_commands(context, self.commands)
filtered_commands = await help_command.filter_commands(context, client.commands)
commands = await help_command.group_commands(context, filtered_commands)
if not arguments:
payload = await help_command.create_bot_help(context, commands)
else:
command_name = arguments[0]
payload = await help_command.create_global_help(context, commands)
try:
command = self.get_command(command_name)
except KeyError:
cog = self.cogs.get(command_name)
if cog:
payload = await help_command.create_cog_help(context, cog)
else:
parent: ClientT_D | Group[ClientT_D] = client
for param in arguments:
try:
command = parent.get_command(param)
except LookupError:
try:
cog = client.get_cog(param)
except LookupError:
payload = await help_command.handle_no_command_found(context, param)
else:
payload = await help_command.create_cog_help(context, cog)
finally:
break
if isinstance(command, Group):
parent = command
else:
return await help_command.handle_no_command_found(context, command_name)
payload = await help_command.create_command_help(context, command)
break
else:
if TYPE_CHECKING:
command = cast(Command[ClientT_D], ...)
if isinstance(command, Group):
payload = await help_command.create_group_help(context, command)
else:
payload = await help_command.create_command_help(context, command)
if TYPE_CHECKING:
payload = cast(MessagePayload, ...)
msg_payload: MessagePayload
if isinstance(payload, str):
+4 -3
View File
@@ -12,9 +12,10 @@ if TYPE_CHECKING:
__all__ = ("evaluate_parameters",)
ClientT = TypeVar("ClientT", bound="CommandsClient", default="CommandsClient")
ClientCoT = TypeVar("ClientCoT", bound="CommandsClient", default="CommandsClient", covariant=True)
ContextT = TypeVar("ContextT", bound="Context")
ClientT_Co = TypeVar("ClientT_Co", bound="CommandsClient", covariant=True)
ClientT_D = TypeVar("ClientT_D", bound="CommandsClient", default="CommandsClient")
ClientT_Co_D = TypeVar("ClientT_Co_D", bound="CommandsClient", default="CommandsClient", covariant=True)
ContextT = TypeVar("ContextT", bound="Context", default="Context")
def evaluate_parameters(parameters: Iterable[Parameter], globals: dict[str, Any]) -> list[Parameter]:
new_parameters: list[Parameter] = []
+10 -1
View File
@@ -1,4 +1,6 @@
from typing import Iterator
from typing_extensions import Self
from .errors import NoClosingQuote
@@ -16,7 +18,8 @@ class StringView:
def get_rest(self) -> str:
if self.should_undo:
return f"{self.temp} {''.join(self.value)}"
return f"{self.temp} {''.join(self.value)}".rstrip()
# prevent a new space appearing at end if the buffer is depleted
return "".join(self.value)
@@ -51,3 +54,9 @@ class StringView:
self.temp = output
return output
def __iter__(self) -> Self:
return self
def __next__(self) -> str:
return self.get_next_word()
+21 -15
View File
@@ -6,6 +6,7 @@ from typing import (TYPE_CHECKING, Any, Coroutine, Literal, Optional, TypeVar,
import aiohttp
import ulid
from .errors import Forbidden, HTTPError, ServerError
from .file import File
@@ -19,23 +20,18 @@ if TYPE_CHECKING:
from .enums import SortType
from .file import File
from .types import ApiInfo
from .types import Autumn as AutumnPayload
from .types import Channel, DMChannel
from .types import Emoji as EmojiPayload
from .types import EmojiParent, GetServerMembers, GroupDMChannel
from .types import Interactions as InteractionsPayload
from .types import Invite
from .types import Masquerade as MasqueradePayload
from .types import Member
from .types import Member as MemberPayload
from .types import Message as MessagePayload
from .types import (MessageReplyPayload, MessageWithUserData,
PartialInvite, Role)
from .types import SendableEmbed as SendableEmbedPayload
from .types import Server, ServerBans, TextChannel
from .types import User as UserPayload
from .types import UserProfile, VoiceChannel
from .types import (Server, ServerBans, TextChannel, UserProfile, VoiceChannel, Member, Invite, ApiInfo, Channel, SavedMessages,
DMChannel, EmojiParent, GetServerMembers, GroupDMChannel, MessageReplyPayload, MessageWithUserData, PartialInvite, CreateRole)
from aiohttp.client import _RequestOptions
__all__ = ("HttpClient",)
@@ -55,7 +51,7 @@ class HttpClient:
async def request(self, method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"], route: str, *, json: Optional[dict[str, Any]] = None, nonce: bool = True, params: Optional[dict[str, Any]] = None) -> Any:
url = f"{self.api_url}{route}"
kwargs = {}
kwargs: _RequestOptions = {}
headers = {
"User-Agent": "Revolt.py (https://github.com/revoltchat/revolt.py)",
@@ -146,7 +142,7 @@ class HttpClient:
return await self.request("POST", f"/channels/{channel}/messages", json=json)
def edit_message(self, channel: str, message: str, content: Optional[str], embeds: Optional[list[SendableEmbedPayload]] = None) -> Request[None]:
json = {}
json: dict[str, Any] = {}
if content is not None:
json["content"] = content
@@ -290,7 +286,7 @@ class HttpClient:
def fetch_dm_channels(self) -> Request[list[Union[DMChannel, GroupDMChannel]]]:
return self.request("GET", "/users/dms")
def open_dm(self, user_id: str) -> Request[DMChannel]:
def open_dm(self, user_id: str) -> Request[DMChannel | SavedMessages]:
return self.request("GET", f"/users/{user_id}/dm")
def fetch_channel(self, channel_id: str) -> Request[Channel]:
@@ -347,7 +343,7 @@ class HttpClient:
def fetch_bans(self, server_id: str) -> Request[ServerBans]:
return self.request("GET", f"/servers/{server_id}/bans")
def create_role(self, server_id: str, name: str) -> Request[Role]:
def create_role(self, server_id: str, name: str) -> Request[CreateRole]:
return self.request("POST", f"/servers/{server_id}/roles", json={"name": name}, nonce=False)
def delete_role(self, server_id: str, role_id: str) -> Request[None]:
@@ -405,7 +401,14 @@ class HttpClient:
return self.request("PUT", f"/channels/{channel_id}/messages/{message_id}/reactions/{emoji}")
def remove_reaction(self, channel_id: str, message_id: str, emoji: str, user_id: Optional[str], remove_all: bool) -> Request[None]:
return self.request("PUT", f"/channels/{channel_id}/messages/{message_id}/reactions/{emoji}")
parameters: dict[str, str] = {}
if user_id:
parameters["user_id"] = user_id
parameters["remove_all"] = "true" if remove_all else "false"
return self.request("DELETE", f"/channels/{channel_id}/messages/{message_id}/reactions/{emoji}", params=parameters)
def remove_all_reactions(self, channel_id: str, message_id: str) -> Request[None]:
return self.request("DELETE", f"/channels/{channel_id}/messages/{message_id}/reactions")
@@ -422,7 +425,10 @@ class HttpClient:
return await self.request("PUT", f"/custom/emoji/{asset['id']}", json={"name": name, "parent": parent, "nsfw": nsfw})
def edit_member(self, server_id: str, member_id: str, remove: list[str] | None, values: dict[str, Any]) -> Request[MemberPayload]:
return self.request("PATCH", f"/servers/{server_id}/members/{member_id}", json={"remove": remove, **values})
if remove:
values["remove"] = remove
return self.request("PATCH", f"/servers/{server_id}/members/{member_id}", json=values)
def delete_messages(self, channel_id: str, messages: list[str]) -> Request[None]:
return self.request("DELETE", f"/channels/{channel_id}/messages/bulk", json={"ids": messages})
+32 -18
View File
@@ -4,7 +4,7 @@ import datetime
from typing import TYPE_CHECKING, Any, Optional
from .utils import _Missing, Missing
from .utils import _Missing, Missing, parse_timestamp
from .asset import Asset
from .permissions import Permissions
@@ -63,17 +63,12 @@ class Member(User):
self.server: Server = server
self.nickname: str | None = data.get("nickname")
joined_at = data["joined_at"]
if isinstance(joined_at, int):
self.joined_at: datetime.datetime = datetime.datetime.fromtimestamp(joined_at / 1000)
else:
self.joined_at: datetime.datetime = datetime.datetime.strptime(joined_at, "%Y-%m-%dT%H:%M:%S.%f%z")
self.joined_at: datetime.datetime = parse_timestamp(data["joined_at"])
self.current_timeout: datetime.datetime | None
if current_timeout := data.get("timeout"):
self.current_timeout = datetime.datetime.strptime(current_timeout, "%Y-%m-%dT%H:%M:%S.%f%z")
self.current_timeout = parse_timestamp(current_timeout)
else:
self.current_timeout = None
@@ -92,7 +87,14 @@ class Member(User):
""":class:`str`: Returns a string that allows you to mention the given member."""
return f"<@{self.id}>"
def _update(self, *, nickname: Optional[str] = None, avatar: Optional[FilePayload] = None, roles: Optional[list[str]] = None):
def _update(
self,
*,
nickname: Optional[str] = None,
avatar: Optional[FilePayload] = None,
roles: Optional[list[str]] = None,
timeout: Optional[str | int] = None
) -> None:
if nickname is not None:
self.nickname = nickname
@@ -103,6 +105,9 @@ class Member(User):
member_roles = [self.server.get_role(role_id) for role_id in roles]
self.roles = sorted(member_roles, key=lambda role: role.rank, reverse=True)
if timeout is not None:
self.current_timeout = parse_timestamp(timeout)
async def kick(self) -> None:
"""Kicks the member from the server"""
await self.state.http.kick_member(self.server.id, self.id)
@@ -129,6 +134,19 @@ class Member(User):
avatar: File | None | _Missing = Missing,
timeout: datetime.timedelta | None | _Missing = Missing
) -> None:
"""Edits the member
Parameters
-----------
nickname: Union[:class:`str`, :class:`None`]
The new nickname, or :class:`None` to reset it
roles: Union[list[:class:`Role`], :class:`None`]
The new roles for the member, or :class:`None` to clear it
avatar: Union[:class:`File`, :class:`None`]
The new server avatar, or :class:`None` to reset it
timeout: Union[:class:`datetime.timedelta`, :class:`None`]
The new timeout length for the member, or :class:`None` to reset it
"""
remove: list[str] = []
data: dict[str, Any] = {}
@@ -139,21 +157,17 @@ class Member(User):
if roles is None:
remove.append("Roles")
elif roles is not Missing:
data["roles"] = roles
elif not isinstance(roles, _Missing):
data["roles"] = [role.id for role in roles]
if avatar is None:
remove.append("Avatar")
elif avatar is not Missing:
# pyright cant understand custom singletons - it doesnt know this will never be an instance of _Missing here because Missing is the only instance
assert not isinstance(avatar, _Missing)
elif not isinstance(avatar, _Missing):
data["avatar"] = (await self.state.http.upload_file(avatar, "avatars"))["id"]
if timeout is None:
remove.append("Timeout")
elif timeout is not Missing:
assert not isinstance(timeout, _Missing)
elif not isinstance(timeout, _Missing):
data["timeout"] = (datetime.datetime.now(datetime.timezone.utc) + timeout).isoformat()
await self.state.http.edit_member(self.server.id, self.id, remove, data)
@@ -166,7 +180,7 @@ class Member(User):
length: :class:`datetime.timedelta`
The length of the timeout
"""
ends_at = datetime.datetime.utcnow() + length
ends_at = datetime.datetime.now(tz=datetime.timezone.utc) + length
await self.state.http.edit_member(self.server.id, self.id, None, {"timeout": ends_at.isoformat()})
+71 -30
View File
@@ -3,12 +3,11 @@ from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Any, Coroutine, Optional, Union
from revolt.types.message import SystemMessageContent
from .asset import Asset, PartialAsset
from .channel import DMChannel, GroupDMChannel, TextChannel
from .channel import DMChannel, GroupDMChannel, TextChannel, SavedMessageChannel
from .embed import Embed, SendableEmbed, to_embed
from .utils import Ulid
from .utils import Ulid, parse_timestamp
if TYPE_CHECKING:
from .server import Server
@@ -17,7 +16,7 @@ if TYPE_CHECKING:
from .types import Interactions as InteractionsPayload
from .types import Masquerade as MasqueradePayload
from .types import Message as MessagePayload
from .types import MessageReplyPayload
from .types import MessageReplyPayload, SystemMessageContent
from .user import User
from .member import Member
@@ -47,8 +46,8 @@ class Message(Ulid):
The author of the message, will be :class:`User` in DMs
edited_at: Optional[:class:`datetime.datetime`]
The time at which the message was edited, will be None if the message has not been edited
mentions: list[Union[:class:`Member`, :class:`User`]]
The users or members that where mentioned in the message
raw_mentions: list[:class:`str`]
A list of ids of the mentions in this message
replies: list[:class:`Message`]
The message's this message has replied to, this may not contain all the messages if they are outside the cache
reply_ids: list[:class:`str`]
@@ -58,38 +57,37 @@ class Message(Ulid):
interactions: Optional[:class:`MessageInteractions`]
The interactions on the message, if any
"""
__slots__ = ("state", "id", "content", "attachments", "embeds", "channel", "author", "edited_at", "mentions", "replies", "reply_ids", "reactions", "interactions")
__slots__ = ("state", "id", "content", "attachments", "embeds", "channel", "author", "edited_at", "replies", "reply_ids", "reactions", "interactions")
def __init__(self, data: MessagePayload, state: State):
self.state: State = state
self.id: str = data["_id"]
self.content: str = data.get("content", "")
content = data.get("content", "")
if not isinstance(content, str):
self.system_content: SystemMessageContent = content
self.content: str = ""
else:
self.content = content
self.system_content: SystemMessageContent | None = data.get("system")
self.attachments: list[Asset] = [Asset(attachment, state) for attachment in data.get("attachments", [])]
self.embeds: list[Embed] = [to_embed(embed, state) for embed in data.get("embeds", [])]
channel = state.get_channel(data["channel"])
assert isinstance(channel, Union[TextChannel, GroupDMChannel, DMChannel])
self.channel: TextChannel | GroupDMChannel | DMChannel = channel
assert isinstance(channel, (TextChannel, GroupDMChannel, DMChannel, SavedMessageChannel))
self.channel: TextChannel | GroupDMChannel | DMChannel | SavedMessageChannel = channel
self.server_id: str | None = self.channel.server_id
self.mentions: list[Member | User]
self.raw_mentions: list[str] = data.get("mentions", [])
if self.system_content:
author_id: str = self.system_content.get("id", data["author"])
else:
author_id = data["author"]
if self.server_id:
author = state.get_member(self.server_id, data["author"])
self.mentions = [self.server.get_member(member_id) for member_id in data.get("mentions", [])]
author = state.get_member(self.server_id, author_id)
else:
author = state.get_user(data["author"])
self.mentions = [state.get_user(member_id) for member_id in data.get("mentions", [])]
author = state.get_user(author_id)
self.author: Member | User = author
@@ -101,7 +99,7 @@ class Message(Ulid):
self.author.masquerade_avatar = PartialAsset(avatar, state)
if edited_at := data.get("edited"):
self.edited_at: Optional[datetime.datetime] = datetime.datetime.strptime(edited_at, "%Y-%m-%dT%H:%M:%S.%f%z")
self.edited_at: Optional[datetime.datetime] = parse_timestamp(edited_at)
self.replies: list[Message] = []
self.reply_ids: list[str] = []
@@ -137,17 +135,42 @@ class Message(Ulid):
self.embeds = [to_embed(embed, self.state) for embed in embeds]
if edited is not None:
if isinstance(edited, int):
self.edited_at = datetime.datetime.fromtimestamp(edited / 1000, tz=datetime.timezone.utc)
else:
self.edited_at = datetime.datetime.strptime(edited, "%Y-%m-%dT%H:%M:%S.%f%z")
self.edited_at = parse_timestamp(edited)
@property
def mentions(self) -> list[User | Member]:
"""The users or members that where mentioned in the message
Returns: list[Union[:class:`Member`, :class:`User`]]
"""
mentions: list[User | Member] = []
if self.server_id:
for mention in self.raw_mentions:
try:
mentions.append(self.server.get_member(mention))
except LookupError:
pass
else:
for mention in self.raw_mentions:
try:
mentions.append(self.state.get_user(mention))
except LookupError:
pass
return mentions
async def edit(self, *, content: Optional[str] = None, embeds: Optional[list[SendableEmbed]] = None) -> None:
"""Edits the message. The bot can only edit its own message
Parameters
-----------
content: :class:`str`
The new content of the message
embeds: list[:class:`SendableEmbed`]
The new embeds of the message
"""
new_embeds = [embed.to_dict() for embed in embeds] if embeds else None
@@ -169,15 +192,33 @@ class Message(Ulid):
return self.channel.send(*args, **kwargs, replies=[MessageReply(self, mention)])
async def add_reaction(self, emoji: str) -> None:
"""Adds a reaction to the message
Parameters
-----------
emoji: :class:`str`
The emoji to add as a reaction
"""
await self.state.http.add_reaction(self.channel.id, self.id, emoji)
async def remove_reaction(self, emoji: str, user: Optional[User] = None, remove_all: bool = False) -> None:
"""Removes a reaction from the message, this can remove either a specific users, the current users reaction or all of a specific emoji
Parameters
-----------
emoji: :class:`str`
The emoji to remove
user: Optional[:class:`User`]
The user to use for removing a reaction from
remove_all: bool
Whether or not to remove all reactions for that specific emoji
"""
await self.state.http.remove_reaction(self.channel.id, self.id, emoji, user.id if user else None, remove_all)
async def remove_all_reactions(self) -> None:
"""Removes all reactions from the message"""
await self.state.http.remove_all_reactions(self.channel.id, self.id)
@property
def server(self) -> Server:
""":class:`Server` The server this voice channel belongs too
@@ -201,12 +242,12 @@ class MessageReply:
"""
__slots__ = ("message", "mention")
def __init__(self, message: Message, mention: bool = False):
self.message: Message = message
def __init__(self, message: Ulid, mention: bool = False):
self.message: Ulid = message
self.mention: bool = mention
def to_dict(self) -> MessageReplyPayload:
return { "id": self.message.id, "mention": self.mention }
return {"id": self.message.id, "mention": self.mention}
class Masquerade:
"""represents a message's masquerade.
+21 -4
View File
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
from .file import File
from .message import Masquerade, Message, MessageInteractions, MessageReply
from .state import State
from .types.http import MessageWithUserData
__all__ = ("Messageable",)
@@ -86,6 +87,18 @@ class Messageable:
payload = await self.state.http.fetch_message(await self._get_channel_id(), message_id)
return Message(payload, self.state)
def _add_missing_users(self, payload: MessageWithUserData):
for user in payload["users"]:
if user["_id"] not in self.state.users:
self.state.add_user(user)
if members := payload.get("members", []):
server = self.state.get_server(members[0]["_id"]["server"])
for member in members:
if member["_id"]["user"] not in server._members:
server._add_member(member)
async def history(self, *, sort: SortType = SortType.latest, limit: int = 100, before: Optional[str] = None, after: Optional[str] = None, nearby: Optional[str] = None) -> list[Message]:
"""Fetches multiple messages from the channel's history
@@ -109,8 +122,10 @@ class Messageable:
"""
from .message import Message
payloads = await self.state.http.fetch_messages(await self._get_channel_id(), sort=sort, limit=limit, before=before, after=after, nearby=nearby)
return [Message(payload, self.state) for payload in payloads]
payload = await self.state.http.fetch_messages(await self._get_channel_id(), sort=sort, limit=limit, before=before, after=after, nearby=nearby, include_users=True)
self._add_missing_users(payload)
return [Message(msg, self.state) for msg in payload["messages"]]
async def search(self, query: str, *, sort: SortType = SortType.latest, limit: int = 100, before: Optional[str] = None, after: Optional[str] = None) -> list[Message]:
"""searches the channel for a query
@@ -135,8 +150,10 @@ class Messageable:
"""
from .message import Message
payloads = await self.state.http.search_messages(await self._get_channel_id(), query, sort=sort, limit=limit, before=before, after=after)
return [Message(payload, self.state) for payload in payloads]
payload = await self.state.http.search_messages(await self._get_channel_id(), query, sort=sort, limit=limit, before=before, after=after, include_users=True)
self._add_missing_users(payload)
return [Message(msg, self.state) for msg in payload["messages"]]
async def delete_messages(self, messages: list[Message]) -> None:
"""Bulk deletes messages from the channel
+2 -2
View File
@@ -178,11 +178,11 @@ class PermissionsOverwrite:
def __setattr__(self, key: str, value: Any) -> None:
if key in Permissions.FLAG_NAMES:
if key is True:
if value is True:
setattr(self._allow, key, True)
super().__setattr__(key, True)
elif key is False:
elif value is False:
setattr(self._deny, key, True)
super().__setattr__(key, False)
+5 -3
View File
@@ -1,22 +1,24 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
from typing import TYPE_CHECKING, cast
from revolt.enums import ChannelType
from .permissions import Permissions
from .server import Server
if TYPE_CHECKING:
from .channel import Channel, DMChannel, GroupDMChannel, ServerChannel
from .member import Member
from .server import Server
def calculate_permissions(member: Member, target: Server | Channel) -> Permissions:
if member.privileged:
return Permissions.all()
from .server import Server
if isinstance(target, Server):
if target.owner_id == member.id:
return Permissions.all()
@@ -26,7 +28,7 @@ def calculate_permissions(member: Member, target: Server | Channel) -> Permissio
for role in member.roles:
permissions = (permissions | role.permissions._allow) & (~role.permissions._deny)
if member.current_timeout and member.current_timeout > datetime.now():
if member.current_timeout and member.current_timeout > datetime.now(timezone.utc):
permissions = permissions & Permissions.default_view_only()
return permissions
+47 -11
View File
@@ -8,22 +8,25 @@ from .invite import Invite
from .permissions import Permissions
from .role import Role
from .utils import Ulid
from .channel import Channel, TextChannel, VoiceChannel
from .member import Member
if TYPE_CHECKING:
from .channel import Channel, TextChannel, VoiceChannel
from .emoji import Emoji
from .file import File
from .member import Member
from .state import State
from .types import Ban
from .types import Category as CategoryPayload
from .types import File as FilePayload
from .types import Server as ServerPayload
from .types import SystemMessagesConfig
from .types import Member as MemberPayload
__all__ = ("Server", "SystemMessages", "ServerBan")
class SystemMessages:
"""Holds all the configuration for the server's system message channels"""
def __init__(self, data: SystemMessagesConfig, state: State):
self.state: State = state
self.user_joined_id: str | None = data.get("user_joined")
@@ -33,6 +36,13 @@ class SystemMessages:
@property
def user_joined(self) -> Optional[TextChannel]:
"""The channel which user join messages get sent in
Returns
--------
Optional[:class:`TextChannel`]
The channel
"""
if not self.user_joined_id:
return
@@ -42,6 +52,13 @@ class SystemMessages:
@property
def user_left(self) -> Optional[TextChannel]:
"""The channel which user leave messages get sent in
Returns
--------
Optional[:class:`TextChannel`]
The channel
"""
if not self.user_left_id:
return
@@ -51,6 +68,13 @@ class SystemMessages:
@property
def user_kicked(self) -> Optional[TextChannel]:
"""The channel which user kick messages get sent in
Returns
--------
Optional[:class:`TextChannel`]
The channel
"""
if not self.user_kicked_id:
return
@@ -60,6 +84,13 @@ class SystemMessages:
@property
def user_banned(self) -> Optional[TextChannel]:
"""The channel which user ban messages get sent in
Returns
--------
Optional[:class:`TextChannel`]
The channel
"""
if not self.user_banned_id:
return
@@ -154,6 +185,12 @@ class Server(Ulid):
if channels is not None:
self._channels = {channel_id: self.state.get_channel(channel_id) for channel_id in channels}
def _add_member(self, payload: MemberPayload) -> Member:
member = Member(payload, self, self.state)
self._members[member.id] = member
return member
@property
def roles(self) -> list[Role]:
"""list[:class:`Role`] Gets all roles in the server in decending order"""
@@ -166,7 +203,7 @@ class Server(Ulid):
@property
def channels(self) -> list[Channel]:
"""list[:class:`Member`] Gets all channels in the server"""
"""list[:class:`Channel`] Gets all channels in the server"""
return list(self._channels.values())
@property
@@ -273,12 +310,11 @@ class Server(Ulid):
async def set_default_permissions(self, permissions: Permissions) -> None:
"""Sets the default server permissions.
Parameters
-----------
server_permissions: Optional[:class:`ServerPermissions`]
permissions: :class:`Permissions`
The new default server permissions
channel_permissions: Optional[:class:`ChannelPermissions`]
the new default channel permissions
"""
await self.state.http.set_server_default_permissions(self.id, permissions.value)
@@ -333,7 +369,7 @@ class Server(Ulid):
channel = self.state.add_channel(payload)
self._channels[channel.id] = channel
return cast("VoiceChannel", channel)
return cast(VoiceChannel, channel)
async def fetch_invites(self) -> list[Invite]:
"""Fetches all invites in the server
@@ -364,11 +400,11 @@ class Server(Ulid):
return Member(payload, self, self.state)
async def fetch_bans(self) -> list[ServerBan]:
"""Fetches all invites in the server
"""Fetches all bans in the server
Returns
--------
list[:class:`Invite`]
list[:class:`ServerBan`]
"""
payload = await self.state.http.fetch_bans(self.id)
@@ -390,7 +426,7 @@ class Server(Ulid):
"""
payload = await self.state.http.create_role(self.id, name)
return Role(payload, name, self, self.state)
return Role(payload["role"], payload["id"], self, self.state)
async def create_emoji(self, name: str, file: File, *, nsfw: bool = False) -> Emoji:
"""Creates an emoji
@@ -414,7 +450,7 @@ class ServerBan:
Attributes
-----------
reason: Optional[:class:str`]
reason: Optional[:class:`str`]
The reason the user was banned
server: :class:`Server`
The server the user was banned in
+1 -5
View File
@@ -61,8 +61,6 @@ class State:
raise LookupError from None
def add_user(self, payload: UserPayload) -> User:
user = User(payload, self)
if payload.get("relationship") == "User":
@@ -73,10 +71,8 @@ class State:
def add_member(self, server_id: str, payload: MemberPayload) -> Member:
server = self.get_server(server_id)
member = Member(payload, server, self)
server._members[member.id] = member
return member
return server._add_member(payload)
def add_channel(self, payload: ChannelPayload) -> Channel:
channel = channel_factory(payload, self)
+11 -4
View File
@@ -4,8 +4,7 @@ from typing import TYPE_CHECKING, Literal, TypedDict, Union
from typing_extensions import NotRequired
from .channel import (Channel, DMChannel, GroupDMChannel, SavedMessages,
TextChannel, VoiceChannel)
from .channel import Channel, DMChannel, GroupDMChannel, SavedMessages, TextChannel, VoiceChannel
from .message import Message
from .permissions import Overwrite
@@ -16,8 +15,7 @@ if TYPE_CHECKING:
from .file import File
from .member import Member, MemberID
from .server import Server, SystemMessagesConfig
from .user import Status, User, UserProfile
from .user import Status, User, UserProfile, UserRelation
__all__ = (
"BasePayload",
@@ -146,6 +144,7 @@ class ServerMemberUpdateEventPayloadData(TypedDict, total=False):
nickname: str
avatar: File
roles: list[str]
timeout: str | int
class ServerMemberUpdateEventPayload(BasePayload):
id: MemberID
@@ -179,6 +178,14 @@ class UserUpdateEventPayloadData(TypedDict):
avatar: NotRequired[File]
online: NotRequired[bool]
profile: NotRequired[UserProfile]
username: NotRequired[str]
display_name: NotRequired[str]
relations: NotRequired[list[UserRelation]]
badges: NotRequired[int]
online: NotRequired[bool]
flags: NotRequired[int]
discriminator: NotRequired[str]
privileged: NotRequired[bool]
class UserUpdateEventPayload(BasePayload):
id: str
+8 -1
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
from typing_extensions import NotRequired
if TYPE_CHECKING:
from .member import Member
from .message import Message
from .user import User
from .role import Role
__all__ = (
@@ -14,6 +16,7 @@ __all__ = (
"Autumn",
"GetServerMembers",
"MessageWithUserData",
"CreateRole",
)
@@ -48,5 +51,9 @@ class GetServerMembers(TypedDict):
class MessageWithUserData(TypedDict):
messages: list[Message]
members: list[Member]
members: NotRequired[list[Member]]
users: list[User]
class CreateRole(TypedDict):
id: str
role: Role
+1 -1
View File
@@ -20,4 +20,4 @@ class Member(TypedDict):
avatar: NotRequired[File]
roles: NotRequired[list[str]]
joined_at: int | str
timeout: NotRequired[str]
timeout: NotRequired[str | int]
+4 -2
View File
@@ -23,6 +23,7 @@ __all__ = (
"Interactions",
"Message",
"MessageReplyPayload",
"SystemMessageContent",
)
class UserAddContent(TypedDict):
@@ -71,12 +72,13 @@ class Message(TypedDict):
_id: str
channel: str
author: str
content: Union[str, SystemMessageContent]
content: str
system: NotRequired[SystemMessageContent]
attachments: NotRequired[list[File]]
embeds: NotRequired[list[Embed]]
mentions: NotRequired[list[str]]
replies: NotRequired[list[str]]
edited: NotRequired[str]
edited: NotRequired[str | int]
masquerade: NotRequired[Masquerade]
interactions: NotRequired[Interactions]
reactions: dict[str, list[str]]
+85 -8
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple, Optional, Union
from weakref import WeakValueDictionary
from revolt.types.user import UserRelation
from .asset import Asset, PartialAsset
from .channel import DMChannel, GroupDMChannel
from .channel import DMChannel, GroupDMChannel, SavedMessageChannel
from .enums import PresenceType, RelationshipType
from .flags import UserBadges
from .messageable import Messageable
@@ -76,10 +78,10 @@ class User(Messageable, Ulid):
self.state = state
self._members: WeakValueDictionary[str, Member] = WeakValueDictionary() # we store all member versions of this user to avoid having to check every guild when needing to update.
self.id: str = data["_id"]
self.discriminator = data["discriminator"]
self.display_name = data.get("display_name")
self.discriminator: str = data["discriminator"]
self.display_name: str | None = data.get("display_name")
self.original_name: str = data["username"]
self.dm_channel: DMChannel | None = None
self.dm_channel: DMChannel | SavedMessageChannel | None = None
bot = data.get("bot")
@@ -107,6 +109,7 @@ class User(Messageable, Ulid):
user = state.get_user(relation["_id"])
if user:
relations.append(Relation(RelationshipType(relation["status"]), user))
self.relations: list[Relation] = relations
relationship = data.get("relationship")
@@ -175,9 +178,13 @@ class User(Messageable, Ulid):
async def _get_channel_id(self):
if not self.dm_channel:
payload = await self.state.http.open_dm(self.id)
self.dm_channel = DMChannel(payload, self.state)
return self.id
if payload["channel_type"] == "SavedMessages":
self.dm_channel = SavedMessageChannel(payload, self.state)
else:
self.dm_channel = DMChannel(payload, self.state)
return self.dm_channel.id
@property
def owner(self) -> User:
@@ -203,7 +210,21 @@ class User(Messageable, Ulid):
""":class:`str`: Returns a string that allows you to mention the given user."""
return f"<@{self.id}>"
def _update(self, *, status: Optional[StatusPayload] = None, profile: Optional[UserProfileData] = None, avatar: Optional[File] = None, online: Optional[bool] = None):
def _update(
self,
*,
status: Optional[StatusPayload] = None,
profile: Optional[UserProfileData] = None,
avatar: Optional[File] = None,
online: Optional[bool] = None,
display_name: Optional[str] = None,
relations: Optional[list[UserRelation]] = None,
badges: Optional[int] = None,
flags: Optional[int] = None,
discriminator: Optional[str] = None,
privileged: Optional[bool] = None,
username: Optional[str] = None
) -> None:
if status is not None:
presence = status.get("presence")
self.status = Status(status.get("text"), PresenceType(presence) if presence else None)
@@ -222,11 +243,52 @@ class User(Messageable, Ulid):
if online is not None:
self.online = online
if display_name is not None:
self.display_name = display_name
if relations is not None:
new_relations: list[Relation] = []
for relation in relations:
user = self.state.get_user(relation["_id"])
if user:
new_relations.append(Relation(RelationshipType(relation["status"]), user))
self.relations = new_relations
if badges is not None:
self.badges = UserBadges(badges)
if flags is not None:
self.flags = flags
if discriminator is not None:
self.discriminator = discriminator
if privileged is not None:
self.privileged = privileged
if username is not None:
self.original_name = username
# update user infomation for all members
if self.__class__ is User:
for member in self._members.values():
User._update(member, status=status, profile=profile, avatar=avatar, online=online)
User._update(
member,
status=status,
profile=profile,
avatar=avatar,
online=online,
display_name=display_name,
relations=relations,
badges=badges,
flags=flags,
discriminator=discriminator,
privileged=privileged,
username=username
)
async def default_avatar(self) -> bytes:
"""Returns the default avatar for this user
@@ -288,3 +350,18 @@ class User(Messageable, Ulid):
return self._members[server.id]
except IndexError:
raise LookupError from None
async def open_dm(self) -> DMChannel | SavedMessageChannel:
"""Opens a dm with the user, if this user is the current user this will return :class:`SavedMessageChannel`
.. note:: using this function is discouraged as :meth:`User.send` does this implicitally.
Returns
--------
Union[:class:`DMChannel`, :class:`SavedMessageChannel`]
"""
await self._get_channel_id()
assert self.dm_channel
return self.dm_channel
+37 -1
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import datetime
import inspect
from contextlib import asynccontextmanager
@@ -8,7 +10,7 @@ import ulid
from aiohttp import ClientSession
from typing_extensions import ParamSpec
__all__ = ("Missing", "copy_doc", "maybe_coroutine", "get", "client_session")
__all__ = ("_Missing", "Missing", "copy_doc", "maybe_coroutine", "get", "client_session", "parse_timestamp")
class _Missing:
def __repr__(self) -> str:
@@ -45,11 +47,39 @@ async def maybe_coroutine(func: Callable[P, Union[R_T, Coroutine[Any, Any, R_T]]
class Ulid:
"""Base class for any revolt object with an id
Attributes
-----------
id: :class:`str`
The id of the object
"""
id: str
@property
def created_at(self) -> datetime.datetime:
"""Returns a datetime for when the object was created according to the id
Returns
--------
:class:`datetime.datetime`
The datetime of the creation date and time
"""
return ulid.from_str(self.id).timestamp().datetime
class Object(Ulid):
"""Class to mock objects with an id
.. note::
This does not validate or guarantee the id is correct, you must handle this yourself
Parameters
-----------
id: :class:`str`
The ULID id to mock
"""
def __init__(self, id: str):
self.id = id
def get(iterable: Iterable[T], **attrs: Any) -> T:
"""A convenience function to help get a value from an iterable with a specific attribute
@@ -115,3 +145,9 @@ async def client_session():
yield session
finally:
await session.close()
def parse_timestamp(timestamp: int | str) -> datetime.datetime:
if isinstance(timestamp, int):
return datetime.datetime.fromtimestamp(timestamp / 1000, tz=datetime.timezone.utc)
else:
return datetime.datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%f%z")
+32 -20
View File
@@ -6,6 +6,7 @@ import time
from copy import copy
from typing import TYPE_CHECKING, Callable, NamedTuple, cast
from .errors import RevoltError
from . import utils
from .channel import GroupDMChannel, TextChannel, VoiceChannel
from .enums import RelationshipType
@@ -81,7 +82,7 @@ class WebsocketHandler:
async def send_payload(self, payload: BasePayload) -> None:
if use_msgpack:
await self.websocket.send_bytes(msgpack.packb(payload))
await self.websocket.send_bytes(msgpack.packb(payload)) # type: ignore
else:
await self.websocket.send_str(json.dumps(payload))
@@ -102,8 +103,9 @@ class WebsocketHandler:
async def handle_event(self, payload: BasePayload) -> None:
event_type = payload["type"].lower()
logger.debug("Recieved event %s %s", event_type, payload)
try:
if event_type != "ready":
if event_type not in ["ready", "notfound"]:
await self.ready.wait()
func = getattr(self, f"handle_{event_type}")
@@ -115,6 +117,9 @@ class WebsocketHandler:
async def handle_authenticated(self, _: BasePayload) -> None:
logger.info("Successfully authenticated")
async def handle_notfound(self, _: BasePayload) -> None:
raise RevoltError("Invalid token")
async def handle_ready(self, payload: ReadyEventPayload) -> None:
for user_payload in payload["users"]:
user = self.state.add_user(user_payload)
@@ -160,9 +165,10 @@ class WebsocketHandler:
if server_id := message.channel.server_id:
await self._wait_for_server_ready(server_id)
before = copy(message)
message._update(**payload["data"])
self.dispatch("message_update", message)
self.dispatch("message_update", before, message)
async def handle_messagedelete(self, payload: MessageDeleteEventPayload) -> None:
self.dispatch("raw_message_delete", payload)
@@ -312,7 +318,7 @@ class WebsocketHandler:
self.dispatch("member_join", member)
async def handle_memberleave(self, payload: ServerMemberLeaveEventPayload) -> None:
async def handle_servermemberleave(self, payload: ServerMemberLeaveEventPayload) -> None:
await self._wait_for_server_ready(payload["id"])
server = self.state.get_server(payload["id"])
@@ -390,7 +396,7 @@ class WebsocketHandler:
if server := self.state.get_channel(payload["channel_id"]).server_id:
await self._wait_for_server_ready(server)
self.dispatch("raw_reaction_add", payload["channel_id"], payload["id"], payload["user_id"], payload["emoji_id"])
self.dispatch("raw_reaction_add", payload)
try:
message = utils.get(self.state.messages, id=payload["id"])
@@ -407,7 +413,7 @@ class WebsocketHandler:
if server := self.state.get_channel(payload["channel_id"]).server_id:
await self._wait_for_server_ready(server)
self.dispatch("raw_reaction_remove", payload["channel_id"], payload["id"], payload["user_id"], payload["emoji_id"])
self.dispatch("raw_reaction_remove", payload)
try:
message = utils.get(self.state.messages, id=payload["id"])
@@ -423,7 +429,7 @@ class WebsocketHandler:
if server := self.state.get_channel(payload["channel_id"]).server_id:
await self._wait_for_server_ready(server)
self.dispatch("raw_reaction_clear", payload["channel_id"], payload["id"], payload["emoji_id"])
self.dispatch("raw_reaction_clear", payload)
try:
message = utils.get(self.state.messages, id=payload["id"])
@@ -459,26 +465,32 @@ class WebsocketHandler:
self.dispatch("bulk_message_delete", messages)
async def start(self) -> None:
async def start(self, reconnect: bool) -> None:
if use_msgpack:
url = f"{self.ws_url}?format=msgpack"
else:
url = f"{self.ws_url}?format=json"
self.websocket = await self.session.ws_connect(url) # type: ignore
await self.send_authenticate()
asyncio.create_task(self.heartbeat())
while True:
self.websocket = await self.session.ws_connect(url) # type: ignore
await self.send_authenticate()
hb = asyncio.create_task(self.heartbeat())
async for msg in self.websocket:
msg = cast(WSMessage, msg) # aiohttp doesnt use NamedTuple so the type info is missing
async for msg in self.websocket:
msg = cast(WSMessage, msg) # aiohttp doesnt use NamedTuple so the type info is missing
if use_msgpack:
data = cast(bytes, msg.data)
if use_msgpack:
data = cast(bytes, msg.data)
payload = msgpack.unpackb(data)
else:
data = cast(str, msg.data)
payload = msgpack.unpackb(data) # type: ignore
else:
data = cast(str, msg.data)
payload = json.loads(data)
payload = json.loads(data)
self.loop.create_task(self.handle_event(payload))
self.loop.create_task(self.handle_event(payload))
hb.cancel()
if not reconnect:
return