44 Commits

Author SHA1 Message Date
Zomatree 8aafa8b7f2 fix for 3.9 2022-01-05 18:36:32 +00:00
Zomatree 3754357812 bump version to 0.1.3 2022-01-05 18:33:44 +00:00
Zomatree 5b757bd5df fix union converters 2022-01-05 18:31:58 +00:00
Zomatree e938136b2e fix bug with channels 2022-01-05 18:24:06 +00:00
Zomatree 18e8366651 bump sphinx 2022-01-03 21:07:55 +00:00
Zomatree 85fbe30c32 Merge branch 'master' of github.com:Zomatree/revolt.py 2022-01-03 20:55:50 +00:00
Zomatree 02a839a903 bump version 2022-01-03 20:55:39 +00:00
LightSage 9fc5ee1c84 Fix user update (#23) 2022-01-03 20:54:05 +00:00
Zomatree 3200b06019 Merge branch 'master' of github.com:Zomatree/revolt.py 2022-01-03 20:51:22 +00:00
Zomatree df8815e68a add more converters 2022-01-03 20:51:19 +00:00
Ed L ccd379b24b update repo links, grammar (#24)
* chore: update repo links, grammar

* Update revolt/http.py

Co-authored-by: Zomatree <39768508+Zomatree@users.noreply.github.com>
2022-01-03 20:50:16 +00:00
Zomatree 1c5ceffc58 fix categories docs 2022-01-03 20:39:17 +00:00
Zomatree fd6ef5fc30 add utils.get 2022-01-03 20:37:21 +00:00
Zomatree 7ccd651e4b add server.categories and get_category 2022-01-03 20:00:50 +00:00
Zomatree 35566fff42 add client.users 2022-01-03 19:58:34 +00:00
Zomatree abbbc8c1eb fix client.get_* typehints 2022-01-03 19:58:02 +00:00
Zomatree 67af8b5bf0 update justfile 2022-01-03 19:57:23 +00:00
Zomatree 39098692bc add flags 2021-11-20 22:33:10 +00:00
Zomatree a16bac6207 isort 2021-11-13 23:59:25 +00:00
Zomatree 23d8ced599 Merge branch 'master' of github.com:Zomatree/revolt.py 2021-11-13 23:53:24 +00:00
Zomatree e5d65b9400 add Literal converter and rejig command client 2021-11-13 23:53:21 +00:00
Zomatree c874017a6b add more checks 2021-11-13 23:52:23 +00:00
LightSage 21d27bd44a Fix command repr (#22) 2021-11-13 10:35:57 +00:00
Zomatree 91fde98739 add more advance converters 2021-11-10 16:55:24 +00:00
Zomatree 34a80ea5e2 fix linter 2021-11-10 16:55:14 +00:00
Zomatree 7d6f33f95c auto install deps 2021-11-10 16:37:08 +00:00
Zomatree 1310f3a31a add the rest of the events 2021-11-08 13:59:16 +00:00
Zomatree 611939ed6f change check typings 2021-11-07 11:08:24 +00:00
Zomatree 5514d03b63 change example orefix 2021-11-07 10:39:19 +00:00
Zomatree bd984090c7 add basic check system 2021-11-07 10:33:41 +00:00
Zomatree b2558c1d37 fix deleting message outside cache 2021-11-07 10:26:39 +00:00
Zomatree 0c7cc738fc add masquerade 2021-11-07 10:26:12 +00:00
Zomatree d7175d0079 make context subclass messageable 2021-11-06 05:07:47 +00:00
rockboy987 b5727c818e Add replies parameter to messageable.send (#19)
* Add replies parameter to messageable.send

* Apply requested changes
2021-11-04 22:18:55 +00:00
rockboy987 89986731c1 Fix server to be optional in Channel (#21) 2021-11-03 04:37:10 +00:00
Zomatree 2a3071712e add proper argument parsing - converters and keyword arguments 2021-10-30 15:57:03 +01:00
Zomatree 77fa908ad5 add justfile 2021-10-29 09:16:43 +01:00
Zomatree b94c8cd58e fix argument splitter 2021-10-29 03:48:54 +01:00
Zomatree c5a67335d2 add more attributes to context 2021-10-29 03:48:38 +01:00
Zomatree 287b2d42ea add repr to command 2021-10-29 03:45:39 +01:00
Zomatree a2168e35e9 add server_update event + missing server attrs 2021-10-28 03:43:20 +01:00
Zomatree 497d666311 Closes #18 2021-10-28 03:42:06 +01:00
Zomatree c648eda8e5 fix slots issue 2021-10-23 19:58:12 +01:00
Zomatree 34a6a9cdbc bump dev status 2021-10-23 17:22:40 +01:00
38 changed files with 1595 additions and 531 deletions
+2
View File
@@ -5,3 +5,5 @@ dist
*.egg-info
docs/_build
.vscode
.env
.mypy_cache
Executable
+28
View File
@@ -0,0 +1,28 @@
set dotenv-load := true
activate:
source .venv/bin/activate
venv:
python -m venv .venv
just activate
python -m pip install -r requirements.txt
python -m pip install -r docs_requirements.txt
test: venv
python test.py
build: venv
python -m build
upload: venv
python -m twine upload dist/* -u $PYPI_USERNAME -p $PYPI_PASSWORD
lint:
pyright . --venv-path .venv
coverage:
pyright --lib --ignoreexternal --verifytypes revolt
docs:
cd docs && make html
+3 -7
View File
@@ -1,16 +1,12 @@
# Revolt.py
An async library to interact with the https://revolt.chat api.
An async library to interact with the https://revolt.chat API. This library will **only support bots** and I will **not** implement anything exclusively for user accounts.
This library will be focused on making bots and i will not implement anything only for user accounts.
Support server: https://app.revolt.chat/invite/FDXER6hr
Documentation is [here](https://revoltpy.readthedocs.io/en/latest/)
You can join the support server [here](https://rvlt.gg/FDXER6hr) and find the library's documentation [here](https://revoltpy.readthedocs.io/en/latest/).
## Example
More examples in the [examples folder](https://github.com/zomatree/revolt.py/blob/master/examples)
More examples can be found in the [examples folder](https://github.com/revoltchat/revolt.py/blob/master/examples).
```py
import revolt
+32
View File
@@ -78,6 +78,9 @@ Message
.. autoclass:: Message
:members:
.. autoclass:: MessageReply
:members:
Messageable
~~~~~~~~~~~~
@@ -105,6 +108,20 @@ Server
.. autoclass:: Server
:members:
Category
~~~~~~~~~
.. autoclass:: Category
:members:
SystemMessages
~~~~~~~~~~~~~~~
.. autoclass:: SystemMessages
:members:
User
~~~~~
@@ -122,6 +139,12 @@ Status
.. autonamedtuple:: Status
UserBadges
~~~~~~~~~~~
.. autoclass:: UserBadges
:members:
Enums
======
@@ -197,3 +220,12 @@ All enums subclass `aenum.Enum`.
.. attribute:: user
That user is yourself
Utils
======
.. currentmodule:: revolt.utils
A collection a utility functions and classes to aid in making your bot
.. autofunction:: get
+85 -4
View File
@@ -4,9 +4,9 @@ API Reference
===============
CommandsMixin
~~~~~~~~~~~~~
.. autoclass:: revolt.ext.commands.CommandsMixin
CommandsClient
~~~~~~~~~~~~~~~
.. autoclass:: revolt.ext.commands.CommandsClient
:members:
Context
@@ -19,11 +19,92 @@ Command
.. autoclass:: revolt.ext.commands.Command
:members:
command
~~~~~~~~
.. autodecorator:: revolt.ext.commands.command
check
~~~~~~
.. autodecorator:: revolt.ext.commands.check
is_bot_owner
~~~~~~~~~~~~~
.. autodecorator:: revolt.ext.commands.is_bot_owner
is_server_owner
~~~~~~~~~~~~~~~~
.. autodecorator:: revolt.ext.commands.is_server_owner
Exceptions
===========
CommandError
~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.CommandError
:members:
CommandNotFound
~~~~~~~~~~~~~~~~
.. autoclass:: revolt.ext.commands.CommandNotFound
.. autoexception:: revolt.ext.commands.CommandNotFound
:members:
NoClosingQuote
~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.NoClosingQuote
:members:
CheckError
~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.CheckError
:members:
NotBotOwner
~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.NotBotOwner
:members:
NotServerOwner
~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.NotServerOwner
:members:
ServerOnly
~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.ServerOnly
:members:
ConverterError
~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.ConverterError
:members:
InvalidLiteralArgument
~~~~~~~~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.InvalidLiteralArgument
:members:
BadBoolArgument
~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.BadBoolArgument
:members:
CategoryConverterError
~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.CategoryConverterError
:members:
ChannelConverterError
~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.ChannelConverterError
:members:
UserConverterError
~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.UserConverterError
:members:
MemberConverterError
~~~~~~~~~~~~~~~~
.. autoexception:: revolt.ext.commands.MemberConverterError
:members:
+2 -2
View File
@@ -1,6 +1,6 @@
Sphinx==3.5.4
Sphinx==4.3.2
sphinx-nameko-theme==0.0.3
aiohttp==3.7.4.post0
ulid-py==1.1.0
sphinx-toolbox==2.13.0
sphinx-toolbox==2.15.2
aenum==3.1.0
+2 -2
View File
@@ -6,9 +6,9 @@ import revolt
from revolt.ext import commands
class Client(commands.CommandsMixin, revolt.Client):
class Client(commands.CommandsClient):
async def get_prefix(self, message: revolt.Message):
return ">>>"
return "!"
@commands.command()
async def ping(self, ctx: commands.Context):
+6 -3
View File
@@ -1,4 +1,5 @@
from .asset import Asset
from .category import Category
from .channel import (Channel, DMChannel, GroupDMChannel, SavedMessageChannel,
TextChannel, VoiceChannel)
from .client import Client
@@ -7,12 +8,14 @@ from .enums import (AssetType, ChannelType, PresenceType, RelationshipType,
SortType)
from .errors import HTTPError, RevoltError, ServerError
from .file import File
from .flags import UserBadges
from .member import Member
from .message import Message
from .message import Masquerade, Message, MessageReply
from .messageable import Messageable
from .permissions import ChannelPermissions, ServerPermissions
from .role import Role
from .server import Server
from .server import Server, SystemMessages
from .user import Relation, Status, User
from . import utils
__version__ = (0, 1, 0)
__version__ = (0, 1, 3)
+39 -24
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import mimetypes
from typing import TYPE_CHECKING
from .enums import AssetType
@@ -35,8 +36,10 @@ class Asset:
The content type of the file
type: :class:`AssetType`
The type of asset it is
url: :class:`str`
The assets url
"""
__slots__ = ("state", "id", "tag", "size", "filename", "content_type", "width", "height", "type")
__slots__ = ("state", "id", "tag", "size", "filename", "content_type", "width", "height", "type", "url")
def __init__(self, data: FilePayload, state: State):
self.state = state
@@ -58,6 +61,9 @@ class Asset:
self.content_type = data["content_type"]
self.type = AssetType(metadata["type"])
base_url = self.state.api_info["features"]["autumn"]["url"]
self.url = f"{base_url}/{self.tag}/{self.id}"
async def read(self) -> bytes:
"""Reads the files content into bytes"""
return await self.state.http.request_file(self.url)
@@ -72,28 +78,37 @@ class Asset:
"""
fp.write(await self.read())
@property
def url(self) -> str:
"""Returns the url for the asset
.. note:: This can error if autumn is disabled on the instance of revolt
Returns
--------
:class:`str`
The url
class PartialAsset(Asset):
"""Partial asset for when we get limited data about the asset
Raises
-------
:class:`AutumnDisabled`
Raises if autumn is disabled
"""
enabled = self.state.api_info["features"]["autumn"]["enabled"]
if not enabled:
raise AutumnDisabled
Attributes
-----------
id: :class:`str`
The id of the asset, this will always be ``"0"``
tag: Optional[:class:`str`]
The tag of the asset, this corrasponds to where the asset is used, this will always be ``None``
size: :class:`int`
Amount of bytes in the file, this will always be ``0``
filename: :class:`str`
The name of the file, this be always be ``""``
height: Optional[:class:`int`]
The height of the file if it is an image or video, this will always be ``None``
width: Optional[:class:`int`]
The width of the file if it is an image or video, this will always be ``None``
content_type: Optional[:class:`str`]
The content type of the file, this is guessed from the url's file extension if it has one
type: :class:`AssetType`
The type of asset it is, this always be ``AssetType.file``
"""
base_url = self.state.api_info["features"]["autumn"]["url"]
return f"{base_url}/{self.tag}/{self.id}"
def __init__(self, url: str, state: State):
self.state = state
self.id = "0"
self.tag = None
self.size = 0
self.filename = ""
self.height = None
self.width = None
self.content_type = mimetypes.guess_extension(url)
self.type = AssetType.file
self.url = url
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .channel import Channel
from .state import State
from .types import Category as CategoryPayload
__all__ = ("Category",)
class Category:
"""Represents a category in a server that stores channels.
Attributes
-----------
name: :class:`str`
The name of the category
id: :class:`str`
The id of the category
channel_ids: list[:class:`str`]
The ids of channels that are inside the category
"""
def __init__(self, data: CategoryPayload, state: State):
self.state = state
self.name = data["title"]
self.id = data["id"]
self.channel_ids = data["channels"]
@property
def channels(self) -> list[Channel]:
"""Returns a list of channels that the category contains"""
return [self.state.get_channel(channel_id) for channel_id in self.channel_ids]
+13 -10
View File
@@ -42,8 +42,8 @@ class Channel:
self.server_id = ""
@property
def server(self) -> Server:
return self.state.get_server(self.server_id)
def server(self) -> Optional[Server]:
return self.state.get_server(self.server_id) if self.server_id else None
def _update(self):
pass
@@ -69,12 +69,12 @@ class GroupDMChannel(Channel, Messageable):
self.owner = state.get_user(data["owner"])
if perms := data.get("permissions"):
self.permissions = ChannelPermissions(perms)
self.permissions = ChannelPermissions._from_value(perms)
def _update(self, *, name: Optional[str] = None, recipients: Optional[list[str]] = None):
if name:
self.name = name
if recipients:
self.recipients = [self.state.get_user(user_id) for user_id in recipients]
@@ -93,19 +93,22 @@ class TextChannel(Channel, Messageable):
"""A text channel"""
def __init__(self, data: TextChannelPayload, state: State):
super().__init__(data, state)
self.server_id = data["server"]
self.name = data["name"]
self.description = data.get("description")
last_message_id = data.get("last_message")
self.last_message_id = last_message_id
if perms := data.get("default_permissions"):
self.default_permissions = ChannelPermissions(perms)
self.default_permissions = ChannelPermissions._from_value(perms)
if role_perms := data.get("role_permissions"):
self.role_permissions = {role_id: ChannelPermissions(perms) for role_id, perms in role_perms.items()}
self.role_permissions = {role_id: ChannelPermissions._from_value(perms) for role_id, perms in role_perms.items()}
def _get_channel_id(self) -> str:
return self.id
@property
def last_message(self) -> Message:
@@ -146,10 +149,10 @@ class VoiceChannel(Channel):
self.description = data.get("description")
if perms := data.get("default_permissions"):
self.default_permissions = ChannelPermissions(perms)
self.default_permissions = ChannelPermissions._from_value(perms)
if role_perms := data.get("role_permissions"):
self.role_permissions = {role_id: ChannelPermissions(perms) for role_id, perms in role_perms.items()}
self.role_permissions = {role_id: ChannelPermissions._from_value(perms) for role_id, perms in role_perms.items()}
def _update(self, *, name: Optional[str] = None, description: Optional[str] = None):
if name:
+20 -10
View File
@@ -40,7 +40,6 @@ class Client:
max_messages: :class:`int`
The max amount of messages stored in the cache, by default this is 5k
"""
__slots__ = ("session", "token", "api_url", "max_messages", "api_info", "http", "state", "websocket", "listeners")
def __init__(self, session: aiohttp.ClientSession, token: str, api_url: str = "https://api.revolt.chat", max_messages: int = 5000):
self.session = session
@@ -92,7 +91,7 @@ class Client:
self.websocket = WebsocketHandler(self.session, self.token, api_info["ws"], self.dispatch, self.state)
await self.websocket.start()
def get_user(self, id: str) -> Optional[User]:
def get_user(self, id: str) -> User:
"""Gets a user from the cache
Parameters
@@ -102,12 +101,12 @@ class Client:
Returns
--------
Optional[:class:`User`]
The user if found
:class:`User`
The user
"""
return self.state.get_user(id)
def get_channel(self, id: str) -> Optional[Channel]:
def get_channel(self, id: str) -> Channel:
"""Gets a channel from the cache
Parameters
@@ -117,12 +116,12 @@ class Client:
Returns
--------
Optional[:class:`Channel`]
The channel if found
:class:`Channel`
The channel
"""
return self.state.get_channel(id)
def get_server(self, id: str) -> Optional[Server]:
def get_server(self, id: str) -> Server:
"""Gets a server from the cache
Parameters
@@ -132,8 +131,8 @@ class Client:
Returns
--------
Optional[:class:`Server`]
The server if found
:class:`Server`
The server
"""
return self.state.get_server(id)
@@ -166,3 +165,14 @@ class Client:
self.listeners.setdefault(event, []).append((check, future))
return await asyncio.wait_for(future, timeout)
@property
def user(self) -> User:
user = self.websocket.user
assert user
return user
@property
def users(self) -> list[User]:
return list(self.state.users.values())
+3 -1
View File
@@ -1,4 +1,6 @@
from .checks import *
from .client import *
from .command import *
from .context import *
from .errors import *
from .mixin import *
from .converters import IntConverter, BoolConverter, CategoryConverter, UserConverter, MemberConverter, ChannelConverter
+61
View File
@@ -0,0 +1,61 @@
from typing import Any, Callable, Coroutine, TypeVar, Union
from .command import Command
from .context import Context
from .errors import NotBotOwner, NotServerOwner, ServerOnly
__all__ = ("check", "Check", "is_bot_owner", "is_server_owner")
Check = Callable[[Context], Union[Any, Coroutine[Any, Any, Any]]]
T = TypeVar("T", Callable[..., Any], Command)
def check(check: Check):
"""A decorator for adding command checks
Parameters
-----------
check: Callable[[Context], Union[Any, Coroutine[Any, Any, Any]]]
The function to be called, must take one parameter, context and optionally be a coroutine
Returns
--------
Any
The value denoating whether the check should pass or fail
"""
def inner(func: T) -> T:
if isinstance(func, Command):
func.checks.append(check)
else:
checks = getattr(func, "_checks", [])
checks.append(check)
func._checks = checks
return func
return inner
def is_bot_owner():
"""A command check for limiting the command to only the bot's owner"""
@check
def inner(context: Context):
if context.author.id == context.client.user.owner_id:
return True
raise NotBotOwner
return inner
def is_server_owner():
"""A command check for limiting the command to only a server's owner"""
@check
def inner(context: Context):
if not context.server:
raise ServerOnly
if context.author.id == context.server.owner_id:
return True
raise NotServerOwner
return inner
@@ -1,46 +1,48 @@
from __future__ import annotations
import re
import traceback
from typing import Any, Callable, Union
import revolt
from .command import Command
from .context import Context
from .errors import CommandNotFound
from .errors import CheckError, CommandNotFound
from .view import StringView
__all__ = (
"CommandsMeta",
"CommandsMixin"
"CommandsClient"
)
quote_regex = re.compile(r"[\"']")
chunk_regex = re.compile(r"\S+")
class CommandsMeta(type):
_commands: list[Command]
def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any]):
commands: list[Command] = []
for value in attrs.values():
if isinstance(value, Command):
commands.append(value)
self = super().__new__(cls, name, bases, attrs)
self._commands = commands
for command in commands:
command._client = self # type: ignore
for base in reversed(self.__mro__):
for value in base.__dict__.values():
if isinstance(value, Command):
commands.append(value)
self._commands = commands
return self
class CommandsMixin(metaclass=CommandsMeta):
class CommandsClient(revolt.Client, metaclass=CommandsMeta):
"""Main class that adds commands, this class should be subclassed along with `revolt.Client`."""
__slots__ = ("_commands", "all_commands")
_commands: list[Command]
dispatch: Callable[..., None]
def __init__(self, *args, **kwargs):
self.all_commands = {}
self.all_commands: dict[str, Command] = {}
for command in self._commands:
self.all_commands[command.name] = command
@@ -96,42 +98,8 @@ class CommandsMixin(metaclass=CommandsMeta):
"""
self.all_commands[name] = command
def split_content(self, content: str) -> list[str]:
"""Splits a string into seperate parameters, overwrite this function to change how arguments are split.
Parameters
-----------
content: :class:`str`
The content of the message
Returns
--------
list[:class:`str`]
The arguments from the content
"""
args = []
temp: list[str] = []
quoted = False
quote_char = ""
for char in content:
if char == " " and not quoted:
args.append("".join(temp))
temp = []
elif char in ["\"", "'"] and not quoted:
quoted = True
quote_char = char
elif char == quote_char and quoted:
quoted = False
args.append("".join(temp))
else:
temp.append(char)
return args
def get_view(self, message: revolt.Message) -> type[StringView]:
return StringView
def get_context(self, message: revolt.Message) -> type[Context]:
return Context
@@ -169,18 +137,56 @@ class CommandsMixin(metaclass=CommandsMeta):
if not content:
return
command_name, *args = self.split_content(content)
view = self.get_view(message)(content)
try:
command_name = view.get_next_word()
except StopIteration:
return
context_cls = self.get_context(message)
try:
command = self.get_command(command_name)
except KeyError:
return self.dispatch("command_error", Context(None, command_name, message), CommandNotFound(command_name))
context = context_cls(None, command_name, view, message, self)
return self.dispatch("command_error", context, CommandNotFound(command_name))
context = self.get_context(message)(command, command_name, message)
context = context_cls(command, command_name, view, message, self)
try:
return await context.invoke(args)
self.dispatch("command", context)
if not await self.bot_check(context):
raise CheckError(f"the global check for the command failed")
if not await context.can_run():
raise CheckError(f"the check(s) for the command failed")
output = await context.invoke()
self.dispatch("after_command_invoke", context, output)
return output
except Exception as e:
self.dispatch("command_error", context, e)
@staticmethod
async def on_command_error(ctx: Context, error: Exception):
traceback.print_exception(type(error), error, error.__traceback__)
on_message = process_commands
async def bot_check(self, context: Context) -> bool:
"""A global check for the bot that stops commands from running on certain criteria.
Parameters
-----------
context: :class:`Context`
The context for the invokation of the command
Returns
--------
:class:`bool` represents if the command should run or not
"""
return True
+84 -18
View File
@@ -1,23 +1,31 @@
from __future__ import annotations
import inspect
import traceback
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional
from contextlib import suppress
from typing import (TYPE_CHECKING, Annotated, Any, Callable, Coroutine,
Literal, Optional, Union, get_args, get_origin, cast)
import revolt
from revolt.utils import copy_doc
from revolt.utils import copy_doc, maybe_coroutine
from .errors import InvalidLiteralArgument, UnionConverterError
if TYPE_CHECKING:
from .checks import Check
from .context import Context
__all__ = (
"Command",
"command"
)
NoneType = type(None)
class Command:
"""Class for holding info about a command.
Parameters
-----------
callback: Callable[..., Coroutine[Any, Any, Any]]
@@ -27,16 +35,16 @@ class Command:
aliases: list[:class:`str`]
The aliases of the command
"""
__slots__ = ("callback", "name", "aliases", "_client")
_client: revolt.Client
__slots__ = ("callback", "name", "aliases", "signature", "checks")
def __init__(self, callback: Callable[..., Coroutine[Any, Any, Any]], name: str, aliases: list[str]):
self.callback = callback
self.name = name
self.aliases = aliases
self.signature = inspect.signature(self.callback)
self.checks: list[Check] = getattr(callback, "_checks", [])
async def invoke(self, context: Context, args: list[Any]) -> Any:
async def invoke(self, context: Context, *args, **kwargs) -> Any:
"""Runs the command and calls the error handler if the command errors.
Parameters
@@ -47,17 +55,17 @@ class Command:
The arguments for the command
"""
try:
return await self.callback(self._client, context, *args)
return await self.callback(context.client, context, *args, **kwargs)
except Exception as err:
return await self._error_handler(context, err)
@copy_doc(invoke)
def __call__(self, context: Context, args: list[Any]) -> Any:
return self.invoke(context, args)
def __call__(self, context: Context, *args, **kwargs) -> Any:
return self.invoke(context, *args, **kwargs)
def error(self, func: Callable[..., Coroutine[Any, Any, Any]]):
"""Sets the error handler for the command.
Parameters
-----------
func: Callable[..., Coroutine[Any, Any, Any]]
@@ -79,22 +87,80 @@ class Command:
async def _error_handler(ctx: Context, error: Exception):
traceback.print_exception(type(error), error, error.__traceback__)
@staticmethod
def extract_type(t):
if origin := get_origin(t):
if origin is Annotated:
return get_args(t)[1]
return t
@classmethod
async def convert_argument(cls, arg: str, annotation: Any, context: Context):
if annotation:
if annotation is str: # no converting is needed - its already a string
return arg
if origin := get_origin(annotation):
if origin is Union:
for converter in get_args(annotation):
try:
return await cls.convert_argument(arg, converter, context)
except:
if converter is NoneType:
context.view.undo()
return None
raise UnionConverterError(arg)
elif origin is Annotated:
converter: Callable[[str, Context], Any] = get_args(annotation)[1] # the typehint affects the other if statement somehow
return await maybe_coroutine(converter, arg, context)
elif origin is Literal:
if arg in get_args(annotation):
return arg
else:
raise InvalidLiteralArgument(arg)
else:
return await maybe_coroutine(cast(Callable, annotation), arg, context)
else:
return arg
async def parse_arguments(self, context: Context):
for name, parameter in list(self.signature.parameters.items())[2:]:
if parameter.kind == parameter.KEYWORD_ONLY:
context.kwargs[name] = await self.convert_argument(context.view.get_rest(), parameter.annotation, context)
elif parameter.kind == parameter.VAR_POSITIONAL:
with suppress(StopIteration):
while True:
context.args.append(await self.convert_argument(context.view.get_next_word(), parameter.annotation, context))
elif parameter.kind == parameter.POSITIONAL_OR_KEYWORD:
context.args.append(await self.convert_argument(context.view.get_next_word(), parameter.annotation, context))
def __repr__(self) -> str:
return f"<Command name=\"{self.name}\">"
def command(*, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: type[Command] = Command):
"""Turns a function into a :class:`Command`.
"""A decorator that turns a function into a :class:`Command`.
Parameters
-----------
name: Optional[:class:`str`]
The name of the command, this defaults to the functions name
aliases: Optional[list[:class:`str`]]
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
Returns
--------
:class:`Command`
The command
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 [])
return inner
+45 -14
View File
@@ -1,20 +1,24 @@
from __future__ import annotations
from typing import Any, Optional
from typing import TYPE_CHECKING, Any, Optional
import revolt
from revolt.utils import copy_doc
from revolt.utils import maybe_coroutine
from .command import Command
if TYPE_CHECKING:
from .client import CommandsClient
from .view import StringView
__all__ = (
"Context",
)
class Context:
class Context(revolt.Messageable):
"""Stores metadata the commands execution.
Parameters
Attributes
-----------
command: Optional[:class:`Command`]
The command, this can be `None` when no command was found and the error handler is being executed
@@ -22,16 +26,39 @@ class Context:
The command name that was used, this can be an alias, the commands name or a command that doesnt exist
message: :class:`Message`
The message that was sent to invoke the command
channel: :class:`Messageable`
The channel the command was invoked in
server: :class:`Server`
The server the command was invoked in
author: Union[:class:`Member`, :class:`User`]
The user or member that invoked the commad, will be :class:`User` in DMs
args: list[:class:`str`]
The positional arguments being passed to the command
kwargs: dict[:class:`str`, Any]
The keyword arguments being passed to the command
client: :class:`CommandsClient`
The revolt client
"""
__slots__ = ("command", "invoked_with", "message")
__slots__ = ("command", "invoked_with", "args", "message", "server", "channel", "author", "view", "kwargs", "state", "client")
def __init__(self, command: Optional[Command], invoked_with: str, message: revolt.Message):
def _get_channel_id(self) -> str:
return self.channel.id
def __init__(self, command: Optional[Command], invoked_with: str, view: StringView, message: revolt.Message, client: CommandsClient):
self.command = command
self.invoked_with = invoked_with
self.view = view
self.message = message
self.client = client
self.args = []
self.kwargs = {}
self.server = message.server
self.channel = message.channel
self.author = message.author
self.state = message.state
async def invoke(self, args: list[str]) -> Any:
"""Invokes the command, this is equal to `await context.command.invoke(context, args)`.
async def invoke(self) -> Any:
"""Invokes the command, this is equal to `await command.invoke(context, context.args)`.
.. note:: If the command is `None`, this function will do nothing.
@@ -40,9 +67,13 @@ class Context:
args: list[:class:`str`]
The args being passed to the command
"""
if command := self.command:
return await command.invoke(self, args)
@copy_doc(revolt.Messageable.send)
async def send(self, content: Optional[str] = None, *, embeds: Optional[list[revolt.Embed]] = None, embed: Optional[revolt.Embed] = None, attachments: Optional[list[revolt.File]] = None) -> revolt.Message:
return await self.message.channel.send(content, embeds=embeds, embed=embed, attachments=attachments)
if command := self.command:
await command.parse_arguments(self)
return await command.invoke(self, *self.args, **self.kwargs)
async def can_run(self) -> bool:
"""Runs all of the commands checks, and returns true if all of them pass"""
return all([await maybe_coroutine(check, self) for check in (self.command.checks if self.command else [])])
+82
View File
@@ -0,0 +1,82 @@
from typing import Annotated
import re
from .errors import BadBoolArgument, ServerOnly, CategoryConverterError, ChannelConverterError, UserConverterError, MemberConverterError
from .context import Context
from revolt import Category, Channel, utils, User, Member
__all__ = ("bool_converter", "category_converter", "channel_converter", "user_converter", "member_converter", "IntConverter", "BoolConverter", "CategoryConverter", "UserConverter", "MemberConverter", "ChannelConverter")
channel_regex = re.compile("<#([A-z0-9]{26})>")
user_regex = re.compile("<@([A-z0-9]{26})>")
def bool_converter(arg: str, _):
lowered = arg.lower()
if lowered in ["yes", "true", "ye", "y", "1", "on", "enable"]:
return True
elif lowered in ('no', 'n', 'false', 'f', '0', 'disable', 'off'):
return False
else:
raise BadBoolArgument(lowered)
def category_converter(arg: str, context: Context) -> Category:
if not (server := context.server):
raise ServerOnly
try:
return server.get_category(arg)
except KeyError:
try:
return utils.get(server.categories, name=arg)
except LookupError:
raise CategoryConverterError(arg)
def channel_converter(arg: str, context: Context) -> Channel:
if not (server := context.server):
raise ServerOnly
if (match := channel_regex.match(arg)):
arg = match.group(1)
try:
return server.get_channel(arg)
except KeyError:
try:
return utils.get(server.channels, name=arg)
except LookupError:
raise ChannelConverterError(arg)
def user_converter(arg: str, context: Context) -> User:
if (match := user_regex.match(arg)):
arg = match.group(1)
try:
return context.client.get_user(arg)
except KeyError:
try:
return utils.get(context.client.users, name=arg)
except LookupError:
raise UserConverterError(arg)
def member_converter(arg: str, context: Context) -> Member:
if not (server := context.server):
raise ServerOnly
if (match := user_regex.match(arg)):
arg = match.group(1)
try:
return server.get_member(arg)
except KeyError:
try:
return utils.get(server.members, name=arg)
except LookupError:
raise MemberConverterError(arg)
IntConverter = Annotated[int, lambda arg, _: int(arg)]
BoolConverter = Annotated[bool, bool_converter]
CategoryConverter = Annotated[Category, category_converter]
UserConverter = Annotated[User, user_converter]
MemberConverter = Annotated[Member, member_converter]
ChannelConverter = Annotated[Channel, channel_converter]
+69 -2
View File
@@ -1,10 +1,28 @@
from revolt import RevoltError
__all__ = (
"CommandError",
"CommandNotFound",
"NoClosingQuote",
"CheckError",
"NotBotOwner",
"NotServerOwner",
"ServerOnly",
"ConverterError",
"InvalidLiteralArgument",
"BadBoolArgument",
"CategoryConverterError",
"ChannelConverterError",
"UserConverterError",
"MemberConverterError",
)
class CommandNotFound(Exception):
class CommandError(RevoltError):
"""base error for all command's related errors"""
class CommandNotFound(CommandError):
"""Raised when a command isnt found.
Parameters
-----------
command_name: :class:`str`
@@ -14,3 +32,52 @@ class CommandNotFound(Exception):
def __init__(self, command_name: str):
self.command_name = command_name
class NoClosingQuote(CommandError):
"""Raised when there is no closing quote for a command argument"""
class CheckError(CommandError):
"""Raised when a check fails for a command"""
class NotBotOwner(CheckError):
"""Raised when the `is_bot_owner` check fails"""
class NotServerOwner(CheckError):
"""Raised when the `is_server_owner` check fails"""
class ServerOnly(CheckError):
"""Raised when a check requires the command to be ran in a server"""
class ConverterError(CommandError):
"""Base class for all converter errors"""
class InvalidLiteralArgument(ConverterError):
"""Raised when the argument is not a valid literal argument"""
class BadBoolArgument(ConverterError):
"""Raised when the bool converter fails"""
class CategoryConverterError(ConverterError):
"""Raised when the Category conveter fails"""
def __init__(self, argument: str):
self.argument = argument
class ChannelConverterError(ConverterError):
"""Raised when the Channel conveter fails"""
def __init__(self, argument: str):
self.argument = argument
class UserConverterError(ConverterError):
"""Raised when the Category conveter fails"""
def __init__(self, argument: str):
self.argument = argument
class MemberConverterError(ConverterError):
"""Raised when the Category conveter fails"""
def __init__(self, argument: str):
self.argument = argument
class UnionConverterError(ConverterError):
"""Raised when all converters in a union fails"""
def __init__(self, argument: str):
self.argument = argument
+52
View File
@@ -0,0 +1,52 @@
from .errors import NoClosingQuote
class StringView:
def __init__(self, string: str):
self.value = iter(string)
self.temp = ""
self.should_undo = False
def undo(self):
self.should_undo = True
def next_char(self) -> str:
return next(self.value)
def get_rest(self) -> str:
if self.should_undo:
return f"{self.temp} {''.join(self.value)}"
return "".join(self.value)
def get_next_word(self) -> str:
if self.should_undo:
self.should_undo = False
return self.temp
char = self.next_char()
temp = []
while char == " ":
char = self.next_char()
if char in ["\"", "'"]:
quote = char
try:
while (char := self.next_char()) != quote:
temp.append(char)
except StopIteration:
raise NoClosingQuote
else:
temp.append(char)
try:
while (char := self.next_char()) not in " \n":
temp.append(char)
except StopIteration:
pass
output = "".join(temp)
self.temp = output
return output
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
from typing import Callable, Iterator, Optional, TypeVar, Union, overload
__all__ = ("flag_value", "Flags", "UserBadges")
F_T = TypeVar("F_T", bound="Flags")
F_V = TypeVar("F_V", bound="flag_value")
class flag_value:
__slots__ = ("flag", "__doc__")
def __init__(self, func: Callable[[], int]):
self.flag = func()
self.__doc__ = func.__doc__
@overload
def __get__(self: F_V, instance: None, owner: type[F_T]) -> F_V:
...
@overload
def __get__(self, instance: F_T, owner: type[F_T]) -> bool:
...
def __get__(self: F_V, instance: Optional[F_T], owner: type[F_T]) -> Union[F_V, bool]:
if instance is None:
return self
return instance._check_flag(self.flag)
def __set__(self, instance: Flags, value: bool):
instance._set_flag(self.flag, value)
class Flags:
def __init__(self, **kwargs: bool):
self.value = 0
for k, v in kwargs.items():
setattr(self, k, v)
@classmethod
def _from_value(cls: type[F_T], value: int) -> F_T:
self = cls.__new__(cls)
self.value = value
return self
def _check_flag(self, flag: int) -> bool:
return (self.value & flag) == flag
def _set_flag(self, flag: int, value: bool):
if value:
self.value |= flag
else:
self.value &= ~flag
def __eq__(self: F_T, other: F_T) -> bool:
return self.value == other.value
def __ne__(self: F_T, other: F_T) -> bool:
return not self.__eq__(other)
def __or__(self: F_T, other: F_T) -> F_T:
return self.__class__._from_value(self.value | other.value)
def __and__(self: F_T, other: F_T) -> F_T:
return self.__class__._from_value(self.value & other.value)
def __invert__(self: F_T) -> F_T:
return self.__class__._from_value(~self.value)
def __add__(self: F_T, other: F_T) -> F_T:
return self | other
def __sub__(self: F_T, other: F_T) -> F_T:
return self & ~other
def __lt__(self: F_T, other: F_T) -> bool:
return self.value < other.value
def __gt__(self: F_T, other: F_T) -> bool:
return self.value > other.value
def __repr__(self):
return f"<{self.__class__.__name__} value={self.value}>"
def __iter__(self) -> Iterator[tuple[str, bool]]:
for name, value in self.__class__.__dict__.items():
if isinstance(value, flag_value):
yield name, value.__get__(self, self.__class__)
def __hash__(self) -> int:
return hash(self.value)
class UserBadges(Flags):
"""Contains all user badges"""
@flag_value
def developer():
""":class:`bool` The developer badge."""
return 1 << 0
@flag_value
def translator():
""":class:`bool` The translator badge."""
return 1 << 1
@flag_value
def supporter():
""":class:`bool` The supporter badge."""
return 1 << 2
@flag_value
def responsible_disclosure():
""":class:`bool` The responsible disclosure badge."""
return 1 << 3
@flag_value
def founder():
""":class:`bool` The founder badge."""
return 1 << 4
@flag_value
def platform_moderation():
""":class:`bool` The platform moderation badge."""
return 1 << 5
@flag_value
def active_supporter():
""":class:`bool` The active supporter badge."""
return 1 << 6
@flag_value
def paw():
""":class:`bool` The paw badge."""
return 1 << 7
@flag_value
def early_adopter():
""":class:`bool` The early adopter badge."""
return 1 << 8
@flag_value
def reserved_relevant_joke_badge_1():
""":class:`bool` The reserved relevant joke badge 1 badge."""
return 1 << 9
+19 -11
View File
@@ -23,10 +23,12 @@ if TYPE_CHECKING:
from .types import Autumn as AutumnPayload
from .types import Channel, DMChannel
from .types import Embed as EmbedPayload
from .types import GetServerMembers, Member
from .types import GetServerMembers
from .types import Masquerade as MasqueradePayload
from .types import Member
from .types import Message as MessagePayload
from .types import (MessageWithUserData, Role, Server, ServerBans,
ServerInvite, TextChannel)
from .types import (MessageReplyPayload, MessageWithUserData, Role, Server,
ServerBans, ServerInvite, TextChannel)
from .types import User as UserPayload
from .types import UserProfile, VoiceChannel
@@ -51,7 +53,7 @@ class HttpClient:
kwargs = {}
headers = {
"User-Agent": "Revolt.py https://github.com/Zomatree/revolt.py",
"User-Agent": "Revolt.py (https://github.com/revoltchat/revolt.py)",
"x-bot-token": self.token
}
@@ -79,14 +81,14 @@ class HttpClient:
if 200 <= resp_code <= 300:
return response
elif resp_code == 400:
raise HTTPError
else:
raise HTTPError(resp_code)
async def upload_file(self, file: File, tag: str) -> AutumnPayload:
url = f"{self.api_info['features']['autumn']['url']}/{tag}"
headers = {
"User-Agent": "Revolt.py https://github.com/Zomatree/revolt.py"
"User-Agent": "Revolt.py (https://github.com/revoltchat/revolt.py)"
}
form = aiohttp.FormData()
@@ -104,12 +106,12 @@ class HttpClient:
else:
return response
async def send_message(self, channel: str, content: Optional[str], embeds: Optional[list[EmbedPayload]], attachments: Optional[list[File]]) -> MessagePayload:
async def send_message(self, channel: str, content: Optional[str], embeds: Optional[list[EmbedPayload]], attachments: Optional[list[File]], replies: Optional[list[MessageReplyPayload]], masquerade: Optional[MasqueradePayload]) -> MessagePayload:
json: dict[str, Any] = {}
if content:
json["content"] = content
if embeds:
json["embeds"] = embeds
@@ -119,9 +121,15 @@ class HttpClient:
for attachment in attachments:
data = await self.upload_file(attachment, "attachments")
attachment_ids.append(data["id"])
json["attachments"] = attachment_ids
if replies:
json["replies"] = replies
if masquerade:
json["masquerade"] = masquerade
return await self.request("POST", f"/channels/{channel}/messages", json=json)
def edit_message(self, channel: str, message: str, content: str) -> Request[None]:
+23 -13
View File
@@ -2,14 +2,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from .asset import Asset
from .user import User
if TYPE_CHECKING:
from .server import Server
from .state import State
from .types import File
from .types import Member as MemberPayload
__all__ = ("Member",)
def flattern_user(member: Member, user: User):
@@ -27,31 +28,40 @@ class Member(User):
The roles of the member, ordered by the role's rank in decending order
server: :class:`Server`
The server the member belongs to
guild_avatar: Optional[:class:`Asset`]
The member's guild avatar if any
"""
__slots__ = ("_state", "nickname", "roles", "server")
__slots__ = ("_state", "nickname", "roles", "server", "guild_avatar")
def __init__(self, data: MemberPayload, server: Server, state: State):
user = state.get_user(data["_id"]["user"])
assert user
flattern_user(self, user)
self._state = state
self.nickname = data.get("nickname")
roles = []
for role in (server.get_role(role_id) for role_id in data.get("roles", [])):
if role:
roles.append(role)
if avatar := data.get("avatar"):
self.guild_avatar = Asset(avatar, state)
else:
self.guild_avatar = None
roles = [server.get_role(role_id) for role_id in data.get("roles", [])]
self.roles = sorted(roles, key=lambda role: role.rank, reverse=True)
self.server = server
@property
def owner(self) -> Optional[User]:
owner_id = self.owner_id
def avatar(self) -> Optional[Asset]:
"""Optional[:class:`Asset`] The avatar the member is displaying, this includes guild avatars and masqueraded avatar"""
return self.masquerade_avatar or self.guild_avatar or self.original_avatar
if not owner_id:
return
def _update(self, *, nickname: Optional[str] = None, avatar: Optional[File] = None, roles: Optional[list[str]] = None):
if nickname:
self.nickname = nickname
return self.state.get_user(owner_id)
if avatar:
self.guild_avatar = Asset(avatar, self.state)
if roles:
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)
+86 -10
View File
@@ -1,22 +1,28 @@
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, NamedTuple, Optional
from .asset import Asset
from .asset import Asset, PartialAsset
from .channel import Messageable
from .embed import Embed
if TYPE_CHECKING:
from .state import State
from .types import Masquerade as MasqueradePayload
from .types import Message as MessagePayload
from .types import MessageReplyPayload
__all__ = ("Message",)
__all__ = (
"Message",
"MessageReply",
"Masquerade"
)
class Message:
"""Represents a message
Attributes
-----------
id: :class:`str`
@@ -35,12 +41,18 @@ class Message:
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
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`]
The message's ids this message has replies to
"""
__slots__ = ("state", "id", "content", "attachments", "embeds", "channel", "server", "author", "edited_at")
__slots__ = ("state", "id", "content", "attachments", "embeds", "channel", "server", "author", "edited_at", "mentions", "replies", "reply_ids")
def __init__(self, data: MessagePayload, state: State):
self.state = state
self.id = data["_id"]
self.content = data["content"]
self.attachments = [Asset(attachment, state) for attachment in data.get("attachments", [])]
@@ -51,16 +63,40 @@ class Message:
self.channel = channel
self.server = self.channel and self.channel.server
if self.server:
author = state.get_member(self.server.id, data["author"])
else:
author = state.get_user(data["author"])
assert author
self.author = author
self.edited_at: Optional[datetime.datetime] = None
if masquerade := data.get("masquerade"):
if name := masquerade.get("name"):
self.author.masquerade_name = name
if avatar := masquerade.get("avatar"):
self.author.masquerade_avatar = PartialAsset(avatar, state)
if edited_at := data.get("edited"):
self.edited_at: Optional[datetime.datetime] = datetime.datetime.strptime(edited_at["$date"], "%Y-%m-%dT%H:%M:%S.%f%z")
if self.server:
self.mentions = [self.server.get_member(member_id) for member_id in data.get("mentions", [])]
else:
self.mentions = [state.get_user(member_id) for member_id in data.get("mentions", [])]
self.replies = []
self.reply_ids = []
for reply in data.get("replies", []):
try:
message = state.get_message(reply)
self.replies.append(message)
except KeyError:
pass
self.reply_ids.append(reply)
def _update(self, *, content: Optional[str] = None, edited_at: Optional[str] = None) -> Message:
if content:
@@ -84,3 +120,43 @@ class Message:
async def delete(self) -> None:
"""Deletes the message. The bot can only delete its own messages and messages it has permission to delete """
await self.state.http.delete_message(self.channel.id, self.id)
class MessageReply(NamedTuple):
"""A namedtuple which represents a reply to a message.
Parameters
-----------
message: :class:`Message`
The message being replied to.
mention: :class:`bool`
Whether the reply should mention the author of the message. Defaults to false.
"""
message: Message
mention: bool = False
def to_dict(self) -> MessageReplyPayload:
return { "id": self.message.id, "mention": self.mention }
class Masquerade(NamedTuple):
"""A namedtuple which represents a message's masquerade.
Parameters
-----------
name: Optional[:class:`str`]
The name to display for the message
avatar: Optional[:class:`str`]
The avatar's url to display for the message
"""
name: Optional[str] = None
avatar: Optional[str] = None
def to_dict(self) -> MasqueradePayload:
output: MasqueradePayload = {}
if name := self.name:
output["name"] = name
if avatar := self.avatar:
output["avatar"] = avatar
return output
+15 -6
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from .embed import Embed
from .file import File
from .message import Message
from .message import Masquerade, Message, MessageReply
from .state import State
@@ -13,18 +13,20 @@ __all__ = ("Messageable",)
class Messageable:
"""Base class for all channels that you can send messages in
Attributes
-----------
id: :class:`str`
The id of the channel
"""
id: str
state: State
__slots__ = ()
async def send(self, content: Optional[str] = None, *, embeds: Optional[list[Embed]] = None, embed: Optional[Embed] = None, attachments: Optional[list[File]] = None) -> Message:
def _get_channel_id(self) -> str:
raise NotImplementedError
async def send(self, content: Optional[str] = None, *, embeds: Optional[list[Embed]] = None, embed: Optional[Embed] = None, attachments: Optional[list[File]] = None, replies: Optional[list[MessageReply]] = None, reply: Optional[MessageReply] = None, masquerade: Optional[Masquerade] = None) -> Message:
"""Sends a message in a channel, you must send at least one of either `content`, `embeds` or `attachments`
Parameters
@@ -35,6 +37,8 @@ class Messageable:
The attachments of the message
embeds: Optional[list[:class:`Embed`]]
The embeds of the message
replies: Optional[list[:class:`MessageReply`]]
The list of messages to reply to.
Returns
--------
@@ -44,7 +48,12 @@ class Messageable:
if embed:
embeds = [embed]
embed_payload = [embed.to_dict() for embed in embeds] if embeds else None
if reply:
replies = [reply]
message = await self.state.http.send_message(self.id, content, embed_payload, attachments)
embed_payload = [embed.to_dict() for embed in embeds] if embeds else None
reply_payload = [reply.to_dict() for reply in replies] if replies else None
masquerade_payload = masquerade.to_dict() if masquerade else None
message = await self.state.http.send_message(self._get_channel_id(), content, embed_payload, attachments, reply_payload, masquerade_payload)
return self.state.add_message(message)
+66 -264
View File
@@ -1,15 +1,12 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .types import Permission as PermissionTuple
from .flags import Flags, flag_value
__all__ = (
"ChannelPermissions",
"ServerPermissions"
)
)
# Channel permissions
#
@@ -37,315 +34,120 @@ __all__ = (
# ChangeAvatar = 0b00000000000000000100000000000000 // 16382
# RemoveAvatars = 0b00000000000000001000000000000000 // 32768
class _Permission:
__slots__ = ("_value",)
def __init__(self, value: int):
self._value = value
def __or__(self, p: _Permission):
self._value |= p._value
return self
def __and__(self, p: _Permission):
self._value &= p._value
return self
def __invert__(self):
self._value = ~self._value
return self
def __add__(self, p: _Permission):
# For example we have a bit array `0011` representing permissions
# To allow a permission we would convert the bit for that permission from 0 to 1
# Let's say we want to allow permissions with bit array `0101`
# Our desired result is `0111` which can be achieved by simple OR (A+B)
return self | p
def __sub__(self, p: _Permission):
# For example we have a bit array `0011` representing permissions
# To deny a permission we would convert the bit for that permission from 1 to 0
# Let's say we want to deny permissions with bit array `0101`
# Our desired result is `0010` which can be achieved by Selective Clear Algorithm (A.B')
return self & ~p
def __lt__(self, p: _Permission):
return self._value < p._value
def __gt__(self, p: _Permission):
return self._value > p._value
def __eq__(self, p: _Permission):
return self._value == p._value
def __repr__(self):
return f"{self.__class__.__name__}<{self._value:032b}>"
def _check(self, bit_pos: int):
# To check if a permission is present we would need to compare the bit for that position in our bit array
# For example if we have permission `0100` and we need to check if it is present in our bit array `0101`
# We need to compare the 3rd bit from the left, so we shift it 2 places (n-1) and perform AND with 1
# If the result is 1 the bit was 1 and the permission is present
return (self._value >> bit_pos) & 1 == 1
class ChannelPermissions(_Permission):
class ChannelPermissions(Flags):
"""Represents the channel permissions for a role as seen in channel settings."""
__slots__ = ()
@property
def value(self) -> int:
return self._value
@classmethod
def none(cls) -> ChannelPermissions:
return cls(0)
return cls._from_value(0)
@classmethod
def all(cls) -> ChannelPermissions:
# channel permissions here do not have manage messages permission
return cls(0b00000000000000000000000011111011)
return cls._from_value(0b11111011)
@classmethod
def view(cls) -> ChannelPermissions:
return cls(0b00000000000000000000000000000001)
# view permission is always present and therefore is included in every permission below
return cls._from_value(0b1)
@classmethod
def send_message(cls) -> ChannelPermissions:
return cls(0b00000000000000000000000000000011)
return cls._from_value(0b11)
@classmethod
def manage_channel(cls) -> ChannelPermissions:
return cls(0b00000000000000000000000000001001)
return cls._from_value(0b1001)
@classmethod
def voice_call(cls) -> ChannelPermissions:
return cls(0b00000000000000000000000000010001)
return cls._from_value(0b10001)
@classmethod
def invite_others(cls) -> ChannelPermissions:
return cls(0b00000000000000000000000000100001)
return cls._from_value(0b100001)
@classmethod
def embed_links(cls) -> ChannelPermissions:
return cls(0b00000000000000000000000001000001)
return cls._from_value(0b1000001)
@classmethod
def upload_files(cls) -> ChannelPermissions:
return cls(0b00000000000000000000000010000001)
return cls._from_value(0b10000001)
@property
def can_view(self) -> bool:
return self._check(0)
@flag_value
def can_view() -> int:
return 1 << 0
@property
def can_send_message(self) -> bool:
return self._check(1)
@flag_value
def can_send_message() -> int:
return 1 << 1
@property
def can_manage_channel(self) -> bool:
return self._check(3)
@flag_value
def can_manage_channel() -> int:
return 1 << 3
@property
def can_voice_call(self) -> bool:
return self._check(4)
@flag_value
def can_voice_call() -> int:
return 1 << 4
@property
def can_invite_others(self) -> bool:
return self._check(5)
@flag_value
def can_invite_others() -> int:
return 1 << 5
@property
def can_embed_links(self) -> bool:
return self._check(6)
@flag_value
def can_embed_links() -> int:
return 1 << 6
@property
def can_upload_files(self) -> bool:
return self._check(7)
@flag_value
def can_upload_files() -> int:
return 1 << 7
class ServerPermissions:
class ServerPermissions(Flags):
"""Represents the server permissions for a role as seen in server settings."""
__slots__ = ("_server_perms", "_channel_perms", )
def __init__(self, server_perms: int, channel_perms: int) -> None:
self._server_perms = _Permission(server_perms)
self._channel_perms = _Permission(channel_perms)
def __add__(self, p: ServerPermissions) -> ServerPermissions:
self._server_perms += p._server_perms
self._channel_perms += p._channel_perms
return self
def __sub__(self, p: ServerPermissions) -> ServerPermissions:
self._server_perms -= p._server_perms
self._channel_perms -= p._channel_perms
return self
def __lt__(self, p: ServerPermissions) -> bool:
return self._server_perms < p._server_perms and self._channel_perms < p._channel_perms
def __gt__(self, p: ServerPermissions) -> bool:
return self._server_perms > p._server_perms and self._channel_perms > p._channel_perms
def __eq__(self, p: ServerPermissions) -> bool:
return self._server_perms == p._server_perms and self._channel_perms == p._channel_perms
def __repr__(self) -> str:
return f"{self.__class__.__name__}<Server: {self._server_perms._value:032b} Channel: {self._channel_perms._value:032b}>"
@property
def value(self) -> PermissionTuple:
return self._server_perms._value, self._channel_perms._value
@classmethod
def none(cls) -> ServerPermissions:
return cls(0, 0)
return cls._from_value(0)
@classmethod
def all(cls) -> ServerPermissions:
# channel permissions here do not have manage channel permission
return cls(0b00000000000000001111000000111111, 0b00000000000000000000000011110111)
return cls._from_value(0b1111000000111111)
# view server and view channel are always present and therefore are included in every permission below
# server permissions
@flag_value
def view_server() -> int:
return 1 << 0
@classmethod
def view_server(cls) -> ServerPermissions:
return cls(0b00000000000000000000000000000001, 1)
@flag_value
def manage_roles() -> int:
return 1 << 1
@classmethod
def manage_roles(cls) -> ServerPermissions:
return cls(0b00000000000000000000000000000011, 1)
@flag_value
def manage_channels() -> int:
return 1 << 2
@classmethod
def manage_channels(cls) -> ServerPermissions:
return cls(0b00000000000000000000000000000101, 1)
@flag_value
def manage_server() -> int:
return 1 << 3
@classmethod
def manage_server(cls) -> ServerPermissions:
return cls(0b00000000000000000000000000001001, 1)
@flag_value
def kick_members() -> int:
return 1 << 4
@classmethod
def kick_members(cls) -> ServerPermissions:
return cls(0b00000000000000000000000000010001, 1)
@flag_value
def ban_members() -> int:
return 1 << 5
@classmethod
def ban_members(cls) -> ServerPermissions:
return cls(0b00000000000000000000000000100001, 1)
@flag_value
def change_nicknames() -> int:
return 1 << 12
@classmethod
def change_nicknames(cls) -> ServerPermissions:
return cls(0b00000000000000000001000000000001, 1)
@flag_value
def manage_nicknames() -> int:
return 1 << 13
@classmethod
def manage_nicknames(cls) -> ServerPermissions:
return cls(0b00000000000000000010000000000001, 1)
@flag_value
def change_avatar() -> int:
return 1 << 14
@classmethod
def change_avatar(cls) -> ServerPermissions:
return cls(0b00000000000000000100000000000001, 1)
@classmethod
def remove_avatars(cls) -> ServerPermissions:
return cls(0b00000000000000001000000000000001, 1)
@property
def can_view_server(self) -> bool:
return self._server_perms._check(0)
@property
def can_manage_roles(self) -> bool:
return self._server_perms._check(1)
@property
def can_manage_channels(self) -> bool:
return self._server_perms._check(2)
@property
def can_manage_server(self) -> bool:
return self._server_perms._check(3)
@property
def can_kick_members(self) -> bool:
return self._server_perms._check(4)
@property
def can_ban_members(self) -> bool:
return self._server_perms._check(5)
@property
def can_change_nicknames(self) -> bool:
return self._server_perms._check(12)
@property
def can_manage_nicknames(self) -> bool:
return self._server_perms._check(13)
@property
def can_change_avatar(self) -> bool:
return self._server_perms._check(14)
@property
def can_remove_avatars(self) -> bool:
return self._server_perms._check(15)
# channel permissions
@classmethod
def view_channels(cls) -> ServerPermissions:
return cls(1, 0b00000000000000000000000000000001)
@classmethod
def send_message(cls) -> ServerPermissions:
return cls(1, 0b00000000000000000000000000000011)
@classmethod
def manage_messages(cls) -> ServerPermissions:
return cls(1, 0b00000000000000000000000000000101)
@classmethod
def voice_call(cls) -> ServerPermissions:
return cls(1, 0b00000000000000000000000000010001)
@classmethod
def invite_others(cls) -> ServerPermissions:
return cls(1, 0b00000000000000000000000000100001)
@classmethod
def embed_links(cls) -> ServerPermissions:
return cls(1, 0b00000000000000000000000001000001)
@classmethod
def upload_files(cls) -> ServerPermissions:
return cls(1, 0b00000000000000000000000010000001)
@property
def can_view_channel(self) -> bool:
return self._channel_perms._check(0)
@property
def can_send_message(self) -> bool:
return self._channel_perms._check(1)
@property
def can_manage_messages(self) -> bool:
return self._channel_perms._check(2)
@property
def can_voice_call(self) -> bool:
return self._channel_perms._check(4)
@property
def can_invite_others(self) -> bool:
return self._channel_perms._check(5)
@property
def can_embed_links(self) -> bool:
return self._channel_perms._check(6)
@property
def can_upload_files(self) -> bool:
return self._channel_perms._check(7)
@flag_value
def remove_avatars() -> int:
return 1 << 15
View File
+38 -11
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from .permissions import ServerPermissions
from revolt.types import server
from .permissions import ChannelPermissions, ServerPermissions
if TYPE_CHECKING:
from .server import Server
@@ -14,7 +16,7 @@ __all__ = ("Role",)
class Role:
"""Represents a role
Attributes
-----------
id: :class:`str`
@@ -29,11 +31,13 @@ class Role:
The position of the role in the role heirarchy
server: :class:`Server`
The server the role belongs to
permissions: :class:`ServerPermissions`
server_permissions: :class:`ServerPermissions`
The server permissions for the role
channel_permissions: :class:`ChannelPermissions`
The channel permissions for the role
"""
__slots__ = ("id", "name", "colour", "hoist", "rank", "state", "server", "permissions")
__slots__ = ("id", "name", "colour", "hoist", "rank", "state", "server", "server_permissions", "channel_permissions")
def __init__(self, data: RolePayload, role_id: str, state: State, server: Server):
self.state = state
self.id = role_id
@@ -42,17 +46,40 @@ class Role:
self.hoist = data.get("hoist", False)
self.rank = data.get("rank", 0)
self.server = server
self.permissions = ServerPermissions(*data.get("permissions"))
self.server_permissions = ServerPermissions._from_value(data["permissions"][0])
self.channel_permissions = ChannelPermissions._from_value(data["permissions"][1])
@property
def color(self):
return self.colour
async def set_permissions(self, permissions: ServerPermissions) -> None:
async def set_permissions(self, *, server_permissions: Optional[ServerPermissions] = None, channel_permissions: Optional[ChannelPermissions] = None) -> None:
"""Sets the permissions for a role in a server.
Parameters
-----------
permissions: :class:`ServerPermissions`
The new permissions for the role
server_permissions: Optional[:class:`ServerPermissions`]
The new server permissions for the role
channel_permissions: Optional[:class:`ChannelPermissions`]
The new channel permissions for the role
"""
await self.state.http.set_role_permissions(self.server.id, self.id, *permissions.value)
if not server_permissions and not channel_permissions:
return
server_value = (server_permissions or self.server_permissions).value
channel_value = (channel_permissions or self.channel_permissions).value
await self.state.http.set_role_permissions(self.server.id, self.id, server_value, channel_value)
def _update(self, *, name: Optional[str] = None, colour: Optional[str] = None, hoist: Optional[bool] = None, rank: Optional[int] = None):
if name:
self.name = name
if colour:
self.colour = colour
if hoist:
self.hoist = hoist
if rank:
self.rank = rank
+142 -24
View File
@@ -1,50 +1,147 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, cast
from typing import TYPE_CHECKING, Optional
from .channel import Channel
from .permissions import ServerPermissions
from .asset import Asset
from .category import Category
from .channel import Channel, channel_factory
from .permissions import ChannelPermissions, ServerPermissions
from .role import Role
if TYPE_CHECKING:
from .channel import TextChannel
from .member import Member
from .state import State
from .types import Category as CategoryPayload
from .types import File as FilePayload
from .types import Permission as PermissionPayload
from .types import Server as ServerPayload
from .types import SystemMessagesConfig
__all__ = ("Server",)
__all__ = ("Server", "SystemMessages")
class SystemMessages:
def __init__(self, data: SystemMessagesConfig, state: State):
self.state = state
self.user_joined_id = data.get("user_joined")
self.user_left_id = data.get("user_left")
self.user_kicked_id = data.get("user_kicked")
self.user_banned_id = data.get("user_banned")
@property
def user_joined(self) -> Optional[TextChannel]:
if not self.user_joined_id:
return
channel = self.state.get_channel(self.user_joined_id)
assert isinstance(channel, TextChannel)
return channel
@property
def user_left(self) -> Optional[TextChannel]:
if not self.user_left_id:
return
channel = self.state.get_channel(self.user_left_id)
assert isinstance(channel, TextChannel)
return channel
@property
def user_kicked(self) -> Optional[TextChannel]:
if not self.user_kicked_id:
return
channel = self.state.get_channel(self.user_kicked_id)
assert isinstance(channel, TextChannel)
return channel
@property
def user_banned(self) -> Optional[TextChannel]:
if not self.user_banned_id:
return
channel = self.state.get_channel(self.user_banned_id)
assert isinstance(channel, TextChannel)
return channel
class Server:
"""Represents a server
Attributes
-----------
id: :class:`str`
The id of the server
name: :class:`str`
The name of the server
owner: Optional[:class:`Member`]
The owner of the server
owner_id: :class:`str`
The owner's id of the server
description: Optional[:class:`str`]
The servers description
nsfw: :class:`bool`
Whether the server is nsfw or not
system_messages: :class:`SystemMessages`
The system message config for the server
icon: Optional[:class:`Asset`]
The servers icon
banner: Optional[:class:`Asset`]
The servers banner
"""
__slots__ = ("state", "id", "name", "owner_id", "default_permissions", "_members", "_roles", "_channels")
__slots__ = ("state", "id", "name", "owner_id", "default_server_permissions", "default_channel_permissions", "_members", "_roles", "_channels", "description", "icon", "banner", "nsfw", "system_messages", "_categories")
def __init__(self, data: ServerPayload, state: State):
self.state = state
self.id = data["_id"]
self.name = data["name"]
self.owner_id = data["owner"]
self.default_permissions = ServerPermissions(*data["default_permissions"])
self.default_server_permissions = ServerPermissions._from_value(data["default_permissions"][0])
self.default_channel_permissions = ChannelPermissions._from_value(data["default_permissions"][1])
self.description = data.get("description") or None
self.nsfw = data.get("nsfw", False)
self.system_messages = SystemMessages(data.get("system_messages", {}), state)
self._categories = {data["id"]: Category(data, state) for data in data.get("categories", [])}
if icon := data.get("icon"):
self.icon = Asset(icon, state)
else:
self.icon = None
if banner := data.get("banner"):
self.banner = Asset(banner, state)
else:
self.banner = None
self._members: dict[str, Member] = {}
self._roles: dict[str, Role] = {role_id: Role(role, role_id, state, self) for role_id, role in data.get("roles", {}).items()}
channels = cast(list[Channel], list(filter(bool, [state.get_channel(channel_id) for channel_id in data["channels"]])))
self._channels: dict[str, Channel] = {channel.id: channel for channel in channels}
self._channels: dict[str, Channel] = {channel_id: state.get_channel(channel_id) for channel_id in data.get("channels", [])}
def _update(self, *, owner: Optional[str] = None, name: Optional[str] = None, description: Optional[str] = None, icon: Optional[FilePayload] = None, banner: Optional[FilePayload] = None, default_permissions: Optional[PermissionPayload] = None, nsfw: Optional[bool] = None, system_messages: Optional[SystemMessagesConfig] = None, categories: Optional[list[CategoryPayload]] = None):
if owner:
self.owner_id = owner
if name:
self.name = name
if description is not None:
self.description = description or None
if icon:
self.icon = Asset(icon, self.state)
if banner:
self.banner = Asset(banner, self.state)
if default_permissions:
self.default_server_permissions = ServerPermissions._from_value(default_permissions[0])
self.default_channel_permissions = ChannelPermissions._from_value(default_permissions[1])
if nsfw is not None:
self.nsfw = nsfw
if system_messages is not None:
self.system_messages = SystemMessages(system_messages, self.state)
if categories is not None:
self._categories = {data["id"]: Category(data, self.state) for data in categories}
@property
def roles(self) -> list[Role]:
"""list[:class:`Role`] Gets all roles in the server in decending order"""
return list(self._roles.values())
@property
def members(self) -> list[Member]:
"""list[:class:`Member`] Gets all members in the server"""
@@ -55,6 +152,11 @@ class Server:
"""list[:class:`Member`] Gets all channels in the server"""
return list(self._channels.values())
@property
def categories(self) -> list[Category]:
"""list[:class:`Category`] Gets all categories in the server"""
return list(self._categories.values())
def get_role(self, role_id: str) -> Role:
"""Gets a role from the cache
@@ -100,20 +202,36 @@ class Server:
"""
return self._channels[channel_id]
def get_category(self, category_id: str) -> Category:
"""Gets a category from the cache
Parameters
-----------
id: :class:`str`
The id of the category
Returns
--------
:class:`Category`
The category
"""
return self._categories[category_id]
@property
def owner(self) -> Optional[Member]:
owner_id = self.owner_id
def owner(self) -> Member:
""":class:`Member` The owner of the server"""
return self.get_member(self.owner_id)
if not owner_id:
return
return self.get_member(owner_id)
async def set_default_permissions(self, permissions: ServerPermissions) -> None:
async def set_default_permissions(self, *, server_permissions: Optional[ServerPermissions] = None, channel_permissions: Optional[ChannelPermissions] = None) -> None:
"""Sets the default server permissions.
Parameters
-----------
permissions: :class:`ServerPermissions`
server_permissions: Optional[:class:`ServerPermissions`]
The new default server permissions
channel_permissions: Optional[:class:`ChannelPermissions`]
the new default channel permissions
"""
await self.state.http.set_default_permissions(self.id, *permissions.value)
server_value = (server_permissions or self.default_server_permissions).value
channel_value = (channel_permissions or self.default_channel_permissions).value
await self.state.http.set_default_permissions(self.id, server_value, channel_value)
+1
View File
@@ -1,3 +1,4 @@
from .category import *
from .channel import *
from .embed import *
from .file import *
+1 -1
View File
@@ -9,4 +9,4 @@ __all__ = ("Category",)
class Category(TypedDict):
id: str
title: str
channels: list[Channel]
channels: list[str]
+53 -6
View File
@@ -1,16 +1,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict, Union
from typing import TYPE_CHECKING, Literal, TypedDict, Union
from .channel import (Channel, DMChannel, Group, SavedMessages, TextChannel,
VoiceChannel)
from .message import Message
if TYPE_CHECKING:
from .member import Member
from .member import Member, MemberID
from .server import Server
from .user import User
from .user import Status, User
__all__ = (
"BasePayload",
@@ -25,7 +24,16 @@ __all__ = (
"ChannelUpdateEventPayload",
"ChannelDeleteEventPayload",
"ChannelStartTypingEventPayload",
"ChannelDeleteTypingEventPayload"
"ChannelDeleteTypingEventPayload",
"ServerUpdateEventPayload",
"ServerDeleteEventPayload",
"ServerMemberUpdateEventPayload",
"ServerMemberJoinEventPayload",
"ServerMemberLeaveEventPayload",
"ServerRoleUpdateEventPayload",
"ServerRoleDeleteEventPayload",
"UserUpdateEventPayload",
"UserRelationshipEventPayload"
)
class BasePayload(TypedDict):
@@ -78,7 +86,7 @@ ChannelCreateEventPayload = Union[ChannelCreateEventPayload_Group, ChannelCreate
class ChannelUpdateEventPayload(BasePayload):
id: str
data: ...
clear: ...
clear: Literal["Icon", "Description"]
class ChannelDeleteEventPayload(BasePayload):
id: str
@@ -88,3 +96,42 @@ class ChannelStartTypingEventPayload(BasePayload):
user: str
ChannelDeleteTypingEventPayload = ChannelStartTypingEventPayload
class ServerUpdateEventPayload(BasePayload):
id: str
data: dict
clear: Literal["Icon", "Banner", "Description"]
class ServerDeleteEventPayload(BasePayload):
id: str
class ServerMemberUpdateEventPayload(BasePayload):
id: MemberID
data: dict
clear: Literal["Nickname", "Avatar"]
class ServerMemberJoinEventPayload(BasePayload):
id: str
user: str
ServerMemberLeaveEventPayload = ServerMemberJoinEventPayload
class ServerRoleUpdateEventPayload(BasePayload):
id: str
role_id: str
data: dict
clear: Literal["Color"]
class ServerRoleDeleteEventPayload(BasePayload):
id: str
role_id: str
class UserUpdateEventPayload(BasePayload):
id: str
data: dict
clear: Literal["ProfileContent", "ProfileBackground", "StatusText", "Avatar"]
class UserRelationshipEventPayload(BasePayload):
id: str
user: str
status: Status
+19 -1
View File
@@ -7,7 +7,11 @@ if TYPE_CHECKING:
from .file import File
__all__ = ("Message",)
__all__ = (
"Message",
"MessageReplyPayload",
"Masquerade"
)
class UserAddContent(TypedDict):
id: str
@@ -40,12 +44,26 @@ class ChannelDescriptionChangeContent(TypedDict):
class ChannelIconChangeContent(TypedDict):
by: str
MessageEdited = TypedDict("MessageEdited", {"$date": str})
class Masquerade(TypedDict, total=False):
name: str
avatar: str
class _OptionalMessage(TypedDict):
attachments: list[File]
embeds: list[Embed]
mentions: list[str]
replies: list[str]
edited: MessageEdited
masquerade: Masquerade
class Message(_OptionalMessage):
_id: str
channel: str
author: str
content: Union[str, UserAddContent, UserRemoveContent, UserJoinedContent, UserLeftContent, UserKickedContent, UserBannedContent, ChannelRenameContent, ChannelDescriptionChangeContent, ChannelIconChangeContent]
class MessageReplyPayload(TypedDict):
id: str
mention: bool
+3 -1
View File
@@ -13,6 +13,7 @@ __all__ = (
"BannedUser",
"Ban",
"ServerBans",
"SystemMessagesConfig"
)
class SystemMessagesConfig(TypedDict, total=False):
@@ -29,8 +30,9 @@ class _ServerOptional(TypedDict, total=False):
roles: list[Role]
icon: File
banner: File
nsfw: bool
class Server(TypedDict):
class Server(_ServerOptional):
_id: str
owner: str
name: str
+48 -13
View File
@@ -1,14 +1,16 @@
from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple, Optional
from typing import TYPE_CHECKING, NamedTuple, Optional, Union
from .asset import Asset
from .asset import Asset, PartialAsset
from .enums import PresenceType, RelationshipType
from .flags import UserBadges
if TYPE_CHECKING:
from .state import State
from .types import File
from .types import Status as StatusPayload
from .types import User as UserPayload
from .types import UserRelation
__all__ = ("User",)
@@ -23,6 +25,11 @@ class Status(NamedTuple):
text: Optional[str]
presence: Optional[PresenceType]
class UserProfile(NamedTuple):
"""A namedtuple representing a users profile"""
content: Optional[str]
background: Optional[Asset]
class User:
"""Represents a user
@@ -30,20 +37,16 @@ class User:
-----------
id: :class:`str`
The users id
name: :class:`str`
The users name
bot: :class:`bool`
Whether or not the user is a bot
owner: Optional[:class:`User`]
The bot's owner if the user is a bot
badges: :class:`int`
badges: :class:`UserBadges`
The users badges
online: :class:`bool`
Whether or not the user is online
flags: :class:`int`
The user flags
avatar: :class:`Asset`
The users avatar if they have one
relations: list[:class:`Relation`]
A list of the users relations
relationship: Optional[:class:`RelationshipType`]
@@ -51,14 +54,14 @@ class User:
status: Optional[:class:`Status`]
The users status
"""
__flattern_attributes__ = ("id", "name", "bot", "owner_id", "badges", "online", "flags", "avatar", "relations", "relationship", "status")
__flattern_attributes__ = ("id", "bot", "owner_id", "badges", "online", "flags", "relations", "relationship", "status", "masquerade_avatar", "masquerade_name", "original_name", "original_avatar", "profile")
__slots__ = (*__flattern_attributes__, "state")
def __init__(self, data: UserPayload, state: State):
self.state = state
self.id = data["_id"]
self.name = data["username"]
self.original_name = data["username"]
bot = data.get("bot")
if bot:
self.bot = True
@@ -67,12 +70,12 @@ class User:
self.bot = False
self.owner_id = None
self.badges = data.get("badges", 0)
self.badges = UserBadges._from_value(data.get("badges", 0))
self.online = data.get("online", False)
self.flags = data.get("flags", 0)
avatar = data.get("avatar")
self.avatar = Asset(avatar, state) if avatar else None
self.original_avatar = Asset(avatar, state) if avatar else None
relations = []
@@ -92,6 +95,11 @@ class User:
else:
self.status = None
self.profile: Optional[UserProfile] = None
self.masquerade_avatar: Optional[PartialAsset] = None
self.masquerade_name: Optional[str] = None
@property
def owner(self) -> Optional[User]:
owner_id = self.owner_id
@@ -100,3 +108,30 @@ class User:
return
return self.state.get_user(owner_id)
@property
def name(self) -> str:
""":class:`str` The name the user is displaying, this includes there orginal name and masqueraded name"""
return self.masquerade_name or self.original_name
@property
def avatar(self) -> Union[Asset, PartialAsset, None]:
"""Optional[:class:`Asset`] The avatar the member is displaying, this includes there orginal avatar and masqueraded avatar"""
return self.masquerade_avatar or self.original_avatar
def _update(self, *, status: Optional[StatusPayload] = None, profile_content: Optional[str] = None, profile_background: Optional[File] = None, avatar: Optional[File] = None, online: Optional[bool] = None):
if status:
presence = status.get("presence")
self.status = Status(status.get("text"), PresenceType(presence) if presence else None)
if profile_background:
self.profile = UserProfile(self.profile.content if self.profile else None, Asset(profile_background, self.state))
if profile_content:
self.profile = UserProfile(profile_content, self.profile.background if self.profile else None)
if avatar:
self.original_avatar = Asset(avatar, self.state)
if online:
self.online = online
+62 -2
View File
@@ -1,6 +1,10 @@
from typing import Callable, TypeVar
import inspect
from typing import Any, Callable, Coroutine, TypeVar, Union, Iterable
from operator import attrgetter
__all__ = ("Missing", "copy_doc")
from typing_extensions import ParamSpec
__all__ = ("Missing", "copy_doc", "maybe_coroutine", "get")
class _Missing:
def __repr__(self):
@@ -16,3 +20,59 @@ def copy_doc(from_t: T) -> Callable[[T], T]:
return to_t
return inner
R_T = TypeVar("R_T")
P = ParamSpec("P")
# its impossible to type this function correctly for a couple reasons:
# 1. isawaitable doesnt narrow while keeping typevars - there is an open pr for this (typeshed#5658) but it cant be merged because mypy doesnt support the feature fully
# 2. typeguard doesnt narrow for the negative case which is dumb imo, so `value` would stay being a union even after the if statement (PEP 647 - "The type is not narrowed in the negative case")
async def maybe_coroutine(func: Callable[P, Union[R_T, Coroutine[Any, Any, R_T]]], *args: P.args, **kwargs: P.kwargs) -> R_T:
value = func(*args, **kwargs)
if inspect.isawaitable(value):
value = await value
return value # type: ignore
def get(iterable: Iterable[T], **attrs: Any) -> T:
"""A convenaince function to help get a value from an iterable with a specific attribute
Examples
---------
.. code-block:: python
:emphasize-lines: 4
from revolt import utils
channel = utils.get(server.channels, name="General")
await channel.send("Hello general chat.")
Parameters
-----------
iterable: Iterable
The values to search though
**attrs: Any
The attributes to check
Returns
--------
Any
The value from the iterable with the met attributes
Raises
-------
LookupError
Raises when none of the values in the iterable matches the attributes
"""
converted = [(attrgetter(attr.replace('__', '.')), value) for attr, value in attrs.items()]
for elem in iterable:
if all(pred(elem) == value for pred, value in converted):
return elem
raise LookupError
+136 -10
View File
@@ -5,11 +5,19 @@ import logging
from copy import copy
from typing import TYPE_CHECKING, Callable, cast
from .enums import RelationshipType
from .types import (ChannelCreateEventPayload, ChannelDeleteEventPayload,
ChannelDeleteTypingEventPayload,
ChannelStartTypingEventPayload, ChannelUpdateEventPayload)
from .types import Message as MessagePayload
from .types import MessageDeleteEventPayload, MessageUpdateEventPayload
from .types import (MessageDeleteEventPayload, MessageUpdateEventPayload,
ServerDeleteEventPayload, ServerMemberJoinEventPayload,
ServerMemberLeaveEventPayload,
ServerMemberUpdateEventPayload,
ServerRoleDeleteEventPayload, ServerRoleUpdateEventPayload,
ServerUpdateEventPayload, UserRelationshipEventPayload,
UserUpdateEventPayload)
from .user import Status
try:
import ujson as json
@@ -36,7 +44,7 @@ __all__ = ("WebsocketHandler",)
logger = logging.getLogger("revolt")
class WebsocketHandler:
__slots__ = ("session", "token", "ws_url", "dispatch", "state", "websocket", "loop")
__slots__ = ("session", "token", "ws_url", "dispatch", "state", "websocket", "loop", "user", "ready")
def __init__(self, session: aiohttp.ClientSession, token: str, ws_url: str, dispatch: Callable[..., None], state: State):
self.session = session
@@ -46,6 +54,8 @@ class WebsocketHandler:
self.state = state
self.websocket: aiohttp.ClientWebSocketResponse
self.loop = asyncio.get_running_loop()
self.user = None
self.ready = asyncio.Event()
async def send_payload(self, payload: BasePayload):
if use_msgpack:
@@ -71,6 +81,9 @@ class WebsocketHandler:
event_type = payload["type"].lower()
logger.debug("Recieved event %s %s", event_type, payload)
try:
if event_type != "ready":
await self.ready.wait()
func = getattr(self, f"handle_{event_type}")
except:
logger.debug("Unknown event '%s'", event_type)
@@ -82,8 +95,11 @@ class WebsocketHandler:
logger.info("Successfully authenticated")
async def handle_ready(self, payload: ReadyEventPayload):
for user in payload["users"]:
self.state.add_user(user)
for user_payload in payload["users"]:
user = self.state.add_user(user_payload)
if user.relationship == RelationshipType.user:
self.user = user
for channel in payload["channels"]:
self.state.add_channel(channel)
@@ -97,6 +113,7 @@ class WebsocketHandler:
await self.state.fetch_all_server_members()
self.ready.set()
self.dispatch("ready")
async def handle_message(self, payload: MessageEventPayload):
@@ -124,10 +141,13 @@ class WebsocketHandler:
async def handle_messagedelete(self, payload: MessageDeleteEventPayload):
self.dispatch("raw_message_delete", payload)
message = self.state.get_message(payload["id"])
if message:
self.state.messages.remove(message)
self.dispatch("message_delete", message)
try:
message = self.state.get_message(payload["id"])
except:
return
self.state.messages.remove(message)
self.dispatch("message_delete", message)
async def handle_channelcreate(self, payload: ChannelCreateEventPayload):
channel = self.state.add_channel(payload)
@@ -161,11 +181,117 @@ class WebsocketHandler:
self.dispatch("typing_start", channel, user)
async def handle_channeldeletetyping(self, payload: ChannelDeleteTypingEventPayload):
async def handle_channelstoptyping(self, payload: ChannelDeleteTypingEventPayload):
channel = self.state.get_channel(payload["id"])
user = self.state.get_user(payload["user"])
self.dispatch("typing_delete", channel, user)
self.dispatch("typing_stop", channel, user)
async def handle_serverupdate(self, payload: ServerUpdateEventPayload):
server = self.state.get_server(payload["id"])
old_server = copy(server)
server._update(**payload["data"])
if clear := payload.get("clear"):
if clear == "Icon":
server.icon = None
elif clear == "Banner":
server.banner = None
elif clear == "Description":
server.description = None
self.dispatch("server_update", old_server, server)
async def handle_serverdelete(self, payload: ServerDeleteEventPayload):
server = self.state.servers.pop(payload["id"])
for channel in server.channels:
del self.state.channels[channel.id]
self.dispatch("server_delete", server)
async def handle_servermemberupdate(self, payload: ServerMemberUpdateEventPayload):
member = self.state.get_member(payload["id"]["server"], payload["id"]["user"])
old_member = copy(member)
if clear := payload.get("clear"):
if clear == "Nickname":
member.nickname = None
elif clear == "Avatar":
member.guild_avatar = None
member._update(**payload["data"])
self.dispatch("member_update", old_member, member)
async def handle_servermemberjoin(self, payload: ServerMemberJoinEventPayload):
member = self.state.add_member(payload["id"], {"_id": {"server": payload["id"], "user": payload["user"]}})
self.dispatch("member_join", member)
async def handle_memberleave(self, payload: ServerMemberLeaveEventPayload):
server = self.state.get_server(payload["id"])
member = server._members.pop(payload["user"])
self.dispatch("member_leave", member)
async def handle_serveroleupdate(self, payload: ServerRoleUpdateEventPayload):
server = self.state.get_server(payload["id"])
role = server.get_role(payload["role_id"])
old_role = copy(role)
if clear := payload.get("clear"):
if clear == "Colour":
role.colour = None
role._update(**payload["data"])
self.dispatch("role_update", old_role, role)
async def handle_serverroledelete(self, payload: ServerRoleDeleteEventPayload):
server = self.state.get_server(payload["id"])
role = server._roles.pop(payload["role_id"])
self.dispatch("role_delete", role)
async def handle_userupdate(self, payload: UserUpdateEventPayload):
user = self.state.get_user(payload["id"])
old_user = copy(user)
if clear := payload.get("clear"):
if clear == "ProfileContent":
...
elif clear == "ProfileBackground":
...
elif clear == "StatusText":
# user.status will never be none because they are trying to remove the text
if user.status.presence is None: # type: ignore
user.status = None
else:
user.status = Status(None, user.status.presence) # type: ignore
elif clear == "Avatar":
user.original_avatar = None
# the keys have . in them so i need to replace with _
data = payload["data"]
data["profile_content"] = data.pop("profile.content", None)
data["profile_background"] = data.pop("profile.background", None)
user._update(**data)
self.dispatch("user_update", old_user, user)
async def handle_userrelationship(self, payload: UserRelationshipEventPayload):
user = self.state.get_user(payload["user"])
old_relationship = user.relationship
user.relationship = RelationshipType(payload["status"])
self.dispatch("user_relationship_update", user, old_relationship, user.relationship)
async def start(self):
if use_msgpack:
+14 -6
View File
@@ -6,16 +6,22 @@ here = pathlib.Path(__file__).parent.resolve()
long_description = (here / "README.md").read_text(encoding="utf-8")
requirements = []
with open('requirements.txt') as f:
requirements = f.read().splitlines()
setup(
name="revolt.py",
version="0.1.0",
description="Python wrapper around revolt.chat",
version="0.1.3",
description="Python wrapper for the revolt.chat API",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/Zomatree/revolt.py",
url="https://github.com/revoltchat/revolt.py",
author="Zomatree",
classifiers=[
"Development Status :: 3 - Beta",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
@@ -23,6 +29,7 @@ setup(
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
],
@@ -32,8 +39,9 @@ setup(
extras_require={
"speedups": ["ujson", "aiohttp[speedups]==3.7.4.post0", "msgpack==1.0.2"],
},
install_requires=requirements,
project_urls={
"Bug Reports": "https://github.com/Zomatree/revolt.py/issues",
"Source": "https://github.com/Zomatree/revolt.py/",
"Bug Reports": "https://github.com/revoltchat/revolt.py/issues",
"Source": "https://github.com/revoltchat/revolt.py/",
},
)