20 Commits

Author SHA1 Message Date
Zomatree ef6508dc69 change http methods 2022-03-07 02:46:31 +00:00
Zomatree a4438e73f8 list remove 2022-03-07 02:37:59 +00:00
Zomatree be2af590af add cogs and extensions 2022-02-14 05:56:18 +00:00
Zomatree 16177e6698 fix missing inconsistancies 2022-02-14 05:51:46 +00:00
Zomatree 40e9e3a26e import * 2022-02-14 05:50:50 +00:00
Zomatree c29a763576 add embed support 2022-02-14 05:50:35 +00:00
Zomatree 5e757d129f fix parameters with no annotation 2022-02-08 22:15:45 +00:00
Zomatree b17b7fc171 inital implementation of group commands 2022-01-28 19:32:00 +00:00
Zomatree 9dfcf69e52 bump version to v0.1.6 2022-01-28 19:15:11 +00:00
Zomatree b1a91cecb1 fix typing fix error 2022-01-28 19:14:15 +00:00
Zomatree 7cf3fa3e01 bump version to 0.1.5 2022-01-27 23:41:56 +00:00
Zomatree bff242c3f6 fix deps not being auto installed from pypi 2022-01-27 23:41:21 +00:00
Zomatree 0fbb90f65e fix typing issues 2022-01-22 00:30:09 +00:00
Zomatree c7963f02e9 cover most api routes 2022-01-21 22:04:30 +00:00
Bye 5641390d5b Add pip install instructions (#25)
Co-authored-by: Zomatree <39768508+Zomatree@users.noreply.github.com>
2022-01-21 20:24:29 +00:00
Zomatree 7a37da571f bump version to 0.1.4 2022-01-05 18:45:23 +00:00
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
39 changed files with 1205 additions and 200 deletions
+1
View File
@@ -7,3 +7,4 @@ docs/_build
.vscode
.env
.mypy_cache
build
+3 -3
View File
@@ -9,13 +9,13 @@ venv:
python -m pip install -r requirements.txt
python -m pip install -r docs_requirements.txt
test: venv
test:
python test.py
build: venv
build:
python -m build
upload: venv
upload:
python -m twine upload dist/* -u $PYPI_USERNAME -p $PYPI_PASSWORD
lint:
Executable
+3
View File
@@ -0,0 +1,3 @@
include requirements.txt
include LICENSE
include README.md
+15 -1
View File
@@ -2,7 +2,21 @@
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.
You can join the support server [here](https://rvlt.gg/FDXER6hr) and find the library's documentation [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/).
## Installing
You can use `pip` to install revolt.py. It differs slightly depending on what OS/Distro you use.
On Windows
```
py -m pip install -U revolt.py # -U to update
```
On macOS and Linux
```
python3 -m pip install -U revolt.py
```
## Example
+3 -8
View File
@@ -90,21 +90,16 @@ 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:
+1
View File
@@ -1,3 +1,4 @@
aiohttp==3.7.4.post0
ulid-py==1.1.0
aenum==3.1.0
typing_extensions==4.1.1
+17 -19
View File
@@ -1,21 +1,19 @@
from .asset import Asset
from .category import Category
from .channel import (Channel, DMChannel, GroupDMChannel, SavedMessageChannel,
TextChannel, VoiceChannel)
from .client import Client
from .embed import Embed
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 Masquerade, Message, MessageReply
from .messageable import Messageable
from .permissions import ChannelPermissions, ServerPermissions
from .role import Role
from .server import Server, SystemMessages
from .user import Relation, Status, User
from . import utils
from .asset import *
from .category import *
from .channel import *
from .client import *
from .embed import *
from .enums import *
from .errors import *
from .file import *
from .flags import *
from .member import *
from .message import *
from .messageable import *
from .permissions import *
from .role import *
from .server import *
from .user import *
__version__ = (0, 1, 2)
__version__ = (0, 1, 6)
+133 -23
View File
@@ -1,10 +1,14 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, cast
from typing import TYPE_CHECKING, Literal, Optional, Union
from revolt.utils import Missing
from .asset import Asset
from .enums import ChannelType
from .messageable import Messageable
from .permissions import ChannelPermissions
from .utils import Missing
if TYPE_CHECKING:
from .message import Message
@@ -13,13 +17,28 @@ if TYPE_CHECKING:
from .state import State
from .types import Channel as ChannelPayload
from .types import DMChannel as DMChannelPayload
from .types import Group as GroupDMChannelPayload
from .types import GroupDMChannel as GroupDMChannelPayload
from .types import SavedMessages as SavedMessagesPayload
from .types import TextChannel as TextChannelPayload
from .types import VoiceChannel as VoiceChannelPayload
from .user import User
__all__ = ("Channel",)
__all__ = ("DMChannel", "GroupDMChannel", "SavedMessageChannel", "TextChannel", "VoiceChannel", "Channel")
class EditableChannel:
__slots__ = ()
state: State
id: str
async def edit(self, **kwargs):
remove = []
if kwargs.get("icon", Missing) == None:
remove.append("Icon")
elif kwargs.get("description", Missing) == None:
remove.append("Description")
await self.state.http.edit_channel(self.id, remove, kwargs)
class Channel:
"""Base class for all channels
@@ -30,8 +49,6 @@ class Channel:
The id of the channel
channel_type: ChannelType
The type of the channel
server: Optional[:class:`Server`]
The server the channel is part of
"""
__slots__ = ("state", "id", "channel_type", "server_id")
@@ -41,13 +58,21 @@ class Channel:
self.channel_type = ChannelType(data["channel_type"])
self.server_id = ""
@property
def server(self) -> Optional[Server]:
return self.state.get_server(self.server_id) if self.server_id else None
async def _get_channel_id(self) -> str:
return self.id
def _update(self):
pass
async def delete(self):
"""Deletes or closes the channel"""
await self.state.http.close_channel(self.id)
@property
def server(self) -> Server:
""":class:`Server` The server this voice channel belongs too"""
return self.state.get_server(self.server_id)
class SavedMessageChannel(Channel, Messageable):
"""The Saved Message Channel"""
def __init__(self, data: SavedMessagesPayload, state: State):
@@ -58,26 +83,54 @@ class DMChannel(Channel, Messageable):
def __init__(self, data: DMChannelPayload, state: State):
super().__init__(data, state)
class GroupDMChannel(Channel, Messageable):
__slots__ = ("recipients", "name", "owner", "permissions")
class GroupDMChannel(Channel, Messageable, EditableChannel):
"""A group DM channel
Attributes
-----------
recipients: list[:class:`User`]
The recipients of the group dm channel
name: :class:`str`
The name of the group dm channel
owner: :class:`User`
The user who created the group dm channel
icon: Optional[:class:`Asset`]
The icon of the group dm channel
permissions: :class:`ChannelPermissions`
The permissions of the users inside the group dm channel
description: Optional[:class:`str`]
The description of the channel, if any
"""
__slots__ = ("recipients", "name", "owner", "permissions", "icon", "description")
"""A group DM channel"""
def __init__(self, data: GroupDMChannelPayload, state: State):
super().__init__(data, state)
self.recipients = [state.get_user(user_id) for user_id in data["recipients"]]
self.name = data["name"]
self.owner = state.get_user(data["owner"])
self.description = data.get("description")
if icon := data.get("icon"):
self.icon = Asset(icon, state)
else:
self.icon = None
if perms := data.get("permissions"):
self.permissions = ChannelPermissions._from_value(perms)
else:
self.permissions = ChannelPermissions._from_value(0)
def _update(self, *, name: Optional[str] = None, recipients: Optional[list[str]] = None):
def _update(self, *, name: Optional[str] = None, recipients: Optional[list[str]] = None, description: Optional[str] = None):
if name:
self.name = name
if recipients:
self.recipients = [self.state.get_user(user_id) for user_id in recipients]
if description:
self.description = description
async def set_default_permissions(self, permissions: ChannelPermissions) -> None:
"""Sets the default permissions for a group.
Parameters
@@ -87,10 +140,28 @@ class GroupDMChannel(Channel, Messageable):
"""
await self.state.http.set_channel_default_permissions(self.id, permissions.value)
class TextChannel(Channel, Messageable):
__slots__ = ("name", "description", "last_message_id", "server_id", "default_permissions", "role_permissions")
class TextChannel(Channel, Messageable, EditableChannel):
__slots__ = ("name", "description", "last_message_id", "server_id", "default_permissions", "role_permissions", "icon")
"""A text channel"""
"""A text channel
Attributes
-----------
name: :class:`str`
The name of the text channel
server_id: :class:`str`
The id of the server this text channel belongs to
last_message_id: Optional[:class:`str`]
The id of the last message in this channel, if any
default_permissions: :class:`ChannelPermissions`
The default permissions for all users in the text channel
role_permissions: dict[:class:`str`, :class:`ChannelPermissions`]
A dictionary of role id's to the permissions of that role in the text channel
icon: Optional[:class:`Asset`]
The icon of the text channel, if any
description: Optional[:class:`str`]
The description of the channel, if any
"""
def __init__(self, data: TextChannelPayload, state: State):
super().__init__(data, state)
@@ -101,17 +172,29 @@ class TextChannel(Channel, Messageable):
last_message_id = data.get("last_message")
self.last_message_id = last_message_id
if perms := data.get("default_permissions"):
self.default_permissions = ChannelPermissions._from_value(perms)
self.default_permissions = ChannelPermissions._from_value(data.get("default_permissions", 0))
self.role_permissions = {role_id: ChannelPermissions._from_value(perms) for role_id, perms in data.get("role_permissions", {}).items()}
if role_perms := data.get("role_permissions"):
self.role_permissions = {role_id: ChannelPermissions._from_value(perms) for role_id, perms in role_perms.items()}
if icon := data.get("icon"):
self.icon = Asset(icon, state)
else:
self.icon = None
def _get_channel_id(self) -> str:
return self.id
@property
def last_message(self) -> Message:
def last_message(self) -> Optional[Message]:
"""Gets the last message from the channel, shorthand for `client.get_message(channel.last_message_id)`
Returns
--------
:class:`Message` the last message in the channel
"""
if not self.last_message_id:
return
return self.state.get_message(self.last_message_id)
def _update(self, *, name: Optional[str] = None, description: Optional[str] = None):
@@ -139,8 +222,26 @@ class TextChannel(Channel, Messageable):
"""
await self.state.http.set_channel_role_permissions(self.id, role.id, permissions.value)
class VoiceChannel(Channel):
"""A voice channel"""
class VoiceChannel(Channel, EditableChannel):
"""A voice channel
Attributes
-----------
name: :class:`str`
The name of the voice channel
server_id: :class:`str`
The id of the server this voice channel belongs to
last_message_id: Optional[:class:`str`]
The id of the last message in this channel, if any
default_permissions: :class:`ChannelPermissions`
The default permissions for all users in the voice channel
role_permissions: dict[:class:`str`, :class:`ChannelPermissions`]
A dictionary of role id's to the permissions of that role in the voice channel
icon: Optional[:class:`Asset`]
The icon of the voice channel, if any
description: Optional[:class:`str`]
The description of the channel, if any
"""
def __init__(self, data: VoiceChannelPayload, state: State):
super().__init__(data, state)
@@ -150,9 +251,18 @@ class VoiceChannel(Channel):
if perms := data.get("default_permissions"):
self.default_permissions = ChannelPermissions._from_value(perms)
else:
self.default_permissions = ChannelPermissions._from_value(0)
if role_perms := data.get("role_permissions"):
self.role_permissions = {role_id: ChannelPermissions._from_value(perms) for role_id, perms in role_perms.items()}
else:
self.role_permissions = {}
if icon := data.get("icon"):
self.icon = Asset(icon, state)
else:
self.icon = None
def _update(self, *, name: Optional[str] = None, description: Optional[str] = None):
if name:
@@ -179,7 +289,7 @@ class VoiceChannel(Channel):
"""
await self.state.http.set_channel_role_permissions(self.id, role.id, permissions.value)
def channel_factory(data: ChannelPayload, state: State) -> Channel:
def channel_factory(data: ChannelPayload, state: State) -> Union[DMChannel, GroupDMChannel, SavedMessageChannel, TextChannel, VoiceChannel]:
if data["channel_type"] == "SavedMessage":
return SavedMessageChannel(data, state)
elif data["channel_type"] == "DirectMessage":
+109 -1
View File
@@ -2,11 +2,15 @@ from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, TypeVar
from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast
import aiohttp
from .channel import (DMChannel, GroupDMChannel, SavedMessageChannel,
TextChannel, VoiceChannel, channel_factory)
from .http import HttpClient
from .invite import Invite
from .message import Message
from .state import State
from .websocket import WebsocketHandler
@@ -168,6 +172,7 @@ class Client:
@property
def user(self) -> User:
""":class:`User` the user corrasponding to the client"""
user = self.websocket.user
assert user
@@ -175,4 +180,107 @@ class Client:
@property
def users(self) -> list[User]:
"""list[:class:`User`] All users the client can see"""
return list(self.state.users.values())
async def fetch_user(self, user_id: str) -> User:
"""Fetchs a user
Parameters
-----------
user_id: :class:`str`
The id of the user you are fetching
Returns
--------
:class:`User`
The user with the matching id
"""
payload = await self.http.fetch_user(user_id)
return User(payload, self.state)
async def fetch_dm_channels(self) -> list[Union[DMChannel, GroupDMChannel]]:
"""Fetchs all dm channels the client has made
Returns
--------
list[Union[:class:`DMChanel`, :class:`GroupDMChannel`]]
A list of :class:`DMChannel` or :class`GroupDMChannel`
"""
channel_payloads = await self.http.fetch_dm_channels()
return cast(list[Union[DMChannel, GroupDMChannel]], [channel_factory(payload, self.state) for payload in channel_payloads])
async def fetch_channel(self, channel_id: str) -> Union[DMChannel, GroupDMChannel, SavedMessageChannel, TextChannel, VoiceChannel]:
"""Fetches a channel
Parameters
-----------
channel_id: :class:`str`
The id of the channel
Returns
--------
Union[:class:`DMChannel`, :class:`GroupDMChannel`, :class:`SavedMessageChannel`, :class:`TextChannel`, :class:`VoiceChannel`]
The channel with the matching id
"""
payload = await self.http.fetch_channel(channel_id)
return channel_factory(payload, self.state)
async def fetch_server(self, server_id: str) -> Server:
"""Fetchs a server
Parameters
-----------
server_id: :class:`str`
The id of the server you are fetching
Returns
--------
:class:`Server`
The server with the matching id
"""
payload = await self.http.fetch_server(server_id)
return Server(payload, self.state)
async def fetch_invite(self, code: str) -> Invite:
"""Fetchs an invite
Parameters
-----------
code: :class:`str`
The code of the invite you are fetching
Returns
--------
:class:`Invite`
The invite with the matching code
"""
payload = await self.http.fetch_invite(code)
return Invite(payload, code, self.state)
def get_message(self, message_id: str) -> Message:
"""Gets a message from the cache
Parameters
-----------
message_id: :class:`str`
The id of the message you are getting
Returns
--------
:class:`Message`
The message with the matching id
Raises
-------
LookupError
This raises if the message is not found in the cache
"""
for message in self.state.messages:
if message.id == message_id:
return message
raise LookupError
+97 -8
View File
@@ -1,17 +1,106 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional, Union
from .enums import EmbedType
from .asset import Asset
if TYPE_CHECKING:
from .state import State
from .types import Embed as EmbedPayload
from .types import SendableEmbed as SendableEmbedPayload
from .types import WebsiteEmbed as WebsiteEmbedPayload
from .types import ImageEmbed as ImageEmbedPayload
from .types import TextEmbed as TextEmbedPayload
from .types import NoneEmbed as NoneEmbedPayload
__all__ = ("Embed", "WebsiteEmbed", "ImageEmbed", "TextEmbed", "NoneEmbed", "to_embed", "SendableEmbed")
__all__ = ("Embed",)
class WebsiteEmbed:
type = EmbedType.website
class Embed:
@classmethod
def from_dict(cls, data: EmbedPayload):
...
def __init__(self, embed: WebsiteEmbedPayload):
self.url = embed.get("url")
self.special = embed.get("special")
self.title = embed.get("title")
self.description = embed.get("description")
self.image = embed.get("image")
self.video = embed.get("video")
self.site_name = embed.get("site_name")
self.icon_url = embed.get("icon_url")
self.colour = embed.get("colour")
def to_dict(self) -> EmbedPayload:
...
class ImageEmbed:
type = EmbedType.image
def __init__(self, image: ImageEmbedPayload):
self.url = image.get("url")
self.width = image.get("width")
self.height = image.get("height")
self.size = image.get("size")
class TextEmbed:
type = EmbedType.text
def __init__(self, embed: TextEmbedPayload, state: State):
self.icon_url = embed.get("icon_url")
self.url = embed.get("url")
self.title = embed.get("title")
self.description = embed.get("description")
if media := embed.get("media"):
self.media = Asset(media, state)
else:
self.media = None
self.colour = embed.get("colour")
class NoneEmbed:
type = EmbedType.none
Embed = Union[WebsiteEmbed, ImageEmbed, TextEmbed, NoneEmbed]
def to_embed(payload: EmbedPayload, state: State) -> Embed:
if payload["type"] == "Website":
return WebsiteEmbed(payload)
elif payload["type"] == "Image":
return ImageEmbed(payload)
elif payload["type"] == "Text":
return TextEmbed(payload, state)
else:
return NoneEmbed()
class SendableEmbed:
def __init__(self, **attrs):
self.title: Optional[str] = None
self.description: Optional[str] = None
self.media: Optional[str] = None
self.icon_url: Optional[str] = None
self.colour: Optional[str] = None
self.url: Optional[str] = None
for key, value in attrs.items():
setattr(self, key, value)
def to_dict(self) -> SendableEmbedPayload:
output: SendableEmbedPayload = {"type": "Text"}
if title := self.title:
output["title"] = title
if description := self.description:
output["description"] = description
if media := self.media:
output["media"] = media
if icon_url := self.icon_url:
output["icon_url"] = icon_url
if colour := self.colour:
output["colour"] = colour
if url := self.url:
output["url"] = url
return output
+7
View File
@@ -14,6 +14,7 @@ __all__ = (
"RelationshipType",
"AssetType",
"SortType",
"EmbedType"
)
class ChannelType(enum.Enum):
@@ -49,3 +50,9 @@ class SortType(enum.Enum):
latest = "Latest"
oldest = "Oldest"
relevance = "Relevance"
class EmbedType(enum.Enum):
website = "Website"
image = "Image"
text = "Text"
none = "None"
+3
View File
@@ -2,4 +2,7 @@ from .checks import *
from .client import *
from .command import *
from .context import *
from .converters import *
from .errors import *
from .group import *
from .cog import *
+1 -1
View File
@@ -29,7 +29,7 @@ def check(check: Check):
else:
checks = getattr(func, "_checks", [])
checks.append(check)
func._checks = checks
func._checks = checks # type: ignore
return func
+46 -10
View File
@@ -1,24 +1,28 @@
from __future__ import annotations
import re
import traceback
from typing import Any, Callable, Union
import sys
from typing import Any, Union, Protocol, runtime_checkable
from importlib import import_module
import revolt
from .command import Command
from .context import Context
from .errors import CheckError, CommandNotFound
from .errors import CheckError, CommandNotFound, MissingSetup
from .view import StringView
from .cog import Cog
__all__ = (
"CommandsMeta",
"CommandsClient"
)
quote_regex = re.compile(r"[\"']")
chunk_regex = re.compile(r"\S+")
@runtime_checkable
class ExtensionProtocol(Protocol):
@staticmethod
def setup(client: CommandsClient):
raise NotImplementedError
class CommandsMeta(type):
_commands: list[Command]
@@ -26,7 +30,6 @@ class CommandsMeta(type):
def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any]):
commands: list[Command] = []
self = super().__new__(cls, name, bases, attrs)
for base in reversed(self.__mro__):
for value in base.__dict__.values():
if isinstance(value, Command):
@@ -34,9 +37,6 @@ class CommandsMeta(type):
self._commands = commands
for command in commands:
command._client = self # type: ignore
return self
class CommandsClient(revolt.Client, metaclass=CommandsMeta):
@@ -46,6 +46,8 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
def __init__(self, *args, **kwargs):
self.all_commands: dict[str, Command] = {}
self.cogs: dict[str, Cog] = {}
self.extensions: dict[str, ExtensionProtocol] = {}
for command in self._commands:
self.all_commands[command.name] = command
@@ -101,6 +103,9 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
"""
self.all_commands[name] = command
for alias in command.aliases:
self.all_commands[alias] = command
def get_view(self, message: revolt.Message) -> type[StringView]:
return StringView
@@ -171,6 +176,7 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
return output
except Exception as e:
await command._error_handler(command.cog or self, context, e)
self.dispatch("command_error", context, e)
@staticmethod
@@ -193,3 +199,33 @@ class CommandsClient(revolt.Client, metaclass=CommandsMeta):
"""
return True
def add_cog(self, cog: Cog):
cog._inject(self)
def remove_cog(self, cog_name: str) -> Cog:
cog = self.cogs.pop(cog_name)
cog._uninject(self)
return cog
def load_extension(self, name: str):
extension = import_module(name)
if not isinstance(extension, ExtensionProtocol):
raise MissingSetup(f"'{extension}' is missing a setup function")
self.extensions[name] = extension
extension.setup(self)
def unload_extension(self, name: str):
extension = self.extensions.pop(name)
del sys.modules[name]
if teardown := getattr(extension, "teardown", None):
teardown(self)
def reload_extension(self, name: str):
self.unload_extension(name)
self.load_extension(name)
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from distutils import command
from typing import TYPE_CHECKING, Any
from .command import Command
if TYPE_CHECKING:
from .client import CommandsClient
__all__ = ("Cog", "CogMeta")
class CogMeta(type):
_commands: list[Command]
def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any]):
commands: list[Command] = []
self = super().__new__(cls, name, bases, attrs)
for base in reversed(self.__mro__):
for value in base.__dict__.values():
if isinstance(value, Command):
value.cog = self # type: ignore
commands.append(value)
self._commands = commands
return self
class Cog(metaclass=CogMeta):
_commands: list[Command]
def _inject(self, client: CommandsClient):
client.cogs[type(self).__name__] = self
for command in self._commands:
client.add_command(command.name, command)
def _uninject(self, client: CommandsClient):
for name, command in client.all_commands.copy().items():
if command in self._commands:
del client.all_commands[name]
+31 -25
View File
@@ -4,22 +4,26 @@ import inspect
import traceback
from contextlib import suppress
from typing import (TYPE_CHECKING, Annotated, Any, Callable, Coroutine,
Literal, Optional, Union, get_args, get_origin)
Literal, Optional, Union, cast, get_args, get_origin)
import revolt
from revolt.utils import copy_doc, maybe_coroutine
from .errors import InvalidLiteralArgument
from .errors import InvalidLiteralArgument, UnionConverterError
if TYPE_CHECKING:
from .checks import Check
from .context import Context
from .group import Group
__all__ = (
"Command",
"command"
)
NoneType = type(None)
class Command:
"""Class for holding info about a command.
@@ -31,10 +35,10 @@ class Command:
The name of the command
aliases: list[:class:`str`]
The aliases of the command
parent: Optional[:class:`Group`]
The parent of the command if this command is a subcommand
"""
__slots__ = ("callback", "name", "aliases", "_client", "signature", "checks")
_client: revolt.Client
__slots__ = ("callback", "name", "aliases", "signature", "checks", "parent", "_error_handler", "cog")
def __init__(self, callback: Callable[..., Coroutine[Any, Any, Any]], name: str, aliases: list[str]):
self.callback = callback
@@ -42,6 +46,9 @@ class Command:
self.aliases = aliases
self.signature = inspect.signature(self.callback)
self.checks: list[Check] = getattr(callback, "_checks", [])
self.parent: Optional[Group] = None
self.cog = None
self._error_handler: Callable[[Any, Context, Exception], Coroutine[Any, Any, Any]] = type(self)._default_error_handler
async def invoke(self, context: Context, *args, **kwargs) -> Any:
"""Runs the command and calls the error handler if the command errors.
@@ -54,9 +61,9 @@ class Command:
The arguments for the command
"""
try:
return await self.callback(self._client, context, *args, **kwargs)
return await self.callback(self.cog or context.client, context, *args, **kwargs)
except Exception as err:
return await self._error_handler(context, err)
return await self._error_handler(self.cog or context.client, context, err)
@copy_doc(invoke)
def __call__(self, context: Context, *args, **kwargs) -> Any:
@@ -79,11 +86,10 @@ class Command:
await ctx.send(str(error))
"""
self._error_handler = func # type: ignore
self._error_handler = func
return func
@staticmethod
async def _error_handler(ctx: Context, error: Exception):
async def _default_error_handler(self, ctx: Context, error: Exception):
traceback.print_exception(type(error), error, error.__traceback__)
@staticmethod
@@ -95,49 +101,49 @@ class Command:
return t
@classmethod
async def convert_argument(cls, arg: str, parameter: inspect.Parameter, context: Context):
if annot := parameter.annotation:
if annot is str: # no converting is needed - its already a string
async def convert_argument(cls, arg: str, annotation: Any, context: Context):
if annotation is not inspect._empty:
if annotation is str: # no converting is needed - its already a string
return arg
if origin := get_origin(annot):
if origin := get_origin(annotation):
if origin is Union:
for converter in get_args(annot):
for converter in get_args(annotation):
try:
return await maybe_coroutine(converter, arg, context)
return await cls.convert_argument(arg, converter, context)
except:
if converter is None:
if converter is NoneType:
context.view.undo()
return None
raise UnionConverterError(arg)
elif origin is Annotated:
converter: Callable[[str, Context], Any] = get_args(annot)[1] # the typehint affects the other if statement somehow
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(annot):
if arg in get_args(annotation):
return arg
else:
raise InvalidLiteralArgument(arg)
else:
annot: Callable[..., Any]
return await maybe_coroutine(annot, arg, context)
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, context)
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, context))
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, context))
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}\">"
+20 -6
View File
@@ -6,6 +6,7 @@ import revolt
from revolt.utils import maybe_coroutine
from .command import Command
from .group import Group
if TYPE_CHECKING:
from .client import CommandsClient
@@ -41,7 +42,7 @@ class Context(revolt.Messageable):
"""
__slots__ = ("command", "invoked_with", "args", "message", "server", "channel", "author", "view", "kwargs", "state", "client")
def _get_channel_id(self) -> str:
async 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):
@@ -58,7 +59,7 @@ class Context(revolt.Messageable):
self.state = message.state
async def invoke(self) -> Any:
"""Invokes the command, this is equal to `await command.invoke(context, context.args)`.
"""Invokes the command.
.. note:: If the command is `None`, this function will do nothing.
@@ -69,11 +70,24 @@ class Context(revolt.Messageable):
"""
if command := self.command:
await command.parse_arguments(self)
if isinstance(command, Group):
try:
subcommand_name = self.view.get_next_word()
except StopIteration:
pass
else:
if subcommand := command.subcommands.get(subcommand_name):
self.command = command = subcommand
return await self.invoke()
self.view.undo()
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 [])])
async def can_run(self, command: Optional[Command] = 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 [])])
+9 -5
View File
@@ -1,11 +1,14 @@
from typing import Annotated
import re
from typing import Annotated
from revolt import Category, Channel, Member, User, utils
from .errors import BadBoolArgument, ServerOnly, CategoryConverterError, ChannelConverterError, UserConverterError, MemberConverterError
from .context import Context
from .errors import (BadBoolArgument, CategoryConverterError,
ChannelConverterError, MemberConverterError, ServerOnly,
UserConverterError)
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})>")
@@ -47,7 +50,7 @@ def channel_converter(arg: str, context: Context) -> Channel:
raise ChannelConverterError(arg)
def user_converter(arg: str, context: Context) -> User:
if (match := user_converter.match(arg)):
if (match := user_regex.match(arg)):
arg = match.group(1)
try:
@@ -62,7 +65,7 @@ def member_converter(arg: str, context: Context) -> Member:
if not (server := context.server):
raise ServerOnly
if (match := user_converter.match(arg)):
if (match := user_regex.match(arg)):
arg = match.group(1)
try:
@@ -78,3 +81,4 @@ 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]
+9
View File
@@ -15,6 +15,7 @@ __all__ = (
"ChannelConverterError",
"UserConverterError",
"MemberConverterError",
"MissingSetup",
)
class CommandError(RevoltError):
@@ -76,3 +77,11 @@ 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
class MissingSetup(CommandError):
"""Raised when an extension is missing the `setup` function"""
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Callable, Coroutine, Any
from .command import Command
if TYPE_CHECKING:
from .context import Context
__all__ = (
"Group",
"group"
)
class Group(Command):
"""Class for holding info about a group command.
Parameters
-----------
callback: Callable[..., Coroutine[Any, Any, Any]]
The callback for the group command
name: :class:`str`
The name of the command
aliases: list[:class:`str`]
The aliases of the group command
subcommands: dict[:class:`str`, :class:`Command`]
The group's subcommands.
"""
__slots__ = ("subcommands",)
def __init__(self, callback: Callable[..., Coroutine[Any, Any, Any]], name: str, aliases: list[str]):
self.subcommands: dict[str, Command] = {}
super().__init__(callback, name, aliases)
def command(self, *, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: type[Command] = Command):
"""A decorator that turns a function into a :class:`Command` and registers the command as a subcommand.
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
--------
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]]):
command = cls(func, name or func.__name__, aliases or [])
command.parent = self
self.subcommands[command.name] = command
return command
return inner
def group(self, *, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: Optional[type["Group"]] = None):
"""A decorator that turns a function into a :class:`Group` and registers the command as a subcommand
Parameters
-----------
name: Optional[:class:`str`]
The name of the group command, this defaults to the functions name
aliases: Optional[list[:class:`str`]]
The aliases of the group command, defaults to no aliases
cls: type[:class:`Group`]
The class used for creating the command, this defaults to :class:`Group` but can be used to use a custom group subclass
Returns
--------
Callable[Callable[..., Coroutine], :class:`Group`]
A function that takes the command callback and returns a :class:`Group`
"""
cls = cls or type(self)
def inner(func: Callable[..., Coroutine[Any, Any, Any]]):
command = cls(func, name or func.__name__, aliases or [])
command.parent = self
self.subcommands[command.name] = command
return command
return inner
def __repr__(self) -> str:
return f"<Group name=\"{self.name}\">"
def group(*, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: type[Group] = Group):
"""A decorator that turns a function into a :class:`Group`
Parameters
-----------
name: Optional[:class:`str`]
The name of the group command, this defaults to the functions name
aliases: Optional[list[:class:`str`]]
The aliases of the group command, defaults to no aliases
cls: type[:class:`Group`]
The class used for creating the command, this defaults to :class:`Group` but can be used to use a custom group subclass
Returns
--------
Callable[Callable[..., Coroutine], :class:`Group`]
A function that takes the command callback and returns a :class:`Group`
"""
def inner(func: Callable[..., Coroutine[Any, Any, Any]]):
return cls(func, name or func.__name__, aliases or [])
return inner
+4 -1
View File
@@ -14,6 +14,9 @@ class StringView:
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:
@@ -38,7 +41,7 @@ class StringView:
else:
temp.append(char)
try:
while (char := self.next_char()) != " ":
while (char := self.next_char()) not in " \n":
temp.append(char)
except StopIteration:
pass
+34 -9
View File
@@ -23,15 +23,14 @@ if TYPE_CHECKING:
from .types import Autumn as AutumnPayload
from .types import Channel, DMChannel
from .types import Embed as EmbedPayload
from .types import GetServerMembers
from .types import GetServerMembers, Invite
from .types import Masquerade as MasqueradePayload
from .types import Member
from .types import Message as MessagePayload
from .types import (MessageReplyPayload, MessageWithUserData, Role, Server,
ServerBans, ServerInvite, TextChannel)
from .types import (MessageReplyPayload, MessageWithUserData,
PartialInvite, Role, Server, ServerBans, TextChannel)
from .types import User as UserPayload
from .types import UserProfile, VoiceChannel
from .types import UserProfile, VoiceChannel, GroupDMChannel, Member
from .types import SendableEmbed as SendableEmbedPayload
__all__ = ("HttpClient",)
@@ -106,7 +105,7 @@ class HttpClient:
else:
return response
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:
async def send_message(self, channel: str, content: Optional[str], embeds: Optional[list[SendableEmbedPayload]], attachments: Optional[list[File]], replies: Optional[list[MessageReplyPayload]], masquerade: Optional[MasqueradePayload]) -> MessagePayload:
json: dict[str, Any] = {}
if content:
@@ -267,7 +266,7 @@ class HttpClient:
def fetch_default_avatar(self, user_id: str) -> Request[bytes]:
return self.request_file(f"{self.api_url}/users/{user_id}/default_avatar")
def fetch_dm_channels(self) -> Request[list[Channel]]:
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]:
@@ -293,6 +292,14 @@ class HttpClient:
def delete_leave_server(self, server_id: str) -> Request[None]:
return self.request("DELETE", f"/servers/{server_id}")
@overload
def create_channel(self, server_id: str, channel_type: Literal["Text"], name: str, description: Optional[str]) -> Request[TextChannel]:
...
@overload
def create_channel(self, server_id: str, channel_type: Literal["Voice"], name: str, description: Optional[str]) -> Request[VoiceChannel]:
...
def create_channel(self, server_id: str, channel_type: Literal["Text", "Voice"], name: str, description: Optional[str]) -> Request[Union[TextChannel, VoiceChannel]]:
payload = {
"type": channel_type,
@@ -304,7 +311,7 @@ class HttpClient:
return self.request("POST", f"/servers/{server_id}/channels", json=payload)
def fetch_server_invites(self, server_id: str) -> Request[list[ServerInvite]]:
def fetch_server_invites(self, server_id: str) -> Request[list[PartialInvite]]:
return self.request("GET", f"/servers/{server_id}/invites")
def fetch_member(self, server_id: str, member_id: str) -> Request[Member]:
@@ -352,3 +359,21 @@ class HttpClient:
def delete_role(self, server_id: str, role_id: str) -> Request[None]:
return self.request("DELETE", f"/servers/{server_id}/roles/{role_id}")
def fetch_invite(self, code: str) -> Request[Invite]:
return self.request("GET", f"/invites/{code}")
def delete_invite(self, code: str) -> Request[None]:
return self.request("DELETE", f"/invites/{code}")
def edit_channel(self, channel_id: str, remove: list[str], values: dict[str, Any]):
if remove:
values["remove"] = remove
return self.request("PATCH", f"/channels/{channel_id}", json=values)
def edit_role(self, server_id: str, role_id: str, remove: list[str], values: dict[str, Any]):
if remove:
values["remove"] = remove
return self.request("PATCH", f"/servers/{server_id}/roles/{role_id}", json=values)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .asset import Asset
if TYPE_CHECKING:
from .state import State
from .types import Invite as InvitePayload
__all__ = ("Invite",)
class Invite:
"""Represents a server invite.
Attributes
-----------
code: :class:`str`
The code for the invite
server: :class:`Server`
The server this invite is for
channel: :class:`Channel`
The channel this invite is for
user_name: :class:`str`
The name of the user who made the invite
user: Optional[:class:`User`]
The user who made the invite, this is only set if this was fetched via :meth:`Server.fetch_invites`
user_avatar: Optional[:class:`Asset`]
The invite creator's avatar, if any
member_count: :class:`int`
The member count of the server this invite is for
"""
def __init__(self, data: InvitePayload, code: str, state: State):
self.state = state
self.code = code
self.server = state.get_server(data["server_id"])
self.channel = self.server.get_channel(data["channel_id"])
self.user_name = data["user_name"]
self.user = None
if avatar := data.get("user_avatar"):
self.user_avatar = Asset(avatar, state)
else:
self.user_avatar = None
self.member_count = data["member_count"]
@staticmethod
def _from_partial(code: str, server: str, creator: str, channel: str, state: State) -> Invite:
invite = Invite.__new__(Invite)
invite.state = state
invite.code = code
invite.server = state.get_server(server)
invite.channel = state.get_channel(channel)
invite.user = state.get_user(creator)
invite.user_name = invite.user.name
invite.user_avatar = invite.user.avatar
invite.member_count = len(invite.server.members)
return invite
async def delete(self):
"""Deletes the invite"""
await self.state.http.delete_invite(self.code)
+18
View File
@@ -65,3 +65,21 @@ class Member(User):
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)
async def kick(self):
"""Kicks the member from the server"""
await self.state.http.kick_member(self.server.id, self.id)
async def ban(self, *, reason: Optional[str] = None):
"""Bans the member from the server
Parameters
-----------
reason: Optional[:class:`str`]
The reason for the ban
"""
await self.state.http.ban_member(self.server.id, self.id, reason)
async def unban(self):
"""Unbans the member from the server"""
await self.state.http.unban_member(self.server.id, self.id)
+3 -3
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, NamedTuple, Optional
from .asset import Asset, PartialAsset
from .channel import Messageable
from .embed import Embed
from .embed import to_embed
if TYPE_CHECKING:
from .state import State
@@ -31,7 +31,7 @@ class Message:
The content of the message, this will not include system message's content
attachments: list[:class:`Asset`]
The attachments of the message
embeds: list[:class:`Embed`]
embeds: list[Union[:class:`WebsiteEmbed`, :class:`ImageEmbed`, :class:`TextEmbed`, :class:`NoneEmbed`]]
The embeds of the message
channel: :class:`Messageable`
The channel the message was sent in
@@ -56,7 +56,7 @@ class Message:
self.id = data["_id"]
self.content = data["content"]
self.attachments = [Asset(attachment, state) for attachment in data.get("attachments", [])]
self.embeds = [Embed.from_dict(embed) for embed in data.get("embeds", [])]
self.embeds = [to_embed(embed, state) for embed in data.get("embeds", [])]
channel = state.get_channel(data["channel"])
assert isinstance(channel, Messageable)
+75 -6
View File
@@ -2,8 +2,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from .enums import SortType
if TYPE_CHECKING:
from .embed import Embed
from .embed import Embed, SendableEmbed
from .file import File
from .message import Masquerade, Message, MessageReply
from .state import State
@@ -23,10 +25,10 @@ class Messageable:
__slots__ = ()
def _get_channel_id(self) -> str:
async 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:
async def send(self, content: Optional[str] = None, *, embeds: Optional[list[SendableEmbed]] = None, embed: Optional[SendableEmbed] = 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,8 +37,10 @@ class Messageable:
The content of the message, this will not include system message's content
attachments: Optional[list[:class:`File`]]
The attachments of the message
embeds: Optional[list[:class:`Embed`]]
The embeds of the message
embed: Optional[:class:`SendableEmbed`]
The embed to send with the message
embeds: Optional[list[:class:`SendableEmbed`]]
The embeds to send with the message
replies: Optional[list[:class:`MessageReply`]]
The list of messages to reply to.
@@ -55,5 +59,70 @@ class Messageable:
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)
message = await self.state.http.send_message(await self._get_channel_id(), content, embed_payload, attachments, reply_payload, masquerade_payload)
return self.state.add_message(message)
async def fetch_message(self, message_id: str) -> Message:
"""Fetches a message from the channel
Parameters
-----------
message_id: :class:`str`
The id of the message you want to fetch
Returns
--------
:class:`Message`
The message with the matching id
"""
payload = await self.state.http.fetch_message(await self._get_channel_id(), message_id)
return Message(payload, self.state)
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
Parameters
-----------
sort: :class:`SortType`
The order to sort the messages in
limit: :class:`int`
How many messages to fetch
before: Optional[:class:`str`]
The id of the message which should come *before* all the messages to be fetched
after: Optional[:class:`str`]
The id of the message which should come *after* all the messages to be fetched
nearby: Optional[:class:`str`]
The id of the message which should be nearby all the messages to be fetched
Returns
--------
list[:class:`Message`]
The messages found in order of the sort parameter
"""
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]
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
Parameters
-----------
query: :class:`str`
The query to search for in the channel
sort: :class:`SortType`
The order to sort the messages in
limit: :class:`int`
How many messages to fetch
before: Optional[:class:`str`]
The id of the message which should come *before* all the messages to be fetched
after: Optional[:class:`str`]
The id of the message which should come *after* all the messages to be fetched
Returns
--------
list[:class:`Message`]
The messages found in order of the sort parameter
"""
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]
+1 -2
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from .flags import Flags, flag_value
__all__ = (
"ChannelPermissions",
"ServerPermissions"
@@ -43,7 +42,7 @@ class ChannelPermissions(Flags):
@classmethod
def all(cls) -> ChannelPermissions:
return cls._from_value(0b11111011)
return cls._from_value(0b11111111)
@classmethod
def view(cls) -> ChannelPermissions:
+24 -7
View File
@@ -2,8 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from revolt.types import server
from .utils import Missing
from .permissions import ChannelPermissions, ServerPermissions
if TYPE_CHECKING:
@@ -23,7 +22,7 @@ class Role:
The id of the role
name: :class:`str`
The name of the role
colour: :class:`str`
colour: Optional[:class:`str`]
The colour of the role
hoist: :class:`bool`
Whether members with the role will display seperate from everyone else
@@ -38,13 +37,13 @@ class Role:
"""
__slots__ = ("id", "name", "colour", "hoist", "rank", "state", "server", "server_permissions", "channel_permissions")
def __init__(self, data: RolePayload, role_id: str, state: State, server: Server):
def __init__(self, data: RolePayload, role_id: str, server: Server, state: State):
self.state = state
self.id = role_id
self.name = data["name"]
self.colour = data.get("colour")
self.hoist = data.get("hoist", False)
self.rank = data.get("rank", 0)
self.colour = None
self.hoist = False
self.rank = 0
self.server = server
self.server_permissions = ServerPermissions._from_value(data["permissions"][0])
self.channel_permissions = ChannelPermissions._from_value(data["permissions"][1])
@@ -83,3 +82,21 @@ class Role:
if rank:
self.rank = rank
async def delete(self):
"""Deletes the role"""
await self.state.http.delete_role(self.server.id, self.id)
async def edit(self, **kwargs):
"""Edits the role
Parameters
-----------
"""
remove = []
if kwargs.get("colour", Missing) is None:
kwargs.pop("colour")
remove.append("Colour")
await self.state.http.edit_role(self.server.id, self.id, remove, kwargs)
+127 -6
View File
@@ -1,10 +1,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Optional, cast
from .asset import Asset
from .category import Category
from .channel import Channel
from .channel import Channel, VoiceChannel
from .invite import Invite
from .permissions import ChannelPermissions, ServerPermissions
from .role import Role
@@ -16,7 +17,7 @@ if TYPE_CHECKING:
from .types import File as FilePayload
from .types import Permission as PermissionPayload
from .types import Server as ServerPayload
from .types import SystemMessagesConfig
from .types import SystemMessagesConfig, Ban
__all__ = ("Server", "SystemMessages")
@@ -98,7 +99,7 @@ class Server:
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.system_messages = SystemMessages(data.get("system_messages", cast("SystemMessagesConfig", {})), state)
self._categories = {data["id"]: Category(data, state) for data in data.get("categories", [])}
if icon := data.get("icon"):
@@ -112,9 +113,9 @@ class Server:
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()}
self._roles: dict[str, Role] = {role_id: Role(role, role_id, self, state) for role_id, role in data.get("roles", {}).items()}
self._channels: dict[str, Channel] = {channel.id: channel for channel in self.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:
@@ -235,3 +236,123 @@ class Server:
channel_value = (channel_permissions or self.default_channel_permissions).value
await self.state.http.set_default_permissions(self.id, server_value, channel_value)
async def leave_server(self):
"""Leaves or deletes the server"""
await self.state.http.delete_leave_server(self.id)
async def delete_server(self):
"""Leaves or deletes a server, alias to :meth`Server.leave_server`"""
await self.leave_server()
async def create_text_channel(self, *, name: str, description: Optional[str] = None) -> TextChannel:
"""Creates a text channel in the server
Parameters
-----------
name: :class:`str`
The name of the channel
description: Optional[:class:`str`]
The channel's description
Returns
--------
:class:`TextChannel`
The text channel that was just created
"""
payload = await self.state.http.create_channel(self.id, "Text", name, description)
channel = TextChannel(payload, self.state)
self._channels[channel.id] = channel
return channel
async def create_voice_channel(self, *, name: str, description: Optional[str] = None) -> VoiceChannel:
"""Creates a voice channel in the server
Parameters
-----------
name: :class:`str`
The name of the channel
description: Optional[:class:`str`]
The channel's description
Returns
--------
:class:`VoiceChannel`
The voice channel that was just created
"""
payload = await self.state.http.create_channel(self.id, "Voice", name, description)
channel = VoiceChannel(payload, self.state)
self._channels[channel.id] = channel
return channel
async def fetch_invites(self) -> list[Invite]:
"""Fetches all invites in the server
Returns
--------
list[:class:`Invite`]
"""
invite_payloads = await self.state.http.fetch_server_invites(self.id)
return [Invite._from_partial(payload["_id"], payload["server"], payload["creator"], payload["channel"], self.state) for payload in invite_payloads]
async def fetch_member(self, member_id: str) -> Member:
"""Fetches a member from this server
Parameters
-----------
member_id: :class:`str`
The id of the member you are fetching
Returns
--------
:class:`Member`
The member with the matching id
"""
payload = await self.state.http.fetch_member(self.id, member_id)
return Member(payload, self, self.state)
async def fetch_bans(self) -> list[ServerBan]:
"""Fetches all invites in the server
Returns
--------
list[:class:`Invite`]
"""
payload = await self.state.http.fetch_bans(self.id)
return [ServerBan(ban, self.state) for ban in payload["bans"]]
async def create_role(self, name: str) -> Role:
"""Creates a role in the server
Parameters
-----------
name: :class:`str`
The name of the role
Returns
--------
:class:`Role`
The role that was just created
"""
payload = await self.state.http.create_role(self.id, name)
return Role(payload, name, self, self.state)
class ServerBan:
def __init__(self, ban: Ban, state: State):
self.reason = ban.get("reason")
self.server = state.get_server(ban["_id"]["server"])
self.user = state.get_user(ban["_id"]["user"])
self.state = state
async def unban(self):
"""Unbans the user"""
await self.state.http.unban_member(self.server.id, self.user.id)
+4 -4
View File
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
__all__ = (
"SavedMessages",
"DMChannel",
"Group",
"GroupDMChannel",
"TextChannel",
"VoiceChannel",
"Channel",
@@ -37,7 +37,7 @@ class _GroupOptional(TypedDict):
permissions: int
description: str
class Group(_NonceChannel, _GroupOptional, BaseChannel):
class GroupDMChannel(_NonceChannel, _GroupOptional, BaseChannel):
recipients: list[str]
name: str
owner: str
@@ -47,12 +47,12 @@ class _TextChannelOptional(TypedDict, total=False):
icon: File
default_permissions: int
role_permissions: dict[str, int]
last_message: str
class TextChannel(_NonceChannel, _TextChannelOptional, BaseChannel):
server: str
name: str
description: str
last_message: str
channel_type: Literal["TextChannel"]
class _VoiceChannelOptional(TypedDict, total=False):
@@ -66,4 +66,4 @@ class VoiceChannel(_NonceChannel, _TextChannelOptional, BaseChannel):
description: str
channel_type: Literal["VoiceChannel"]
Channel = Union[SavedMessages, DMChannel, Group, TextChannel, VoiceChannel]
Channel = Union[SavedMessages, DMChannel, GroupDMChannel, TextChannel, VoiceChannel]
+80 -4
View File
@@ -1,8 +1,84 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
from typing import TYPE_CHECKING, Literal, TypedDict, Union
from typing_extensions import NotRequired
__all__ = ("Embed",)
if TYPE_CHECKING:
from .file import File
__all__ = ("Embed", "SendableEmbed", "WebsiteEmbed", "ImageEmbed", "TextEmbed", "NoneEmbed", "YoutubeSpecial", "TwitchSpecial", "SpotifySpecial", "SoundcloudSpecial", "BandcampSpecial", "WebsiteSpecial", "JanuaryImage", "JanuaryVideo")
class YoutubeSpecial(TypedDict):
type: Literal["Youtube"]
id: str
timestamp: NotRequired[str]
class TwitchSpecial(TypedDict):
type: Literal["Twitch"]
content_type: Literal["Channel", "Video", "Clip"]
id: str
class SpotifySpecial(TypedDict):
type: Literal["Spotify"]
content_type: str
id: str
class SoundcloudSpecial(TypedDict):
type: Literal["Soundcloud"]
class BandcampSpecial(TypedDict):
type: Literal["Bandcamp"]
content_type: Literal["Album", "Track"]
id: str
WebsiteSpecial = Union[YoutubeSpecial, TwitchSpecial, SpotifySpecial, SoundcloudSpecial, BandcampSpecial]
class JanuaryImage(TypedDict):
url: str
width: int
height: int
size: Literal["Large", "Preview"]
class JanuaryVideo(TypedDict):
url: str
width: int
height: int
class WebsiteEmbed(TypedDict):
type: Literal["Website"]
url: NotRequired[str]
special: NotRequired[WebsiteSpecial]
title: NotRequired[str]
description: NotRequired[str]
image: NotRequired[JanuaryImage]
video: NotRequired[JanuaryVideo]
site_name: NotRequired[str]
icon_url: NotRequired[str]
colour: NotRequired[str]
class ImageEmbed(JanuaryImage):
type: Literal["Image"]
class TextEmbed(TypedDict):
type: Literal["Text"]
icon_url: NotRequired[str]
url: NotRequired[str]
title: NotRequired[str]
description: NotRequired[str]
media: NotRequired[File]
colour: NotRequired[str]
class NoneEmbed(TypedDict):
type: Literal["None"]
Embed = Union[WebsiteEmbed, ImageEmbed, TextEmbed, NoneEmbed]
class SendableEmbed(TypedDict):
type: Literal["Text"]
icon_url: NotRequired[str]
url: NotRequired[str]
title: NotRequired[str]
description: NotRequired[str]
media: NotRequired[str]
colour: NotRequired[str]
class Embed(TypedDict):
pass # TODO
+3 -3
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Literal, TypedDict, Union
from .channel import (Channel, DMChannel, Group, SavedMessages, TextChannel,
VoiceChannel)
from .channel import (Channel, DMChannel, GroupDMChannel, SavedMessages,
TextChannel, VoiceChannel)
from .message import Message
if TYPE_CHECKING:
@@ -69,7 +69,7 @@ class MessageDeleteEventPayload(BasePayload):
class ChannelCreateEventPayload_SavedMessages(BasePayload, SavedMessages):
pass
class ChannelCreateEventPayload_Group(BasePayload, Group):
class ChannelCreateEventPayload_Group(BasePayload, GroupDMChannel):
pass
class ChannelCreateEventPayload_TextChannel(BasePayload, TextChannel):
+23 -15
View File
@@ -1,22 +1,30 @@
from typing import Literal, TypedDict, Union
from __future__ import annotations
__all__ = (
"GroupInvite",
"ServerInvite",
"Invite",
)
from typing import TYPE_CHECKING, Literal, TypedDict
class GroupInvite(TypedDict):
type: Literal["Group"]
_id: str
creator: str
channel: str
from typing_extensions import NotRequired
class ServerInvite(TypedDict):
if TYPE_CHECKING:
from .file import File
__all__ = ("Invite", "PartialInvite")
class Invite(TypedDict):
type: Literal["Server"]
server_id: str
server_name: str
server_icon: NotRequired[str]
server_banner: NotRequired[str]
channel_id: str
channel_name: str
channel_description: NotRequired[str]
user_name: str
user_avatar: NotRequired[File]
member_count: int
class PartialInvite(TypedDict):
_id: str
server: str
creator: str
channel: str
Invite = Union[ServerInvite, GroupInvite]
creator: str
+1 -6
View File
@@ -9,11 +9,6 @@ __all__ = (
Permission = tuple[int, int]
class _RoleOptional(TypedDict, total=False):
colour: str
hoist: bool
rank: int
class Role(_RoleOptional):
class Role(TypedDict):
name: str
permissions: Permission
+6 -2
View File
@@ -27,7 +27,7 @@ class _ServerOptional(TypedDict, total=False):
description: str
categories: list[Category]
system_messages: SystemMessagesConfig
roles: list[Role]
roles: dict[str, Role]
icon: File
banner: File
nsfw: bool
@@ -46,11 +46,15 @@ class BannedUser(_OptionalBannedUser):
_id: str
username: str
class BanId(TypedDict):
server: str
user: str
class _OptionalBan(TypedDict, total=False):
reason: str
class Ban(_OptionalBan):
_id: str
_id: BanId
class ServerBans(TypedDict):
users: list[BannedUser]
+45 -2
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple, Optional, Union
from .asset import Asset, PartialAsset
from .channel import DMChannel
from .enums import PresenceType, RelationshipType
from .flags import UserBadges
from .messageable import Messageable
if TYPE_CHECKING:
from .state import State
@@ -30,7 +32,7 @@ class UserProfile(NamedTuple):
content: Optional[str]
background: Optional[Asset]
class User:
class User(Messageable):
"""Represents a user
Attributes
@@ -53,14 +55,17 @@ class User:
The relationship between the user and the bot
status: Optional[:class:`Status`]
The users status
dm_channel: Optional[:class:`DMChannel`]
The dm channel between the client and the user, this will only be set if the client has dm'ed the user or :meth:`User.open_dm` was run
"""
__flattern_attributes__ = ("id", "bot", "owner_id", "badges", "online", "flags", "relations", "relationship", "status", "masquerade_avatar", "masquerade_name", "original_name", "original_avatar", "profile")
__flattern_attributes__ = ("id", "bot", "owner_id", "badges", "online", "flags", "relations", "relationship", "status", "masquerade_avatar", "masquerade_name", "original_name", "original_avatar", "profile", "dm_channel")
__slots__ = (*__flattern_attributes__, "state")
def __init__(self, data: UserPayload, state: State):
self.state = state
self.id = data["_id"]
self.original_name = data["username"]
self.dm_channel = None
bot = data.get("bot")
if bot:
@@ -100,6 +105,13 @@ class User:
self.masquerade_avatar: Optional[PartialAsset] = None
self.masquerade_name: Optional[str] = None
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
@property
def owner(self) -> Optional[User]:
owner_id = self.owner_id
@@ -135,3 +147,34 @@ class User:
if online:
self.online = online
async def default_avatar(self) -> bytes:
"""Returns the default avatar for this user
Returns
--------
:class:`bytes`
The bytes of the image
"""
return await self.state.http.fetch_default_avatar(self.id)
async def fetch_profile(self) -> UserProfile:
"""Fetches the user's profile
Returns
--------
:class:`UserProfile`
The user's profile
"""
if profile := self.profile:
return profile
payload = await self.state.http.fetch_profile(self.id)
if file := payload.get("background"):
background = Asset(file, self.state)
else:
background = None
self.profile = UserProfile(payload.get("content"), background)
return self.profile
+4 -4
View File
@@ -1,6 +1,6 @@
import inspect
from typing import Any, Callable, Coroutine, TypeVar, Union, Iterable
from operator import attrgetter
from typing import Any, Callable, Coroutine, Iterable, TypeVar, Union
from typing_extensions import ParamSpec
@@ -18,7 +18,7 @@ def copy_doc(from_t: T) -> Callable[[T], T]:
def inner(to_t: T) -> T:
to_t.__doc__ = from_t.__doc__
return to_t
return inner
R_T = TypeVar("R_T")
@@ -38,13 +38,13 @@ async def maybe_coroutine(func: Callable[P, Union[R_T, Coroutine[Any, Any, R_T]]
def get(iterable: Iterable[T], **attrs: Any) -> T:
"""A convenaince function to help get a value from an iterable with a specific attribute
"""A convenience function to help get a value from an iterable with a specific attribute
Examples
---------
.. code-block:: python
:emphasize-lines: 4
:emphasize-lines: 3
from revolt import utils
+15 -9
View File
@@ -6,9 +6,6 @@ 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,
ServerDeleteEventPayload, ServerMemberJoinEventPayload,
@@ -16,8 +13,11 @@ from .types import (MessageDeleteEventPayload, MessageUpdateEventPayload,
ServerMemberUpdateEventPayload,
ServerRoleDeleteEventPayload, ServerRoleUpdateEventPayload,
ServerUpdateEventPayload, UserRelationshipEventPayload,
UserUpdateEventPayload)
from .user import Status
UserUpdateEventPayload, ChannelCreateEventPayload, ChannelDeleteEventPayload,
ChannelDeleteTypingEventPayload,
ChannelStartTypingEventPayload, ChannelUpdateEventPayload)
from .user import Status, UserProfile
from .channel import TextChannel, GroupDMChannel, VoiceChannel
try:
import ujson as json
@@ -163,10 +163,12 @@ class WebsocketHandler:
if clear := payload.get("clear"):
if clear == "Icon":
pass # TODO
if isinstance(channel, (TextChannel, VoiceChannel, GroupDMChannel)):
channel.icon = None
elif clear == "Description":
channel.description = None # type: ignore
if isinstance(channel, (TextChannel, VoiceChannel, GroupDMChannel)):
channel.description = None
self.dispatch("channel_update", old_channel, channel)
@@ -263,9 +265,13 @@ class WebsocketHandler:
if clear := payload.get("clear"):
if clear == "ProfileContent":
...
if profile := user.profile:
user.profile = UserProfile(None, profile.background)
elif clear == "ProfileBackground":
...
if profile := user.profile:
user.profile = UserProfile(profile.content, None)
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
+7 -7
View File
@@ -5,16 +5,12 @@ from setuptools import find_packages, setup
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()
requirements = (here / "requirements.txt").read_text(encoding="utf-8").splitlines()
setup(
name="revolt.py",
version="0.1.2",
version="0.1.6",
description="Python wrapper for the revolt.chat API",
long_description=long_description,
long_description_content_type="text/markdown",
@@ -37,7 +33,11 @@ setup(
packages=find_packages(),
python_requires=">=3.9",
extras_require={
"speedups": ["ujson", "aiohttp[speedups]==3.7.4.post0", "msgpack==1.0.2"],
"speedups": [
"ujson",
"aiohttp[speedups]==3.7.4.post0",
"msgpack==1.0.2"
],
},
install_requires=requirements,
project_urls={