diff --git a/pyproject.toml b/pyproject.toml index 8fb58d6..5025273 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ docs = [ "sphinx-toolbox==3.2.*", "setuptools==65.4.*" ] +voice = [ + "livekit==0.11.1" +] [project.urls] Homepage = "https://github.com/revoltchat/revolt.py" diff --git a/revolt/__init__.py b/revolt/__init__.py index 3a66c24..f189fc5 100755 --- a/revolt/__init__.py +++ b/revolt/__init__.py @@ -18,5 +18,6 @@ from .permissions import * from .role import * from .server import * from .user import * +from .voice import * __version__ = "0.2.0" diff --git a/revolt/channel.py b/revolt/channel.py index 06459ca..95963e5 100755 --- a/revolt/channel.py +++ b/revolt/channel.py @@ -2,11 +2,14 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Optional, Union + from .asset import Asset from .enums import ChannelType from .messageable import Messageable from .permissions import Permissions, PermissionsOverwrite from .utils import Missing, Ulid +from .voice import VoiceInformation, VoiceState +from .errors import NotAVoiceChannel if TYPE_CHECKING: from .message import Message @@ -65,6 +68,24 @@ class EditableChannel: await self.state.http.edit_channel(self.id, remove, kwargs) +class Joinable: + __slots__ = () + + state: State + id: str + + async def join(self) -> VoiceState: + if isinstance(self, TextChannel): + if self.voice is None: + raise NotAVoiceChannel() + + token = await self.state.http.join_voice_channel(self.id) + + state = VoiceState(self.state, token["token"]) + await state.connect() + + return state + class Channel(Ulid): """Base class for all channels @@ -337,7 +358,7 @@ class ServerChannel(Channel): if default_permissions is not None: self.default_permissions = PermissionsOverwrite._from_overwrite(default_permissions) -class TextChannel(ServerChannel, Messageable, EditableChannel): +class TextChannel(ServerChannel, Messageable, EditableChannel, Joinable): """A text channel Subclasses :class:`ServerChannel` and :class:`Messageable` @@ -366,6 +387,10 @@ class TextChannel(ServerChannel, Messageable, EditableChannel): super().__init__(data, state) self.last_message_id: str | None = data.get("last_message_id") + self.voice: VoiceInformation | None = None + + if voice := data.get("voice"): + self.voice = VoiceInformation(voice) async def _get_channel_id(self) -> str: return self.id @@ -384,7 +409,7 @@ class TextChannel(ServerChannel, Messageable, EditableChannel): return self.state.get_message(self.last_message_id) -class VoiceChannel(ServerChannel, EditableChannel): +class VoiceChannel(ServerChannel, EditableChannel, Joinable): """A voice channel Subclasses :class:`ServerChannel` diff --git a/revolt/client.py b/revolt/client.py index 6870ff8..15ec01b 100755 --- a/revolt/client.py +++ b/revolt/client.py @@ -107,8 +107,7 @@ class Client: except: raise RevoltError(f"Cant fetch api info:\n{text}") - async def start(self, *, reconnect: bool = True) -> None: - """Starts the client""" + async def connect(self): api_info = await self.get_api_info() self.api_info = api_info @@ -116,6 +115,10 @@ class Client: self.state = State(self.http, api_info, self.max_messages) self.websocket = WebsocketHandler(self.session, self.token, api_info["ws"], self.dispatch, self.state) + async def start(self, *, reconnect: bool = True) -> None: + """Starts the client""" + + await self.connect() await self.websocket.start(reconnect) async def stop(self) -> None: diff --git a/revolt/errors.py b/revolt/errors.py index 1e0c755..4935aec 100755 --- a/revolt/errors.py +++ b/revolt/errors.py @@ -24,3 +24,6 @@ class AutumnDisabled(FeatureDisabled): class Forbidden(HTTPError): "Missing permissions" + +class NotAVoiceChannel(RevoltError): + "Channel is not a voice channel" \ No newline at end of file diff --git a/revolt/http.py b/revolt/http.py index 37d0695..80b4159 100755 --- a/revolt/http.py +++ b/revolt/http.py @@ -29,7 +29,10 @@ if TYPE_CHECKING: from .types import SendableEmbed as SendableEmbedPayload from .types import User as UserPayload from .types import (Server, ServerBans, TextChannel, UserProfile, VoiceChannel, Member, Invite, ApiInfo, Channel, SavedMessages, - DMChannel, EmojiParent, GetServerMembers, GroupDMChannel, MessageReplyPayload, MessageWithUserData, PartialInvite, CreateRole) + DMChannel, EmojiParent, GetServerMembers, GroupDMChannel, MessageReplyPayload, MessageWithUserData, PartialInvite, + CreateRole, JoinVoiceChannelPayload) + + from aiohttp.client import _RequestOptions __all__ = ("HttpClient",) @@ -49,7 +52,7 @@ class HttpClient: async def request(self, method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"], route: str, *, json: Optional[dict[str, Any]] = None, nonce: bool = True, params: Optional[dict[str, Any]] = None) -> Any: url = f"{self.api_url}{route}" - kwargs = {} + kwargs: _RequestOptions = {} headers = { "User-Agent": "Revolt.py (https://github.com/revoltchat/revolt.py)", @@ -430,3 +433,6 @@ class HttpClient: def delete_messages(self, channel_id: str, messages: list[str]) -> Request[None]: return self.request("DELETE", f"/channels/{channel_id}/messages/bulk", json={"ids": messages}) + + def join_voice_channel(self, channel_id: str) -> Request[JoinVoiceChannelPayload]: + return self.request("POST", f"/channels/{channel_id}/join_call") \ No newline at end of file diff --git a/revolt/types/__init__.py b/revolt/types/__init__.py index b8ac014..14fc698 100755 --- a/revolt/types/__init__.py +++ b/revolt/types/__init__.py @@ -12,3 +12,4 @@ from .permissions import * from .role import * from .server import * from .user import * +from .voice import * \ No newline at end of file diff --git a/revolt/types/channel.py b/revolt/types/channel.py index 1b330bc..8f5d3e9 100755 --- a/revolt/types/channel.py +++ b/revolt/types/channel.py @@ -4,9 +4,11 @@ from typing import TYPE_CHECKING, Literal, TypedDict, Union from typing_extensions import NotRequired + if TYPE_CHECKING: from .file import File from .permissions import Overwrite + from .voice import VoiceInformation __all__ = ( "SavedMessages", @@ -53,6 +55,8 @@ class TextChannel(BaseChannel): role_permissions: NotRequired[dict[str, Overwrite]] nsfw: NotRequired[bool] last_message_id: NotRequired[str] + voice: NotRequired[VoiceInformation] + class VoiceChannel(BaseChannel): server: str diff --git a/revolt/types/gateway.py b/revolt/types/gateway.py index ff622ad..0e5fe6d 100755 --- a/revolt/types/gateway.py +++ b/revolt/types/gateway.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from .member import Member, MemberID from .server import Server, SystemMessagesConfig from .user import Status, User, UserProfile, UserRelation + from .voice import ChannelVoiceState __all__ = ( "BasePayload", @@ -58,6 +59,7 @@ class ReadyEventPayload(BasePayload): channels: list[Channel] members: list[Member] emojis: list[Emoji] + voice_states: list[ChannelVoiceState] class MessageEventPayload(BasePayload, Message): pass diff --git a/revolt/types/http.py b/revolt/types/http.py index 0cdb5de..51e33ba 100755 --- a/revolt/types/http.py +++ b/revolt/types/http.py @@ -11,12 +11,12 @@ if TYPE_CHECKING: __all__ = ( - "VosoFeature", "ApiInfo", "Autumn", "GetServerMembers", "MessageWithUserData", "CreateRole", + "JoinVoiceChannelPayload" ) @@ -24,16 +24,14 @@ 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 + livekit: ApiFeature + class ApiInfo(TypedDict): revolt: str @@ -56,4 +54,7 @@ class MessageWithUserData(TypedDict): class CreateRole(TypedDict): id: str - role: Role \ No newline at end of file + role: Role + +class JoinVoiceChannelPayload(TypedDict): + token: str \ No newline at end of file diff --git a/revolt/types/voice.py b/revolt/types/voice.py new file mode 100644 index 0000000..51a1f8d --- /dev/null +++ b/revolt/types/voice.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import TypedDict + +from typing_extensions import NotRequired + +__all__ = ( + "VoiceInformation", + "ChannelVoiceState" +) + +class VoiceInformation(TypedDict): + max_users: NotRequired[int] + +class ChannelVoiceState(TypedDict): + id: str + participants: list[UserVoiceState] + +class UserVoiceState(TypedDict): + id: str + can_receive: bool + can_publish: bool + screensharing: bool + camera: bool \ No newline at end of file diff --git a/revolt/voice.py b/revolt/voice.py new file mode 100644 index 0000000..25c078d --- /dev/null +++ b/revolt/voice.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +import livekit +import livekit.rtc + + +if TYPE_CHECKING: + from .types import VoiceInformation as VoiceInformationPayload + from .state import State + + +__all__ = ( + "VoiceInformation", + "VoiceState" +) + + +class VoiceInformation: + """Holds information on the voice configuration of the text channel + + Attributes + ----------- + max_users: Optional[:class:`int`] + How many users can be in the voice at once + """ + def __init__(self, data: VoiceInformationPayload) -> None: + self.max_users: int | None = data.get("max_users", None) + +class VoiceState: + def __init__(self, state: State, token: str) -> None: + self.state = state + self.token = token + self.room = livekit.rtc.Room() + + async def connect(self): + await self.room.connect(self.state.api_info["features"]["livekit"]["url"], self.token) \ No newline at end of file