diff --git a/.gitignore b/.gitignore index 47d499e..212ecb6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .venv **/__pycache__ test.py +dist +*.egg-info diff --git a/revolt/__init__.py b/revolt/__init__.py index 33ae779..89bb12d 100644 --- a/revolt/__init__.py +++ b/revolt/__init__.py @@ -2,3 +2,4 @@ from .channel import TextChannel, VoiceChannel from .client import Client from .server import Server from .user import User +from .message import Message diff --git a/revolt/asset.py b/revolt/asset.py new file mode 100644 index 0000000..4f9bd14 --- /dev/null +++ b/revolt/asset.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Union + +if TYPE_CHECKING: + from .payloads import File as FilePayload + from .state import State + from io import IOBase +class Asset: + def __init__(self, data: FilePayload, state: State): + self.id = data['_id'] + self.tag = data['tag'] + self.size = data['size'] + self.filename = data['filename'] + + metadata = data['metadata'] + + if metadata["type"] == "Image" or metadata["type"] == "Video": # cant use `in` because type narrowing wont happen + self.height = metadata["height"] + self.width = metadata["width"] + else: + self.height = None + self.width = None + + self.content_type = data["content_type"] + + async def read(self) -> bytes: + ... + + async def save(self, fp: IOBase): + fp.write(await self.read()) diff --git a/revolt/channel.py b/revolt/channel.py index f4a0eb7..d27cdee 100644 --- a/revolt/channel.py +++ b/revolt/channel.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Text, Union +from .messageable import Messageable + +from typing import TYPE_CHECKING if TYPE_CHECKING: from .payloads import Channel as ChannelPayload @@ -11,30 +13,32 @@ class Channel: self.state = state self.id = data["_id"] self.channel_type = data["channel_type"] + self.server = None -class SavedMessageChannel(Channel): +class SavedMessageChannel(Channel, Messageable): def __init__(self, data: ChannelPayload, state: State): super().__init__(data, state) -class DMChannel(Channel): +class DMChannel(Channel, Messageable): def __init__(self, data: ChannelPayload, state: State): super().__init__(data, state) -class GroupDMChannel(Channel): +class GroupDMChannel(Channel, Messageable): def __init__(self, data: ChannelPayload, state: State): super().__init__(data, state) -class TextChannel(Channel): +class TextChannel(Channel, Messageable): def __init__(self, data: ChannelPayload, state: State): super().__init__(data, state) + Messageable.__init__(self, state) class VoiceChannel(Channel): def __init__(self, data: ChannelPayload, state: State): super().__init__(data, state) def channel_factory(data: ChannelPayload) -> type[Channel]: - # Literal["SavedMessage", "DirectMessage", "Group", "TextChannel", "VoiceChannel"] channel_type = data["channel_type"] + if channel_type == "SavedMessage": return SavedMessageChannel elif channel_type == "DirectMessage": diff --git a/revolt/client.py b/revolt/client.py index e13d74e..1456da1 100644 --- a/revolt/client.py +++ b/revolt/client.py @@ -26,11 +26,11 @@ R = TypeVar("R") Coro = Coroutine[Any, Any, R] class Client: - def __init__(self, session: aiohttp.ClientSession, token: str, api_url: str = "https://api.revolt.chat"): + def __init__(self, session: aiohttp.ClientSession, token: str, api_url: str = "https://api.revolt.chat", max_messages: int = 5000): self.session = session self.token = token self.api_url = api_url - self.events: dict[str, list[Callable[..., Coro[None]]]] = {} + self.max_messages = max_messages self.api_info: ApiInfo self.http: HttpClient @@ -39,22 +39,27 @@ class Client: def event(self, name: str = None): def inner(func: Callable[..., Coro[None]]): - event_name = name or func.__name__ - self.events.setdefault(event_name, []).append(func) + event_name = "on_{name}" if name else func.__name__ + setattr(self, event_name, func) return func return inner def dispatch(self, event: str, *args: Any): - ... + func = getattr(self, "on_{event}", None) + if func: + asyncio.create_task(func(*args)) + + async def get_api_info(self) -> ApiInfo: + async with self.session.get(self.api_url) as resp: + return json.loads(await resp.text()) async def start(self): - async with self.session.get(self.api_url) as resp: - api_info: ApiInfo = json.loads(await resp.text()) + api_info = await self.get_api_info() self.api_info = api_info self.http = HttpClient(self.session, self.token, self.api_url, self.api_info) - self.state = State(self.http, api_info) + self.state = State(self.http, api_info, self.max_messages) self.websocket = WebsocketHandler(self.session, self.token, api_info["ws"], self.dispatch, self.state) await self.websocket.start() diff --git a/revolt/embed.py b/revolt/embed.py new file mode 100644 index 0000000..2d83f64 --- /dev/null +++ b/revolt/embed.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .payloads import Embed as EmbedPayload + +class Embed: + @classmethod + def from_dict(cls, data: EmbedPayload): + ... + + def to_dict(self) -> EmbedPayload: + ... diff --git a/revolt/errors.py b/revolt/errors.py new file mode 100644 index 0000000..5594211 --- /dev/null +++ b/revolt/errors.py @@ -0,0 +1,8 @@ +class RevoltError(Exception): + "Base exception for revolt" + +class HTTPError(RevoltError): + "Base exception for http errors" + +class ServerError(RevoltError): + "Internal server error" diff --git a/revolt/file.py b/revolt/file.py new file mode 100644 index 0000000..12cef62 --- /dev/null +++ b/revolt/file.py @@ -0,0 +1,24 @@ +from typing import Optional, Union + +import io +import os + +class File: + def __init__(self, file: Union[str, bytes], *, filename: Optional[str] = None, spoiler: bool = False): + if isinstance(file, str): + self.f = open(file, "rb") + elif isinstance(file, bytes): + self.f = io.BytesIO(file) + + if filename is None and isinstance(file, str): + filename = self.f.name + + if spoiler or (filename and filename.startswith("SPOILER_")): + self.spoiler = True + else: + self.spoiler = False + + if self.spoiler and (filename and not filename.startswith("SPOILER_")): + filename = f"SPOILER_{filename}" + + self.filename = filename diff --git a/revolt/http.py b/revolt/http.py index 0775e96..5fa5fa8 100644 --- a/revolt/http.py +++ b/revolt/http.py @@ -1,14 +1,92 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING, Literal +import aiohttp + +from .errors import HTTPError, ServerError +from .file import File + +try: + import ujson as _json +except ImportError: + import json as _json if TYPE_CHECKING: import aiohttp - from .payloads import ApiInfo + from .payloads import ApiInfo, Autumn as AutumnPayload, Message as MessagePayload, Embed as EmbedPayload + from .file import File class HttpClient: def __init__(self, session: aiohttp.ClientSession, token: str, api_url: str, api_info: ApiInfo): self.session = session self.token = token + self.api_url = api_url self.api_info = api_info + async def request(self, method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"], route: str, *, files: Optional[list[File]] = None, json: Optional[dict] = None) -> Any: + url = f"{self.api_url}{route}" + + kwargs = {} + + headers = { + "User-Agent": "Revolt.py https://github.com/Zomatree/revolt.py", + "x-bot-token": self.token + } + + if json: + headers["Content-Type"] = "application/json" + kwargs["data"] = _json.dumps(json) + + kwargs["headers"] = headers + + async with self.session.request(method, url, **kwargs) as resp: + response = _json.loads(await resp.text()) + + resp_code = resp.status + + if 200 <= resp_code <= 300: + return response + elif resp_code == 400: + raise HTTPError + + 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" + } + + form = aiohttp.FormData() + form.add_field("file", file.f.read(), filename=file.filename) + + async with self.session.post(url, data=form, headers=headers) as resp: + response: AutumnPayload = _json.loads(await resp.text()) + + resp_code = resp.status + + if resp_code == 400: + raise HTTPError(response) + elif 500 <= resp_code <= 600: + raise ServerError + else: + return response + + async def send_message(self, channel: str, content: Optional[str], embeds: Optional[list[EmbedPayload]], attachments: Optional[list[File]]) -> MessagePayload: + json = {} + + if content: + json["content"] = content + + if embeds: + json["embeds"] = embeds + + if attachments: + attachment_ids = [] + + for attachment in attachments: + data = await self.upload_file(attachment, "attachments") + attachment_ids.append(data["id"]) + + json["attachments"] = attachment_ids + + return await self.request("POST", "/channels/{channel}/messages", json=json) diff --git a/revolt/member.py b/revolt/member.py new file mode 100644 index 0000000..f12efe8 --- /dev/null +++ b/revolt/member.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .state import State + from .payloads import Member as MemberPayload + from .server import Server + +class Member: + def __init__(self, data: MemberPayload, server: Server, state: State): + self._state = state + self.id = data["_id"] + self.nickname = data.get("nickname") + self.roles = [server.get_role(role_id) for role_id in data.get("roles", [])] + + self.server = server diff --git a/revolt/message.py b/revolt/message.py new file mode 100644 index 0000000..0a6c110 --- /dev/null +++ b/revolt/message.py @@ -0,0 +1,29 @@ +from __future__ import annotations + + +from typing import TYPE_CHECKING + +from .asset import Asset +from .embed import Embed +from .channel import TextChannel + +if TYPE_CHECKING: + from .state import State + from .payloads import Message as MessagePayload + +class Message: + 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', [])] + self.embeds = [Embed.from_dict(embed) for embed in data["embeds"]] + + self.channel = state.get_channel(data['channel']) + self.server = self.channel and self.channel.server + + if isinstance(self.channel, TextChannel): + self.author = state.get_member(self.id, data['author']) + else: + self.author = state.get_user(data['author']) diff --git a/revolt/messageable.py b/revolt/messageable.py new file mode 100644 index 0000000..b9f8431 --- /dev/null +++ b/revolt/messageable.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + + +if TYPE_CHECKING: + from .state import State + from .message import Message + from .embed import Embed + from .file import File + +class Messageable: + id: str + + def __init__(self, state: State): + self.state = state + + async def send(self, content: Optional[str] = None, embeds: Optional[list[Embed]] = None, embed: Optional[Embed] = None, attachments: Optional[list[File]] = None) -> Message: + if embed: + embeds = [embed] + + embed_payload = [embed.to_dict() for embed in embeds] if embeds else None + + message = await self.state.http.send_message(self.id, content, embed_payload, attachments) + return self.state.add_message(message) diff --git a/revolt/payloads.py b/revolt/payloads.py index 91a0ad5..09aa979 100644 --- a/revolt/payloads.py +++ b/revolt/payloads.py @@ -1,8 +1,23 @@ -from typing import Literal, TypedDict, Union +from typing import Literal, Type, TypedDict, Union + +class ApiFeature(TypedDict): + enabled: bool + url: str + +class VosoFeature(ApiFeature): + ws: str + +class Features(TypedDict): + email: bool + invite_only: bool + captcha: ApiFeature + autumn: ApiFeature + january: ApiFeature + voso: VosoFeature class ApiInfo(TypedDict): revolt: str - features: dict + features: Features ws: str app: str vapid: str @@ -16,12 +31,15 @@ class AuthenticatePayload(BasePayload): class UserBot(TypedDict): owner: str -class _FileMetadataOptional(TypedDict, total=False): - width: int +class SizedMetadata(TypedDict): + type: Literal["Image", "Video"] height: int + width: int -class FileMetadata(_FileMetadataOptional): - type: Literal["File", "Text", "Audio", "Image", "Video"] +class SimpleMetadata(TypedDict): + type: Literal["File", "Text", "Audio"] + +FileMetadata = Union[SizedMetadata, SimpleMetadata] class File(TypedDict): _id: str @@ -31,11 +49,15 @@ class File(TypedDict): metadata: FileMetadata content_type: str +class Status(TypedDict, total=False): + text: str + presence: Literal["Busy", "Idle", "Invisible", "Online"] + class _OptionalUser(TypedDict, total=False): avatar: File relations: list badges: int - status: ... + status: Status relationship: Literal["Blocked", "BlockedOther", "Friend", "Incoming", "None", "Outgoing", "User"] online: bool flags: int @@ -82,31 +104,94 @@ class ChannelIconChanged(TypedDict): class _OptionalMessage(TypedDict): attachments: list[File] +class Embed(TypedDict): + pass # TODO + class Message(_OptionalMessage): _id: str channel: str author: str content: Union[str, UserAddContent, UserRemoveContent, UserJoinedContent, UserLeftContent, UserKickedContent, UserBanned, ChannelRenameContent, ChannelDescriptionChangeContent, ChannelIconChanged] + embeds: list[Embed] -class _OptionalChannel(TypedDict, total=False): - user: str +class _NonceChannel(TypedDict, total=False): nonce: str - active: str + +class BaseChannel(TypedDict): + _id: str + +class SavedMessages(_NonceChannel, BaseChannel): + user: str + channel_type: Literal["SavedMessage"] + +class DirectMessage(_NonceChannel, BaseChannel): + active: bool recipients: list[str] last_message: Message + channel_type: Literal["DirectMessage"] + +class _GroupOptional(TypedDict): + icon: File + permissions: int + description: str + +class Group(_NonceChannel, _GroupOptional, BaseChannel): + recipients: list[str] name: str owner: str - description: str + channel_type: Literal["Group"] + +class _TextChannelOptional(TypedDict, total=False): icon: File + default_permissions: int + role_permissions: int -class Channel(_OptionalChannel): - _id: str - channel_type: Literal["SavedMessage", "DirectMessage", "Group", "TextChannel", "VoiceChannel"] +class TextChannel(_NonceChannel, _TextChannelOptional, BaseChannel): + server: str + name: str + description: str + last_message: Message + channel_type: Literal["TextChannel"] -class ReadyPayload(BasePayload): +class _VoiceChannelOptional(TypedDict, total=False): + icon: File + default_permissions: int + role_permissions: int + +class VoiceChannel(_NonceChannel, _TextChannelOptional, BaseChannel): + server: str + name: str + description: str + channel_type: Literal["VoiceChannel"] + +Channel = Union[SavedMessages, DirectMessage, Group, TextChannel, VoiceChannel] + +class ReadyEventPayload(BasePayload): users: list[User] servers: list[Server] channels: list[Channel] -class MessageEventPayload(BasePayload): +class MessageEventPayload(BasePayload, Message): pass + +class Autumn(TypedDict): + id: str + +class _MemberOptional(TypedDict, total=False): + nickname: str + avatar: File + roles: list[str] + +class Member(_MemberOptional): + _id: str + +Permission = tuple[int, int] + +class _RoleOptional(TypedDict, total=False): + colour: str + hoist: bool + rank: int + +class Role(_RoleOptional): + name: str + permissions: Permission diff --git a/revolt/permissions.py b/revolt/permissions.py new file mode 100644 index 0000000..81fd481 --- /dev/null +++ b/revolt/permissions.py @@ -0,0 +1,8 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .payloads import Permission as PermissionTuple + +class Permissions: + def __init__(self, permission_tuple: PermissionTuple): + ... diff --git a/revolt/role.py b/revolt/role.py new file mode 100644 index 0000000..9cd5abe --- /dev/null +++ b/revolt/role.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from .state import State + from .payloads import Role as RolePayload + +class Role: + def __init__(self, data: RolePayload, role_id: str, 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) diff --git a/revolt/server.py b/revolt/server.py index 51cf869..f5f987f 100644 --- a/revolt/server.py +++ b/revolt/server.py @@ -1,13 +1,25 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from .payloads import Server as ServerPayload from .state import State - + from .member import Member + from .role import Role + from .channel import Channel class Server: def __init__(self, data: ServerPayload, state: State): self.state = state self.id = data["_id"] + + self._members: dict[str, Member] = {} + self._roles: dict[str, Role] = {} + self._channels: dict[str, Channel] = {} + + def get_role(self, role_id: str, /) -> Optional[Role]: + return self._roles.get(role_id) + + def get_member(self, member_id: str, /) -> Optional[Member]: + return self._members.get(member_id) diff --git a/revolt/state.py b/revolt/state.py index 3dc7451..2aa7efb 100644 --- a/revolt/state.py +++ b/revolt/state.py @@ -1,38 +1,61 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional +from collections import deque from .user import User from .channel import Channel, channel_factory from .server import Server +from .message import Message +from .member import Member if TYPE_CHECKING: from .http import HttpClient - from .payloads import ApiInfo, User as UserPayload, Channel as ChannelPayload, Server as ServerPayload + from .payloads import ApiInfo, User as UserPayload, Channel as ChannelPayload, Server as ServerPayload, Message as MessagePayload, Member as MemberPayload class State: - def __init__(self, http: HttpClient, api_info: ApiInfo): + def __init__(self, http: HttpClient, api_info: ApiInfo, max_messages: int): self.http = http self.api_info = api_info + self.max_messages = max_messages self.users: dict[str, User] = {} self.channels: dict[str, Channel] = {} self.servers: dict[str, Server] = {} + self.messages: deque[Message] = deque() def get_user(self, id: str) -> Optional[User]: - self.users.get(id) + return self.users.get(id) + + def get_member(self, server_id: str, member_id: str) -> Optional[Member]: + server = self.servers.get(server_id) + + if not server: + return + + return server.get_member(member_id) def get_channel(self, id: str) -> Optional[Channel]: - self.channels.get(id) + return self.channels.get(id) def get_server(self, id: str) -> Optional[Server]: - self.channels.get(id) + return self.servers.get(id) def add_user(self, payload: UserPayload) -> User: user = User(payload, self) self.users[user.id] = user return user + def add_member(self, server_id: str, payload: MemberPayload) -> Optional[Member]: + server = self.get_server(server_id) + if not server: + return + + member = Member(payload, server, self) + + server._members[member.id] = member + return member + def add_channel(self, payload: ChannelPayload) -> Channel: cls = channel_factory(payload) channel = cls(payload, self) @@ -43,3 +66,11 @@ class State: server = Server(payload, self) self.servers[server.id] = server return server + + def add_message(self, payload: MessagePayload) -> Message: + message = Message(payload, self) + if len(self.messages) >= self.max_messages: + self.messages.pop() + + self.messages.appendleft(message) + return message diff --git a/revolt/utils.py b/revolt/utils.py new file mode 100644 index 0000000..3f5a036 --- /dev/null +++ b/revolt/utils.py @@ -0,0 +1,5 @@ +class _Missing: + def __repr__(self): + return "" + +Missing = _Missing() diff --git a/revolt/websocket.py b/revolt/websocket.py index 63ccb62..8eba09f 100644 --- a/revolt/websocket.py +++ b/revolt/websocket.py @@ -1,6 +1,7 @@ from __future__ import annotations +from revolt.payloads import MessageEventPayload -from typing import Callable, TYPE_CHECKING +from typing import Callable, TYPE_CHECKING, Coroutine, cast import logging import asyncio @@ -11,13 +12,13 @@ except ImportError: if TYPE_CHECKING: import aiohttp - from .payloads import BasePayload, AuthenticatePayload, ReadyPayload + from .payloads import BasePayload, AuthenticatePayload, ReadyEventPayload, Message as MessagePayload from .state import State logger = logging.getLogger("revolt") class WebsocketHandler: - def __init__(self, session: aiohttp.ClientSession, token: str, ws_url: str, dispatch: Callable, state: State): + def __init__(self, session: aiohttp.ClientSession, token: str, ws_url: str, dispatch: Callable[..., None], state: State): self.session = session self.token = token self.ws_url = ws_url @@ -52,16 +53,25 @@ class WebsocketHandler: await func(payload) - async def handle_ready(self, payload: ReadyPayload): + async def handle_authenticated(self, _): + logger.info("Successfully authenticated") + + async def handle_ready(self, payload: ReadyEventPayload): for user in payload["users"]: self.state.add_user(user) - + for server in payload["servers"]: self.state.add_server(server) for channel in payload["channels"]: self.state.add_channel(channel) + self.dispatch("ready") + + async def handle_message(self, payload: MessageEventPayload): + message = self.state.add_message(cast(MessagePayload, payload)) + self.dispatch("message", message) + async def start(self): self.websocket = await self.session.ws_connect(self.ws_url)