from __future__ import annotations import asyncio import logging from typing import TYPE_CHECKING, Any, Callable, Literal, 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 .utils import Missing, Ulid from .websocket import WebsocketHandler try: import ujson as json except ImportError: import json if TYPE_CHECKING: from .channel import Channel from .emoji import Emoji from .file import File from .server import Server from .types import ApiInfo from .user import User __all__ = ("Client",) logger = logging.getLogger("revolt") class Client: """The client for interacting with revolt Parameters ----------- session: :class:`aiohttp.ClientSession` The aiohttp session to use for http request and the websocket token: :class:`str` The bots token api_url: :class:`str` The api url for the revolt instance you are connecting to, by default it uses the offical instance hosted at revolt.chat max_messages: :class:`int` The max amount of messages stored in the cache, by default this is 5k """ def __init__(self, session: aiohttp.ClientSession, token: str, *, api_url: str = "https://api.revolt.chat", max_messages: int = 5000, bot: bool = True): self.session = session self.token = token self.api_url = api_url self.max_messages = max_messages self.bot = bot self.api_info: ApiInfo self.http: HttpClient self.state: State self.websocket: WebsocketHandler self.listeners: dict[str, list[tuple[Callable[..., bool], asyncio.Future[Any]]]] = {} super().__init__() def dispatch(self, event: str, *args: Any): """Dispatch an event, this is typically used for testing and internals. Parameters ---------- event: class:`str` The name of the event to dispatch, not including `on_` args: :class:`Any` The arguments passed to the event """ for check, future in self.listeners.pop(event, []): if check(*args): if len(args) == 1: future.set_result(args[0]) else: future.set_result(args) func = getattr(self, f"on_{event}", None) if func: 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): """Starts the client""" 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.bot) self.state = State(self.http, api_info, self.max_messages) self.websocket = WebsocketHandler(self.session, self.token, api_info["ws"], self.dispatch, self.state) await self.websocket.start() async def stop(self): await self.websocket.websocket.close() def get_user(self, id: str) -> User: """Gets a user from the cache Parameters ----------- id: :class:`str` The id of the user Returns -------- :class:`User` The user """ return self.state.get_user(id) def get_channel(self, id: str) -> Channel: """Gets a channel from the cache Parameters ----------- id: :class:`str` The id of the channel Returns -------- :class:`Channel` The channel """ return self.state.get_channel(id) def get_server(self, id: str) -> Server: """Gets a server from the cache Parameters ----------- id: :class:`str` The id of the server Returns -------- :class:`Server` The server """ return self.state.get_server(id) async def wait_for(self, event: str, *, check: Optional[Callable[..., bool]] = None, timeout: Optional[float] = None) -> Any: """Waits for an event Parameters ----------- event: :class:`str` The name of the event to wait for, without the `on_` check: Optional[Callable[..., :class:`bool`]] A function that says what event to wait_for, this function takes the same parameters as the event you are waiting for and should return a bool saying if that is the event you want timeout: Optional[:class:`float`] Time in seconds to wait for the event. By default it waits forever Raises ------- asyncio.TimeoutError If timeout is provided and it was reached Returns -------- Any The parameters of the event """ if not check: check = lambda *_: True future = asyncio.get_running_loop().create_future() self.listeners.setdefault(event, []).append((check, future)) return await asyncio.wait_for(future, timeout) @property def user(self) -> User: """:class:`User` the user corrasponding to the client""" user = self.websocket.user assert user return user @property def users(self) -> list[User]: """list[:class:`User`] All users the client can see""" return list(self.state.users.values()) @property def servers(self) -> list[Server]: """list[:class:'Server'] All servers the client can see""" return list(self.state.servers.values()) @property def global_emojis(self) -> list[Emoji]: return self.state.global_emojis 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 async def edit_self(self, **kwargs: Any): """Edits the client's own user Parameters ----------- avatar: Optional[:class:`File`] The avatar to change to, passing in ``None`` will remove the avatar """ if kwargs.get("avatar", Missing) is None: del kwargs["avatar"] remove = ["Avatar"] else: remove = None await self.state.http.edit_self(remove, kwargs) async def edit_status(self, **kwargs: Any): """Edits the client's own status Parameters ----------- presence: :class:`PresenceType` The presence to change to text: Optional[:class:`str`] The text to change the status to, passing in ``None`` will remove the status """ if kwargs.get("text", Missing) is None: del kwargs["text"] remove = ["StatusText"] else: remove = None if presence := kwargs.get("presence"): kwargs["presence"] = presence.value await self.state.http.edit_self(remove, {"status": kwargs}) async def edit_profile(self, **kwargs: Any): """Edits the client's own profile Parameters ----------- content: Optional[:class:`str`] The new content for the profile, passing in ``None`` will remove the profile content background: Optional[:class:`File`] The new background for the profile, passing in ``None`` will remove the profile background """ remove = [] if kwargs.get("content", Missing) is None: del kwargs["content"] remove.append("ProfileContent") if kwargs.get("background", Missing) is None: del kwargs["background"] remove.append("ProfileBackground") await self.state.http.edit_self(remove, {"profile": kwargs}) async def fetch_emoji(self, emoji_id: str) -> Emoji: """Fetches an emoji Parameters ----------- emoji_id: str The id of the emoji Returns -------- :class:`Emoji` The emoji with the corrasponding id """ emoji = await self.state.http.fetch_emoji(emoji_id) return Emoji(emoji, self.state) async def upload_file(self, file: File, tag: Literal['attachments', 'avatars', 'backgrounds', 'icons', 'banners', 'emojis']) -> Ulid: """Uploads a file to revolt Parameters ----------- file: :class:`File` The file to upload tag: :class:`str` The type of file to upload, this should a string of either `'attachments'`, `'avatars'`, `'backgrounds'`, `'icons'`, `'banners'` or `'emojis'` Returns -------- :class:`Ulid` The id of the file that was uploaded """ asset = await self.http.upload_file(file, tag) ulid = Ulid() ulid.id = asset["id"] return ulid