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 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())

View file

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

View file

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

View file

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

View file

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

View file

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

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

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