From 40edcdc7911e6ce9ff4cbef69c5282d5034babf6 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Apr 2019 20:31:00 +0000 Subject: [PATCH] Implement Room First, the Room itself. --- test.py | 36 ++-- yaboli/__init__.py | 5 +- yaboli/client.py | 2 - yaboli/connection.py | 12 +- yaboli/exceptions.py | 17 ++ yaboli/message.py | 112 ++-------- yaboli/room.py | 499 ++++++++++++++++++++++++++++++++++++------- yaboli/session.py | 71 ++++++ yaboli/user.py | 91 -------- 9 files changed, 554 insertions(+), 291 deletions(-) create mode 100644 yaboli/session.py delete mode 100644 yaboli/user.py diff --git a/test.py b/test.py index 98228f1..a4ba165 100644 --- a/test.py +++ b/test.py @@ -4,7 +4,7 @@ import asyncio import logging -from yaboli import Connection +from yaboli import Room FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" DATE_FORMAT = "%F %T" @@ -19,29 +19,17 @@ logger = logging.getLogger('yaboli') logger.setLevel(logging.DEBUG) logger.addHandler(handler) +class TestClient: + def __init__(self): + self.room = Room("test", target_nick="testbot") + self.stop = asyncio.Event() + + async def run(self): + await self.room.connect() + await self.stop.wait() + async def main(): - conn = Connection("wss://euphoria.io/room/test/ws") - #conn = Connection("wss://euphoria.io/room/cabal/ws") # password protected - - print() - print(" DISCONNECTING TWICE AT THE SAME TIME") - print("Connected successfully:", await conn.connect()) - a = asyncio.create_task(conn.disconnect()) - b = asyncio.create_task(conn.disconnect()) - await a - await b - - print() - print(" DISCONNECTING WHILE CONNECTING (test not working properly)") - asyncio.create_task(conn.disconnect()) - await asyncio.sleep(0) - print("Connected successfully:", await conn.connect()) - await conn.disconnect() - - print() - print(" WAITING FOR PING TIMEOUT") - print("Connected successfully:", await conn.connect()) - await asyncio.sleep(conn.PING_TIMEOUT + 10) - await conn.disconnect() + tc = TestClient() + await tc.run() asyncio.run(main()) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 24b21f5..e8b9e1f 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -6,7 +6,7 @@ from .events import * from .exceptions import * from .message import * from .room import * -from .user import * +from .session import * from .util import * __all__: List[str] = [] @@ -16,4 +16,5 @@ __all__ += events.__all__ __all__ += exceptions.__all__ __all__ += message.__all__ __all__ += room.__all__ -__all__ += user.__all__ +__all__ += session.__all__ +__all__ += util.__all__ diff --git a/yaboli/client.py b/yaboli/client.py index be3e364..c405884 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -1,8 +1,6 @@ from typing import List, Optional -from .message import Message from .room import Room -from .user import User __all__ = ["Client"] diff --git a/yaboli/connection.py b/yaboli/connection.py index 92fe6b3..137d060 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -13,6 +13,9 @@ logger = logging.getLogger(__name__) __all__ = ["Connection"] +# This class could probably be cleaned up by introducing one or two well-placed +# Locks – something for the next rewrite :P + class Connection: """ The Connection handles the lower-level stuff required when connecting to @@ -447,7 +450,8 @@ class Connection: # Finally, reset the ping check logger.debug("Resetting ping check") - self._ping_check.cancel() + if self._ping_check is not None: + self._ping_check.cancel() self._ping_check = asyncio.create_task( self._disconnect_in(self.PING_TIMEOUT)) @@ -473,7 +477,7 @@ class Connection: except IncorrectStateException: logger.debug("Could not send (disconnecting or already disconnected)") - async def _ping_pong(self, packet): + async def _ping_pong(self, packet: Any) -> None: """ Implements http://api.euphoria.io/#ping and is called as "ping-event" callback. @@ -481,7 +485,7 @@ class Connection: logger.debug("Pong!") await self._do_if_possible(self.send( "ping-reply", - {"time": packet["data"]["time"]} + {"time": packet["data"]["time"]}, await_reply=False )) @@ -489,7 +493,7 @@ class Connection: packet_type: str, data: Any, await_reply: bool = True - ) -> Optional[Any]: + ) -> Any: """ Send a packet of type packet_type to the server. diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py index 8601641..cf9d94e 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -8,6 +8,8 @@ __all__ = [ "CouldNotConnectException", "CouldNotAuthenticateException", # Doing stuff in a room + "RoomNotConnectedException", + "EuphError", "RoomClosedException", ] @@ -52,6 +54,21 @@ class CouldNotAuthenticateException(JoinException): # Doing stuff in a room +class RoomNotConnectedException(EuphException): + """ + Either the Room's connect() function has not been called or it has not + completed successfully. + """ + pass + +class EuphError(EuphException): + """ + The euphoria server has sent back an "error" field in its response. + """ + pass + +# TODO This exception is not used currently, decide on whether to keep it or +# throw it away class RoomClosedException(EuphException): """ The room has been closed already. diff --git a/yaboli/message.py b/yaboli/message.py index e985c05..9e6c21e 100644 --- a/yaboli/message.py +++ b/yaboli/message.py @@ -1,108 +1,30 @@ import datetime -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional -from .user import LiveUser, User +from .session import LiveSession, Session if TYPE_CHECKING: - from .client import Client from .room import Room __all__ = ["Message", "LiveMessage"] -# "Offline" message class Message: - def __init__(self, - room_name: str, - id_: str, - parent_id: Optional[str], - timestamp: int, - sender: User, - content: str, - deleted: bool, - truncated: bool): - self._room_name = room_name - self._id = id_ - self._parent_id = parent_id - self._timestamp = timestamp - self._sender = sender - self._content = content - self._deleted = deleted - self._truncated = truncated + pass +# @property +# def room_name(self) -> str: +# return self._room_name +# +# @property +# def time(self) -> datetime.datetime: +# return datetime.datetime.fromtimestamp(self.timestamp) +# +# @property +# def timestamp(self) -> int: +# return self._timestamp - @property - def room_name(self) -> str: - return self._room_name - - @property - def id(self) -> str: - return self._id - - @property - def parent_id(self) -> Optional[str]: - return self._parent_id - - @property - def time(self) -> datetime.datetime: - return datetime.datetime.fromtimestamp(self.timestamp) - - @property - def timestamp(self) -> int: - return self._timestamp - - @property - def sender(self) -> User: - return self._sender - - @property - def content(self) -> str: - return self._content - - @property - def deleted(self) -> bool: - return self._deleted - - @property - def truncated(self) -> bool: - return self._truncated - -# "Online" message -# has a few nice functions class LiveMessage(Message): - def __init__(self, - client: 'Client', - room: 'Room', - id_: str, - parent_id: Optional[str], - timestamp: int, - sender: LiveUser, - content: str, - deleted: bool, - truncated: bool): - self._client = client - super().__init__(room.name, id_, parent_id, timestamp, sender, content, - deleted, truncated) - self._room = room - # The typechecker can't use self._sender directly, because it has type - # User. - # - # TODO Find a way to satisfy the type checker without having this - # duplicate around, if possible? - self._livesender = sender + pass - @property - def room(self) -> 'Room': - return self._room - - @property - def sender(self) -> LiveUser: - return self._livesender - - async def reply(self, text: str) -> None: - pass - - # TODO add some sort of permission guard that checks the room - # UnauthorizedException - async def delete(self, - deleted: bool = True - ) -> None: + @classmethod + def from_data(cls, room: "Room", data: Any) -> "LiveMessage": pass diff --git a/yaboli/room.py b/yaboli/room.py index 092c7b4..c25baa1 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,107 +1,460 @@ -from typing import List, Optional +import asyncio +import logging +from typing import Any, Awaitable, Callable, List, Optional, TypeVar +from .connection import Connection +from .events import Events from .exceptions import * from .message import LiveMessage -from .user import LiveUser +from .session import Account, LiveSession, LiveSessionListing + +logger = logging.getLogger(__name__) __all__ = ["Room"] +T = TypeVar("T") + + class Room: """ - A Room represents one connection to a room on euphoria, i. e. what other - implementations might consider a "client". This means that each Room has - its own session (User) and nick. + Events and parameters: - A Room can only be used once in the sense that after it has been closed, - any further actions will result in a RoomClosedException. If you need to - manually reconnect, instead just create a new Room object. + "snapshot" - snapshot of the room's messages at the time of joining + messages: List[LiveMessage] + "send" - another room member has sent a message + message: LiveMessage + "join" - somebody has joined the room + user: LiveUser - Life cycle of a Room + "part" - somebody has left the room + user: LiveUser - 1. create a new Room and register callbacks - 2. await join() - 3. do room-related stuff - 4. await part() + "nick" - another room member has changed their nick + user: LiveUser + from: str + to: str + "edit" - a message in the room has been modified or deleted + message: LiveMessage + "pm" - another session initiated a pm with you + from: str - the id of the user inviting the client to chat privately + from_nick: str - the nick of the inviting user + from_room: str - the room where the invitation was sent from + pm_id: str - the private chat can be accessed at /room/pm:PMID - IN PHASE 1, a password and a starting nick can be set. The password and - current nick are used when first connecting to the room, or when - reconnecting to the room after connection was lost. - - Usually, event callbacks are also registered during this phase. - - - - IN PHASE 2, the Room creates the initial connection to euphoria and - performs initialisations (i. e. authentication or setting the nick) where - necessary. It also starts the Room's main event loop. The join() function - returns once one of the following cases has occurred: - - 1. the room is now in phase 3, in which case join() returns None - 2. the room could not be joined, in which case one of the JoinExceptions is - returned - - - - IN PHASE 3, the usual room-related functions like say() or nick() are - available. The Room's event loop is running. - - The room will automatically reconnect if it loses connection to euphoria. - The usual room-related functions will block until the room has successfully - reconnected. - - - - IN PHASE 4, the Room is disconnected and the event loop stopped. During and - after completion of this phase, the Room is considered closed. Any further - attempts to re-join or call room action functions will result in a - RoomClosedException. + "disconect" - corresponds to http://api.euphoria.io/#disconnect-event (if + the reason is "authentication changed", the room automatically reconnects) + reason: str - the reason for disconnection """ - # Phase 1 + URL_FORMAT = "wss://euphoria.io/room/{}/ws" def __init__(self, - room_name: str, - nick: str = "", - password: Optional[str] = None) -> None: - pass + name: str, + password: Optional[str] = None, + target_nick: str = "", + url_format: str = URL_FORMAT + ) -> None: + self._name = name + self._password = password + self._target_nick = target_nick + self._url_format = url_format - self.closed = False + self._session: Optional[LiveSession] = None + self._account: Optional[Account] = None + self._private: Optional[bool] = None + self._version: Optional[str] = None + self._users: Optional[LiveSessionListing] = None + self._pm_with_nick: Optional[str] = None + self._pm_with_user_id: Optional[str] = None + self._server_version: Optional[str] = None - # Phase 2 + # Connected management + self._url = self._url_format.format(self._name) + self._connection = Connection(self._url) + self._events = Events() - # Phase 3 + self._connected = asyncio.Event() + self._connected_successfully = False + self._hello_received = False + self._snapshot_received = False - def _ensure_open(self) -> None: - if self.closed: - raise RoomClosedException() + self._connection.register_event("reconnecting", self._on_reconnecting) + self._connection.register_event("hello-event", self._on_hello_event) + self._connection.register_event("snapshot-event", self._on_snapshot_event) + self._connection.register_event("bounce-event", self._on_bounce_event) - async def _ensure_joined(self) -> None: - pass + self._connection.register_event("disconnect-event", self._on_disconnect_event) + self._connection.register_event("join-event", self._on_join_event) + self._connection.register_event("login-event", self._on_login_event) + self._connection.register_event("logout-event", self._on_logout_event) + self._connection.register_event("network-event", self._on_network_event) + self._connection.register_event("nick-event", self._on_nick_event) + self._connection.register_event("edit-message-event", self._on_edit_message_event) + self._connection.register_event("part-event", self._on_part_event) + self._connection.register_event("pm-initiate-event", self._on_pm_initiate_event) + self._connection.register_event("send-event", self._on_send_event) - async def _ensure(self) -> None: - self._ensure_open() - await self._ensure_joined() + def register_event(self, + event: str, + callback: Callable[..., Awaitable[None]] + ) -> None: + """ + Register an event callback. - # Phase 4 + For an overview of the possible events, see the Room docstring. + """ - # Other stuff + self._events.register(event, callback) + + # Connecting, reconnecting and disconnecting + + def _set_connected(self) -> None: + packets_received = self._hello_received and self._snapshot_received + if packets_received and not self._connected.is_set(): + self._connected_successfully = True + self._connected.set() + + def _set_connected_failed(self) -> None: + if not self._connected.is_set(): + self._connected_successfully = False + self._connected.set() + + def _set_connected_reset(self) -> None: + self._connected.clear() + self._connected_successfully = False + self._hello_received = False + self._snapshot_received = False + + async def _on_reconnecting(self) -> None: + self._set_connected_reset() + + async def _on_hello_event(self, packet: Any) -> None: + data = packet["data"] + + self._session = LiveSession.from_data(self, data["session"]) + self._account = Account.from_data(data) + self._private = data["room_is_private"] + self._version = data["version"] + + self._hello_received = True + self._set_connected() + + async def _on_snapshot_event(self, packet: Any) -> None: + data = packet["data"] + + self._server_version = data["version"] + self._users = LiveSessionListing.from_data(self, data["listing"]) + self._pm_with_nick = data.get("pm_with_nick") + self._pm_with_user_id = data.get("pm_with_user_id") + + # Update session nick + nick = data.get("nick") + if nick is not None and self._session is not None: + self._session = self.session.with_nick(nick) + + # Send "session" event + messages = [LiveMessage.from_data(self, msg_data) + for msg_data in data["log"]] + self._events.fire("session", messages) + + self._snapshot_received = True + self._set_connected() + + async def _on_bounce_event(self, packet: Any) -> None: + data = packet["data"] + + # Can we even authenticate? + if not "passcode" in data.get("auth_options", []): + self._set_connected_failed() + return + + # If so, do we have a password? + if self._password is None: + self._set_connected_failed() + return + + reply = await self._connection.send( + "auth", + {"type": "passcode", "passcode": self._password} + ) + + if not reply["data"]["success"]: + self._set_connected_failed() + + async def connect(self) -> bool: + """ + Attempt to connect to the room and start handling events. + + This function returns once the Room is fully connected, i. e. + authenticated, using the correct nick and able to post messages. + """ + + if not await self._connection.connect(): + return False + + await self._connected.wait() + if not self._connected_successfully: + return False + + if self._session is None or self._target_nick != self._session.nick: + await self._nick(self._target_nick) + + return True + + async def disconnect(self) -> None: + """ + Disconnect from the room and stop the Room. + + This function has the potential to mess things up, and it has not yet + been tested thoroughly. Use at your own risk, especially if you want to + call connect() after calling disconnect(). + """ + + self._set_connected_reset() + await self._connection.disconnect() + + # Other events + + async def _on_disconnect_event(self, packet: Any) -> None: + reason = packet["data"]["reason"] + + if reason == "authentication changed": + await self._connection.reconnect() + + self._events.fire("disconnect", reason) + + async def _on_join_event(self, packet: Any) -> None: + data = packet["data"] + + session = LiveSession.from_data(self, data) + self._users = self.users.with_join(session) + + self._events.fire("join", session) + + async def _on_login_event(self, packet: Any) -> None: + pass # TODO implement once cookie support is here + + async def _on_logout_event(self, packet: Any) -> None: + pass # TODO implement once cookie support is here + + async def _on_network_event(self, packet: Any) -> None: + data = packet["data"] + + if data["type"] == "partition": + server_id = data["server_id"] + server_era = data["server_era"] + + users = self.users + + for user in self.users: + if user.server_id == server_id and user.server_era == server_era: + users = users.with_part(user) + self._events.fire("part", user) + + self._users = users + + async def _on_nick_event(self, packet: Any) -> None: + data = packet["data"] + session_id = data["session_id"] + nick_from = data["from"] + nick_to = data["to"] + + session = self.users.get(session_id) + if session is not None: + self._users = self.users.with_nick(session, nick_to) + else: + await self.who() # recalibrating self._users + + self._events.fire("nick", session, nick_from, nick_to) + + async def _on_edit_message_event(self, packet: Any) -> None: + data = packet["data"] + + message = LiveMessage.from_data(self, data) + + self._events.fire("edit", message) + + async def _on_part_event(self, packet: Any) -> None: + data = packet["data"] + + session = LiveSession.from_data(self, data) + self._users = self.users.with_part(session) + + self._events.fire("part", session) + + async def _on_pm_initiate_event(self, packet: Any) -> None: + data = packet["data"] + from_id = data["from"] + from_nick = data["from_nick"] + from_room = data["from_room"] + pm_id = data["pm_id"] + + self._events.fire("pm", from_id, from_nick, from_room, pm_id) + + async def _on_send_event(self, packet: Any) -> None: + data = packet["data"] + + message = LiveMessage.from_data(self, data) + + self._events.fire("send", message) + + # Attributes, ordered the same as in __init__ + + def _wrap_optional(self, x: Optional[T]) -> T: + if x is None: + raise RoomNotConnectedException() + + return x @property def name(self) -> str: - pass - - async def say(self, - text: str, - parent_id: Optional[str] = None - ) -> LiveMessage: - pass + return self._name @property - def users(self) -> List[LiveUser]: - pass + def password(self) -> Optional[str]: + return self._password - # retrieving messages + @property + def target_nick(self) -> str: + return self._target_nick + + @property + def url_format(self) -> str: + return self._url_format + + @property + def session(self) -> LiveSession: + return self._wrap_optional(self._session) + + @property + def account(self) -> Account: + return self._wrap_optional(self._account) + + @property + def private(self) -> bool: + return self._wrap_optional(self._private) + + @property + def version(self) -> str: + return self._wrap_optional(self._version) + + @property + def users(self) -> LiveSessionListing: + return self._wrap_optional(self._users) + + @property + def pm_with_nick(self) -> str: + return self._wrap_optional(self._pm_with_nick) + + @property + def pm_with_user_id(self) -> str: + return self._wrap_optional(self._pm_with_user_id) + + @property + def url(self) -> str: + return self._url + + # Functionality + + # These functions require cookie support and are thus not implemented yet: + # + # login, logout, pm + + def _extract_data(self, packet: Any) -> Any: + error = packet.get("error") + if error is not None: + raise EuphError(error) + + return packet["data"] + + async def _ensure_connected(self) -> None: + await self._connected.wait() + + if not self._connected_successfully: + raise RoomNotConnectedException() + + async def send(self, + content: str, + parent_id: Optional[str] = None + ) -> LiveMessage: + await self._ensure_connected() + + data = {"content": content} + if parent_id is not None: + data["parent"] = parent_id + + reply = await self._connection.send("send", data) + data = self._extract_data(reply) + + return LiveMessage.from_data(self, data) + + async def _nick(self, nick: str) -> str: + """ + This function implements all of the nick-setting logic except waiting + for the room to actually connect. This is because connect() actually + uses this function to set the desired nick before the room is + connected. + """ + + logger.debug(f"Setting nick to {nick!r}") + + self._target_nick = nick + + reply = await self._connection.send("nick", {"name": nick}) + data = self._extract_data(reply) + + new_nick = data["to"] + self._target_nick = new_nick + + logger.debug(f"Set nick to {new_nick!r}") + + return new_nick + + async def nick(self, nick: str) -> str: + await self._ensure_connected() + + return await self._nick(nick) + + async def get(self, message_id: str) -> LiveMessage: + await self._ensure_connected() + + reply = await self._connection.send("get-message", {"id": message_id}) + data = self._extract_data(reply) + + return LiveMessage.from_data(self, data) + + async def log(self, + amount: int, + before_id: Optional[str] = None + ) -> List[LiveMessage]: + await self._ensure_connected() + + data: Any = {"n": amount} + if before_id is not None: + data["before"] = before_id + + reply = await self._connection.send("log", data) + data = self._extract_data(reply) + + messages = [LiveMessage.from_data(self, msg_data) + for msg_data in data["log"]] + return messages + + async def who(self) -> LiveSessionListing: + await self._ensure_connected() + + reply = await self._connection.send("who", {}) + data = self._extract_data(reply) + + own_id = self._session.session_id if self._session is not None else None + self._users = LiveSessionListing.from_data( + self, + data["listing"], + exclude_id = own_id + ) + + return self._users diff --git a/yaboli/session.py b/yaboli/session.py new file mode 100644 index 0000000..114a5c0 --- /dev/null +++ b/yaboli/session.py @@ -0,0 +1,71 @@ +from typing import TYPE_CHECKING, Any, Iterator, Optional + +if TYPE_CHECKING: + from .room import Room + +__all__ = ["Account", "Session", "LiveSession", "LiveSessionListing"] + +class Account: + pass + + @classmethod + def from_data(cls, data: Any) -> "Account": + pass + +class Session: + pass + + @property + def nick(self) -> str: + pass + + @property + def session_id(self) -> str: + pass + +class LiveSession(Session): + pass + + @classmethod + def from_data(cls, room: "Room", data: Any) -> "LiveSession": + pass + + @property + def server_id(self) -> str: + pass + + @property + def server_era(self) -> str: + pass + + def with_nick(self, nick: str) -> "LiveSession": + pass + +class LiveSessionListing: + pass + + def __iter__(self) -> Iterator[LiveSession]: + pass + + @classmethod + def from_data(cls, + room: "Room", + data: Any, + exclude_id: Optional[str] = None + ) -> "LiveSessionListing": + pass + + def get(self, session_id: str) -> Optional[LiveSession]: + pass + + def with_join(self, session: LiveSession) -> "LiveSessionListing": + pass + + def with_part(self, session: LiveSession) -> "LiveSessionListing": + pass + + def with_nick(self, + session: LiveSession, + new_nick: str + ) -> "LiveSessionListing": + pass diff --git a/yaboli/user.py b/yaboli/user.py deleted file mode 100644 index f97d7d7..0000000 --- a/yaboli/user.py +++ /dev/null @@ -1,91 +0,0 @@ -from typing import TYPE_CHECKING - -from .util import atmention, mention - -if TYPE_CHECKING: - from .client import Client - from .room import Room - -__all__ = ["User", "LiveUser"] - -class User: - def __init__(self, - room_name: str, - id_: str, - name: str, - is_staff: bool, - is_manager: bool): - self._room_name = room_name - self._id = id_ - self._name = name - self._is_staff = is_staff - self._is_manager = is_manager - - @property - def room_name(self) -> str: - return self._room_name - - @property - def id(self) -> str: - return self._id - - @property - def name(self) -> str: - # no name = empty str - return self._name - - @property - def is_staff(self) -> bool: - return self._is_staff - - @property - def is_manager(self) -> bool: - return self._is_manager - - @property - def is_account(self) -> bool: - pass - - @property - def is_agent(self) -> bool: - # TODO should catch all old ids too - pass - - @property - def is_bot(self) -> bool: - pass - - # TODO possibly add other fields - - # Properties here? Yeah sure, why not? - - @property - def mention(self) -> str: - return mention(self.name) - - @property - def atmention(self) -> str: - return atmention(self.name) - -class LiveUser(User): - def __init__(self, - client: 'Client', - room: 'Room', - id_: str, - name: str, - is_staff: bool, - is_manager: bool): - super().__init__(room.name, id_, name, is_staff, is_manager) - self._room = room - - @property - def room(self) -> 'Room': - return self._room - - # NotLoggedInException - async def pm(self) -> 'Room': - pass - - # kick - # ban - # ip_ban