Implement Room

First, the Room itself.
This commit is contained in:
Joscha 2019-04-09 20:31:00 +00:00
parent 20f635a7ae
commit 40edcdc791
9 changed files with 554 additions and 291 deletions

36
test.py
View file

@ -4,7 +4,7 @@
import asyncio import asyncio
import logging import logging
from yaboli import Connection from yaboli import Room
FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}"
DATE_FORMAT = "%F %T" DATE_FORMAT = "%F %T"
@ -19,29 +19,17 @@ logger = logging.getLogger('yaboli')
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
logger.addHandler(handler) 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(): async def main():
conn = Connection("wss://euphoria.io/room/test/ws") tc = TestClient()
#conn = Connection("wss://euphoria.io/room/cabal/ws") # password protected await tc.run()
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()
asyncio.run(main()) asyncio.run(main())

View file

@ -6,7 +6,7 @@ from .events import *
from .exceptions import * from .exceptions import *
from .message import * from .message import *
from .room import * from .room import *
from .user import * from .session import *
from .util import * from .util import *
__all__: List[str] = [] __all__: List[str] = []
@ -16,4 +16,5 @@ __all__ += events.__all__
__all__ += exceptions.__all__ __all__ += exceptions.__all__
__all__ += message.__all__ __all__ += message.__all__
__all__ += room.__all__ __all__ += room.__all__
__all__ += user.__all__ __all__ += session.__all__
__all__ += util.__all__

View file

@ -1,8 +1,6 @@
from typing import List, Optional from typing import List, Optional
from .message import Message
from .room import Room from .room import Room
from .user import User
__all__ = ["Client"] __all__ = ["Client"]

View file

@ -13,6 +13,9 @@ logger = logging.getLogger(__name__)
__all__ = ["Connection"] __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: class Connection:
""" """
The Connection handles the lower-level stuff required when connecting to The Connection handles the lower-level stuff required when connecting to
@ -447,6 +450,7 @@ class Connection:
# Finally, reset the ping check # Finally, reset the ping check
logger.debug("Resetting ping check") logger.debug("Resetting ping check")
if self._ping_check is not None:
self._ping_check.cancel() self._ping_check.cancel()
self._ping_check = asyncio.create_task( self._ping_check = asyncio.create_task(
self._disconnect_in(self.PING_TIMEOUT)) self._disconnect_in(self.PING_TIMEOUT))
@ -473,7 +477,7 @@ class Connection:
except IncorrectStateException: except IncorrectStateException:
logger.debug("Could not send (disconnecting or already disconnected)") 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" Implements http://api.euphoria.io/#ping and is called as "ping-event"
callback. callback.
@ -481,7 +485,7 @@ class Connection:
logger.debug("Pong!") logger.debug("Pong!")
await self._do_if_possible(self.send( await self._do_if_possible(self.send(
"ping-reply", "ping-reply",
{"time": packet["data"]["time"]} {"time": packet["data"]["time"]},
await_reply=False await_reply=False
)) ))
@ -489,7 +493,7 @@ class Connection:
packet_type: str, packet_type: str,
data: Any, data: Any,
await_reply: bool = True await_reply: bool = True
) -> Optional[Any]: ) -> Any:
""" """
Send a packet of type packet_type to the server. Send a packet of type packet_type to the server.

View file

@ -8,6 +8,8 @@ __all__ = [
"CouldNotConnectException", "CouldNotConnectException",
"CouldNotAuthenticateException", "CouldNotAuthenticateException",
# Doing stuff in a room # Doing stuff in a room
"RoomNotConnectedException",
"EuphError",
"RoomClosedException", "RoomClosedException",
] ]
@ -52,6 +54,21 @@ class CouldNotAuthenticateException(JoinException):
# Doing stuff in a room # 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): class RoomClosedException(EuphException):
""" """
The room has been closed already. The room has been closed already.

View file

@ -1,108 +1,30 @@
import datetime 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: if TYPE_CHECKING:
from .client import Client
from .room import Room from .room import Room
__all__ = ["Message", "LiveMessage"] __all__ = ["Message", "LiveMessage"]
# "Offline" message
class Message: class Message:
def __init__(self, pass
room_name: str, # @property
id_: str, # def room_name(self) -> str:
parent_id: Optional[str], # return self._room_name
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
@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 # @property
# duplicate around, if possible? # def time(self) -> datetime.datetime:
self._livesender = sender # return datetime.datetime.fromtimestamp(self.timestamp)
#
# @property
# def timestamp(self) -> int:
# return self._timestamp
@property class LiveMessage(Message):
def room(self) -> 'Room':
return self._room
@property
def sender(self) -> LiveUser:
return self._livesender
async def reply(self, text: str) -> None:
pass pass
# TODO add some sort of permission guard that checks the room @classmethod
# UnauthorizedException def from_data(cls, room: "Room", data: Any) -> "LiveMessage":
async def delete(self,
deleted: bool = True
) -> None:
pass pass

View file

@ -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 .exceptions import *
from .message import LiveMessage from .message import LiveMessage
from .user import LiveUser from .session import Account, LiveSession, LiveSessionListing
logger = logging.getLogger(__name__)
__all__ = ["Room"] __all__ = ["Room"]
T = TypeVar("T")
class Room: class Room:
""" """
A Room represents one connection to a room on euphoria, i. e. what other Events and parameters:
implementations might consider a "client". This means that each Room has
its own session (User) and nick.
A Room can only be used once in the sense that after it has been closed, "snapshot" - snapshot of the room's messages at the time of joining
any further actions will result in a RoomClosedException. If you need to messages: List[LiveMessage]
manually reconnect, instead just create a new Room object.
"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 "nick" - another room member has changed their nick
2. await join() user: LiveUser
3. do room-related stuff from: str
4. await part() 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 "disconect" - corresponds to http://api.euphoria.io/#disconnect-event (if
current nick are used when first connecting to the room, or when the reason is "authentication changed", the room automatically reconnects)
reconnecting to the room after connection was lost. reason: str - the reason for disconnection
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.
""" """
# Phase 1 URL_FORMAT = "wss://euphoria.io/room/{}/ws"
def __init__(self, def __init__(self,
room_name: str, name: str,
nick: str = "", password: Optional[str] = None,
password: Optional[str] = None) -> None: target_nick: str = "",
pass 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: self._connection.register_event("reconnecting", self._on_reconnecting)
if self.closed: self._connection.register_event("hello-event", self._on_hello_event)
raise RoomClosedException() 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: self._connection.register_event("disconnect-event", self._on_disconnect_event)
pass 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: def register_event(self,
self._ensure_open() event: str,
await self._ensure_joined() 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 @property
def name(self) -> str: def name(self) -> str:
pass return self._name
async def say(self,
text: str,
parent_id: Optional[str] = None
) -> LiveMessage:
pass
@property @property
def users(self) -> List[LiveUser]: def password(self) -> Optional[str]:
pass 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

71
yaboli/session.py Normal file
View file

@ -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

View file

@ -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