From 6b65bef5e0c036a971335c63808530434061d94e Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 25 Jul 2018 16:02:38 +0000 Subject: [PATCH] Start rewrite --- yaboli/__init__.py | 29 +- yaboli/connection.py | 285 +++++++++-------- yaboli/cookiejar.py | 65 ++++ yaboli/exceptions.py | 4 + yaboli/room.py | 747 ++++++++----------------------------------- 5 files changed, 354 insertions(+), 776 deletions(-) create mode 100644 yaboli/cookiejar.py create mode 100644 yaboli/exceptions.py diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 20e6b90..681e168 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,30 +1,21 @@ +# ---------- BEGIN DEV SECTION ---------- import asyncio -#asyncio.get_event_loop().set_debug(True) # uncomment for asycio debugging mode - import logging -# general (asyncio) logging level -#logging.basicConfig(level=logging.DEBUG) -#logging.basicConfig(level=logging.INFO) -logging.basicConfig(level=logging.WARNING) +# asyncio debugging +asyncio.get_event_loop().set_debug(True) # uncomment for asycio debugging mode +logging.getLogger("asyncio").setLevel(logging.DEBUG) # yaboli logger level -logger = logging.getLogger(__name__) -#logger.setLevel(logging.DEBUG) -logger.setLevel(logging.INFO) +logging.getLogger(__name__).setLevel(logging.DEBUG) +# ----------- END DEV SECTION ----------- -from .bot import * +from .cookiejar import * from .connection import * -from .controller import * -from .database import * -from .room import * -from .utils import * +from .exceptions import * __all__ = ( - bot.__all__ + connection.__all__ + - controller.__all__ + - database.__all__ + - room.__all__ + - utils.__all__ + cookiejar.__all__ + + exceptions.__all__ ) diff --git a/yaboli/connection.py b/yaboli/connection.py index 6fc2cb7..94cfc57 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -4,120 +4,38 @@ import logging import socket import websockets +from .exceptions import ConnectionClosed + + logger = logging.getLogger(__name__) __all__ = ["Connection"] - class Connection: - def __init__(self, url, packet_hook, cookie=None, ping_timeout=10, ping_delay=30): + def __init__(self, url, packet_callback, disconnect_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10): self.url = url - self.packet_hook = packet_hook - self.cookie = cookie + self.packet_callback = packet_callback + self.disconnect_callback = disconnect_callback + self.cookiejar = cookiejar self.ping_timeout = ping_timeout # how long to wait for websocket ping reply self.ping_delay = ping_delay # how long to wait between pings - + self.reconnect_attempts = reconnect_attempts + self._ws = None self._pid = 0 # successive packet ids - self._spawned_tasks = set() + #self._spawned_tasks = set() self._pending_responses = {} - - self._runtask = None - self._pingtask = None # pings - - async def connect(self, max_tries=10, delay=60): - """ - success = await connect(max_tries=10, delay=60) - - Attempt to connect to a room. - Returns the task listening for packets, or None if the attempt failed. - - max_tries - maximum number of reconnect attempts before stopping - delay - time (in seconds) between reconnect attempts - """ - - logger.debug(f"Attempting to connect, max_tries={max_tries}") - - await self.stop() - - logger.debug(f"Stopped previously running things.") - - for tries_left in reversed(range(max_tries)): - logger.info(f"Attempting to connect, {tries_left} tries left.") - try: - self._ws = await websockets.connect(self.url, max_size=None) - except (websockets.InvalidURI, websockets.InvalidHandshake, socket.gaierror): - self._ws = None - if tries_left > 0: - await asyncio.sleep(delay) - else: - self._runtask = asyncio.ensure_future(self._run()) - self._pingtask = asyncio.ensure_future(self._ping()) - logger.debug(f"Started run and ping tasks") - - return self._runtask - - async def _run(self): - """ - Listen for packets and deal with them accordingly. - """ - - try: - while True: - await self._handle_next_message() - except websockets.ConnectionClosed: - pass - finally: - self._clean_up_futures() - self._clean_up_tasks() - - try: - await self._ws.close() # just to make sure - except: - pass # errors are not useful here - - self._pingtask.cancel() - await self._pingtask # should stop now that the ws is closed - self._ws = None - - async def _ping(self): - """ - Periodically ping the server to detect a timeout. - """ - - while True: - try: - logger.debug("Pinging...") - wait_for_reply = await self._ws.ping() - await asyncio.wait_for(wait_for_reply, self.ping_timeout) - logger.debug("Pinged!") - await asyncio.sleep(self.ping_delay) - except asyncio.TimeoutError: - logger.warning("Ping timed out.") - await self._ws.close() - break - except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): - return - - async def stop(self): - """ - Close websocket connection and wait for running task to stop. - """ - - if self._ws: - try: - await self._ws.close() - except: - pass # errors not useful here - - if self._runtask: - await self._runtask - + self._stopped = False + self._pingtask = None + self._runtask = asyncio.create_task(self._run()) + # ... aaand the connection is started. + async def send(self, ptype, data=None, await_response=True): if not self._ws: - raise asyncio.CancelledError - + raise exceptions.ConnectionClosed + #raise asyncio.CancelledError + pid = str(self._new_pid()) packet = { "type": ptype, @@ -125,62 +43,157 @@ class Connection: } if data: packet["data"] = data - + if await_response: wait_for = self._wait_for_response(pid) - + logging.debug(f"Currently used websocket at self._ws: {self._ws}") await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size - + if await_response: await wait_for return wait_for.result() - + + async def stop(self): + """ + Close websocket connection and wait for running task to stop. + + No connection function are to be called after calling stop(). + This means that stop() can only be called once. + """ + + self._stopped = True + if self._ws: + await self._ws.close() # _run() does the cleaning up now. + await self._runtask + + async def _connect(self, tries): + """ + Attempt to connect to a room. + If the Connection is already connected, it attempts to reconnect. + + Returns True on success, False on failure. + + If tries is None, connect retries infinitely. + The delay between connection attempts doubles every attempt (starts with 1s). + """ + + # Assumes _disconnect() has already been called in _run() + + delay = 1 # seconds + while True: + try: + if self._cookiejar: + cookies = [("Cookie", cookie) for cookie in self._cookiejar.sniff()] + self._ws = await websockets.connect(self.url, max_size=None, extra_headers=cookies) + else: + self._ws = await websockets.connect(self.url, max_size=None) + except (websockets.InvalidHandshake, socket.gaierror): # not websockets.InvalidURI + self._ws = None + + if tries is not None: + tries -= 1 + if tries <= 0: + return False + + await asyncio.sleep(delay) + delay *= 2 + else: + if self._cookiejar: + for set_cookie in self._ws.response_headers.get_all("Set-Cookie"): + self._cookiejar.bake(set_cookie) + + self._pingtask = asyncio.create_task(self._ping()) + + return True + + async def _disconnect(self): + """ + Disconnect and clean up all "residue", such as: + - close existing websocket connection + - cancel all pending response futures with a ConnectionClosed exception + - reset package ID counter + - make sure the ping task has finished + """ + + # stop ping task + if self._pingtask: + self._pingtask.cancel() + await self._pingtask + self._pingtask = None + + if self._ws: + await self._ws.close() + self._ws = None + + self._pid = 0 + + # clean up pending response futures + for _, future in self._pending_responses.items(): + logger.debug(f"Cancelling future with ConnectionClosed: {future}") + future.set_exception(exceptions.ConnectionClosed("No server response")) + self._pending_responses = {} + + async def _run(self): + """ + Listen for packets and deal with them accordingly. + """ + + while not self._stopped: + self._connect(self.reconnect_attempts) + + try: + while True: + await self._handle_next_message() + except websockets.ConnectionClosed: + pass + finally: + await self._disconnect() # disconnect and clean up + + async def _ping(self): + """ + Periodically ping the server to detect a timeout. + """ + + try: + while True: + logger.debug("Pinging...") + wait_for_reply = await self._ws.ping() + await asyncio.wait_for(wait_for_reply, self.ping_timeout) + logger.debug("Pinged!") + await asyncio.sleep(self.ping_delay) + except asyncio.TimeoutError: + logger.warning("Ping timed out.") + await self._ws.close() # trigger a reconnect attempt + except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): + pass + def _new_pid(self): self._pid += 1 return self._pid - + async def _handle_next_message(self): response = await self._ws.recv() - task = asyncio.ensure_future(self._handle_json(response)) - self._track_task(task) # will be cancelled when the connection is closed - - def _clean_up_futures(self): - for pid, future in self._pending_responses.items(): - logger.debug(f"Cancelling future: {future}") - future.cancel() - self._pending_responses = {} - - def _clean_up_tasks(self): - for task in self._spawned_tasks: - if not task.done(): - logger.debug(f"Cancelling task: {task}") - task.cancel() - else: - logger.debug(f"Task already done: {task}") - logger.debug(f"Exception: {task.exception()}") - self._spawned_tasks = set() - - async def _handle_json(self, text): packet = json.loads(text) - + # Deal with pending responses pid = packet.get("id", None) future = self._pending_responses.pop(pid, None) if future: future.set_result(packet) - + + ptype = packet.get("type") + data = packet.get("data", None) + error = packet.get("error", None) + if packet.get("throttled", False): + throttled = packet.get("throttled_reason") + else: + throttled = None + # Pass packet onto room - await self.packet_hook(packet) - - def _track_task(self, task): - self._spawned_tasks.add(task) - - # only keep running tasks - self._spawned_tasks = {task for task in self._spawned_tasks if not task.done()} - + asyncio.create_task(self.packet_callback(ptype, data, error, throttled)) + def _wait_for_response(self, pid): future = asyncio.Future() self._pending_responses[pid] = future - return future diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py new file mode 100644 index 0000000..5f6c922 --- /dev/null +++ b/yaboli/cookiejar.py @@ -0,0 +1,65 @@ +import contextlib +import http.cookies as cookies +import logging + + +logger = logging.getLogger(__name__) +__all__ = ["CookieJar"] + + +class CookieJar: + """ + Keeps your cookies in a file. + """ + + def __init__(self, filename): + self._filename = filename + self._cookies = cookies.SimpleCookie() + + with contextlib.suppress(FileNotFoundError): + with open(self._filename, "r") as f: + for line in f: + self._cookies.load(line) + + def sniff(self): + """ + Returns a list of Cookie headers containing all current cookies. + """ + + return [morsel.OutputString(attrs=[]) for morsel in self._cookies.values()] + + def bake(self, cookie_string): + """ + Parse cookie and add it to the jar. + Does not automatically save to the cookie file. + + Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; HttpOnly; Secure" + """ + + logger.debug(f"Baking cookie: {cookie_string!r}") + + self._cookies.load(cookie_string) + + def save(self): + """ + Saves all current cookies to the cookie jar file. + """ + + logger.debug(f"Saving cookies to {self._filename!r}") + + with open(self._filename, "w") as f: + for morsel in self._cookies.values(): + cookie_string = morsel.OutputString() + #f.write(f"{cookie_string}\n") + f.write(cookie_string) + f.write("\n") + + def monster(self): + """ + Removes all cookies from the cookie jar. + Does not automatically save to the cookie file. + """ + + logger.debug("OMNOMNOM, cookies are all gone!") + + self._cookies = cookies.SimpleCookie() diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py new file mode 100644 index 0000000..4aaa8e0 --- /dev/null +++ b/yaboli/exceptions.py @@ -0,0 +1,4 @@ +__all__ = ["ConnectionClosed"] + +class ConnectionClosed(Exception): + pass diff --git a/yaboli/room.py b/yaboli/room.py index 873dcd5..cfc6ad7 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,637 +1,142 @@ -import asyncio -import logging -from .callbacks import * -from .connection import * -from .utils import * - -logger = logging.getLogger(__name__) -__all__ = ["Room"] - +__all__ == ["Room", "Inhabitant"] class Room: """ - This class represents a connection to a room. This basically means that one - room instance means one nick on the nick list. - - It's purpose is to provide a higher-level way of interacting with a room to - a controller. This includes converting packets received from the server to - utility classes where possible, or keeping track of current room state like - the client's nick. - It does not keep track of the room's messages, as keeping (or not keeping) - messages is highly application-dependent. If needed, messages can be kept - using the utils.Log class. - - Room implements all commands necessary for creating bots. For now, the - human flag should always be False, and the cookie None. - It also "attaches" to a controller and calls the corresponding functions - when it receives events from the server - - When connection is lost while the room is running, it will attempt to - reconnect a few times. Loss of connection is determined by self._conn. + TODO """ - - ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" - HUMAN_FORMAT = f"{ROOM_FORMAT}?h=1" - - def __init__(self, roomname, controller, human=False, cookie=None): - """ - Create a room. To connect to the room and start a run task that listens - to packets on the connection, use connect(). - - roomname - name of the room to connect to, without a "&" in front - controller - the controller which should be notified of events - human - currently not implemented, should be False - cookie - currently not implemented, should be None - """ - + + def __init__(self, roomname, inhabitant, password=None, human=False, cookiejar=None): + # TODO: Connect to room etc. + # TODO: Deal with room/connection states of: + # disconnected connecting, fast-forwarding, connected + + self._inhabitant = inhabitant + + # Room info (all fields readonly!) self.roomname = roomname - self.controller = controller - self.human = human - self.cookie = cookie - - # Keeps track of sessions, but not messages, since they might be dealt - # with differently by different controllers. - # If you need to keep track of messages, use utils.Log. self.session = None self.account = None - self.listing = Listing() - - # Various room information + self.listing = None # TODO self.account_has_access = None self.account_email_verified = None self.room_is_private = None self.version = None # the version of the code being run and served by the server self.pm_with_nick = None self.pm_with_user_id = None - - self._callbacks = Callbacks() - self._add_callbacks() - - self._stopping = False - self._runtask = None - - if human: - url = self.HUMAN_FORMAT.format(self.roomname) + + #asyncio.create_task(self._run()) + + async def exit(self): + pass + +# ROOM COMMANDS +# These always return a response from the server. +# If the connection is lost while one of these commands is called, +# the command will retry once the bot has reconnected. + + async def get_message(self, mid): + pass + + async def log(self, n, before_mid=None): + pass + + async def nick(self, nick): + pass + + async def pm(self, uid): + pass + + async def send(self, content, parent_mid=None): + """ + Send a message to the room. + See http://api.euphoria.io/#send + """ + + if parent_mid: + data = await self._send_while_connected( + "send", + content=content, + parent=parent_mid + ) else: - url = self.ROOM_FORMAT.format(self.roomname) - self._conn = Connection(url, self._handle_packet, self.cookie) - - async def connect(self, max_tries=10, delay=60): - """ - runtask = await connect(max_tries, delay) - - Attempts to connect to the room once and returns a task running - self._run, if successful, otherwise None. This can be used to detect if - a room exists. - - The max_tries and delay parameters are passed on to self._run: - max_tries - maximum number of reconnect attempts before stopping - delay - time (in seconds) between reconnect attempts - """ - - task = await self._conn.connect(max_tries=1) - if task: - self._runtask = asyncio.ensure_future(self._run(task, max_tries=max_tries, delay=delay)) - return self._runtask - - async def _run(self, task, max_tries=10, delay=60): - """ - await _run(max_tries, delay) - - Run and reconnect when the connection is lost or closed, unless - self._stopping is set to True. - For an explanation of the parameters, see self.connect. - """ - - while not self._stopping: - if task.done(): - task = await self._conn.connect(max_tries=max_tries, delay=delay) - if not task: - return - - await task - await self.controller.on_disconnected() - - self.stopping = False - - async def stop(self): - """ - await stop() - - Close the connection to the room without reconnecting. - """ - - self._stopping = True - await self._conn.stop() - - if self._runtask: - await self._runtask - - - - # CATEGORY: SESSION COMMANDS - - async def auth(self, atype, passcode=None): - """ - success, reason=None = await auth(atype, passcode=None) - - From api.euphoria.io: - The auth command attempts to join a private room. It should be sent in - response to a bounce-event at the beginning of a session. - - The auth-reply packet reports whether the auth command succeeded. - """ - - data = {"type": atype} - if passcode: - data["passcode"] = passcode - - response = await self._send_packet("auth", data) - rdata = response.get("data") - - success = rdata.get("success") - reason = rdata.get("reason", None) - return success, reason - - async def ping_reply(self, time): - """ - await ping_reply(time) - - From api.euphoria.io: - The ping command initiates a client-to-server ping. The server will - send back a ping-reply with the same timestamp as soon as possible. - - ping-reply is a response to a ping command or ping-event. - """ - - data = {"time": time} - await self._conn.send("ping-reply", data, await_response=False) - - # CATEGORY: CHAT ROOM COMMANDS - - async def get_message(self, message_id): - """ - message = await get_message(message_id) - - From api.euphoria.io: - The get-message command retrieves the full content of a single message - in the room. - - get-message-reply returns the message retrieved by get-message. - """ - - data = {"id": message_id} - - response = await self._send_packet("get-message", data) - rdata = response.get("data") - - message = Message.from_dict(rdata) - return message - - async def log(self, n, before=None): - """ - log, before=None = await log(n, before=None) - - From api.euphoria.io: - The log command requests messages from the room’s message log. This can - be used to supplement the log provided by snapshot-event (for example, - when scrolling back further in history). - - The log-reply packet returns a list of messages from the room’s message - """ - - data = {"n": n} - if before: - data["before"] = before - - response = await self._send_packet("log", data) - rdata = response.get("data") - - messages = [Message.from_dict(d) for d in rdata.get("log")] - before = rdata.get("before", None) - return messages, before - - async def nick(self, name): - """ - session_id, user_id, from_nick, to_nick = await nick(name) - - From api.euphoria.io: - The nick command sets the name you present to the room. This name - applies to all messages sent during this session, until the nick - command is called again. - - nick-reply confirms the nick command. It returns the session’s former - and new names (the server may modify the requested nick). - """ - - data = {"name": name} - - response = await self._send_packet("nick", data) - rdata = response.get("data") - - session_id = rdata.get("session_id") - user_id = rdata.get("id") - from_nick = rdata.get("from") - to_nick = rdata.get("to") - - # update self.session - self.session.nick = to_nick - - return session_id, user_id, from_nick, to_nick - - async def pm_initiate(self, user_id): - """ - pm_id, to_nick = await pm_initiate(user_id) - - From api.euphoria.io: - The pm-initiate command constructs a virtual room for private messaging - between the client and the given UserID. - - The pm-initiate-reply provides the PMID for the requested private - messaging room. - """ - - data = {"user_id": user_id} - - response = await self._send_packet("pm-initiate", data) - rdata = response.get("data") - - pm_id = rdata.get("pm_id") - to_nick = rdata.get("to_nick") - return pm_id, to_nick - - async def send(self, content, parent=None): - """ - message = await send(content, parent=None) - - From api.euphoria.io: - The send command sends a message to a room. The session must be - successfully joined with the room. This message will be broadcast to - all sessions joined with the room. - - If the room is private, then the message content will be encrypted - before it is stored and broadcast to the rest of the room. - - The caller of this command will not receive the corresponding - send-event, but will receive the same information in the send-reply. - """ - - data = {"content": content} - if parent: - data["parent"] = parent - - response = await self._send_packet("send", data) - rdata = response.get("data") - - message = Message.from_dict(rdata) - return message - + data = await self._send_while_connected( + "send", + content=content + ) + + return Message.from_dict(data) + async def who(self): - """ - sessions = await who() - - From api.euphoria.io: - The who command requests a list of sessions currently joined in the - room. - - The who-reply packet lists the sessions currently joined in the room. - """ - - response = await self._send_packet("who") - rdata = response.get("data") - - sessions = [Session.from_dict(d) for d in rdata.get("listing")] - - # update self.listing - self.listing = Listing() - for session in sessions: - if not session.sid == self.session.sid: - self.listing.add(session) - - return sessions - - # CATEGORY: ACCOUNT COMMANDS - # NYI, and probably never will - - # CATEGORY: ROOM HOST COMMANDS - # NYI, and probably never will - - # CATEGORY: STAFF COMMANDS - # NYI, and probably never will - - - - # All the private functions for dealing with stuff - - def _add_callbacks(self): - """ - _add_callbacks() - - Adds the functions that handle server events to the callbacks for that - event. - """ - - self._callbacks.add("bounce-event", self._handle_bounce) - self._callbacks.add("disconnect-event", self._handle_disconnect) - self._callbacks.add("hello-event", self._handle_hello) - self._callbacks.add("join-event", self._handle_join) - self._callbacks.add("login-event", self._handle_login) - self._callbacks.add("logout-event", self._handle_logout) - self._callbacks.add("network-event", self._handle_network) - self._callbacks.add("nick-event", self._handle_nick) - self._callbacks.add("edit-message-event", self._handle_edit_message) - self._callbacks.add("part-event", self._handle_part) - self._callbacks.add("ping-event", self._handle_ping) - self._callbacks.add("pm-initiate-event", self._handle_pm_initiate) - self._callbacks.add("send-event", self._handle_send) - self._callbacks.add("snapshot-event", self._handle_snapshot) - - async def _send_packet(self, *args, **kwargs): - """ - reply_packet = await _send_packet(*args, **kwargs) - - Like self._conn.send, but checks for an error on the packet and raises - the corresponding exception. - """ - - response = await self._conn.send(*args, **kwargs) - self._check_for_errors(response) - - return response - - async def _handle_packet(self, packet): - """ - await _handle_packet(packet) - - Call the correct callbacks to deal with packet. - - This function catches CancelledErrors and instead displays an info so - the console doesn't show stack traces when a bot loses connection. - """ - - self._check_for_errors(packet) - - ptype = packet.get("type") - try: - await self._callbacks.call(ptype, packet) - except asyncio.CancelledError as e: - logger.info(f"&{self.roomname}: Callback of type {ptype!r} cancelled.") - #raise # not necessary? - - def _check_for_errors(self, packet): - """ - _check_for_errors(packet) - - Checks for an error on the packet and raises the corresponding - exception. - """ - - if packet.get("throttled", False): - logger.warn(f"&{self.roomname}: Throttled for reason: {packet.get('throttled_reason', 'no reason')!r}") - - if "error" in packet: - raise ResponseError(packet.get("error")) - - async def _handle_bounce(self, packet): - """ - From api.euphoria.io: - A bounce-event indicates that access to a room is denied. - """ - - data = packet.get("data") - - await self.controller.on_bounce( - reason=data.get("reason", None), - auth_options=data.get("auth_options", None), - agent_id=data.get("agent_id", None), - ip=data.get("ip", None) - ) - - async def _handle_disconnect(self, packet): - """ - From api.euphoria.io: - A disconnect-event indicates that the session is being closed. The - client will subsequently be disconnected. - - If the disconnect reason is “authentication changed”, the client should - immediately reconnect. - """ - - data = packet.get("data") - - await self.controller.on_disconnect(data.get("reason")) - - async def _handle_hello(self, packet): - """ - From api.euphoria.io: - A hello-event is sent by the server to the client when a session is - started. It includes information about the client’s authentication and - associated identity. - """ - - data = packet.get("data") - self.session = Session.from_dict(data.get("session")) - self.room_is_private = data.get("room_is_private") - self.version = data.get("version") - self.account = data.get("account", None) - self.account_has_access = data.get("account_has_access", None) - self.account_email_verified = data.get("account_email_verified", None) - - await self.controller.on_hello( - data.get("id"), - self.session, - self.room_is_private, - self.version, - account=self.account, - account_has_access=self.account_has_access, - account_email_verified=self.account_email_verified - ) - - async def _handle_join(self, packet): - """ - From api.euphoria.io: - A join-event indicates a session just joined the room. - """ - - data = packet.get("data") - session = Session.from_dict(data) - - # update self.listing - self.listing.add(session) - - await self.controller.on_join(session) - - async def _handle_login(self, packet): - """ - From api.euphoria.io: - The login-event packet is sent to all sessions of an agent when that - agent is logged in (except for the session that issued the login - command). - """ - - data = packet.get("data") - - await self.controller.on_login(data.get("account_id")) - - async def _handle_logout(self, packet): - """ - From api.euphoria.io: - The logout-event packet is sent to all sessions of an agent when that - agent is logged out (except for the session that issued the logout - command). - """ - - await self.controller.on_logout() - - async def _handle_network(self, packet): - """ - From api.euphoria.io: - A network-event indicates some server-side event that impacts the - presence of sessions in a room. - - If the network event type is partition, then this should be treated as - a part-event for all sessions connected to the same server id/era - combo. - """ - - data = packet.get("data") - server_id = data.get("server_id") - server_era = data.get("server_era") - - # update self.listing - self.listing.remove_combo(server_id, server_era) - - await self.controller.on_network(server_id, server_era) - - async def _handle_nick(self, packet): - """ - From api.euphoria.io: - nick-event announces a nick change by another session in the room. - """ - - data = packet.get("data") - session_id = data.get("session_id") - to_nick = data.get("to") - - # update self.listing - session = self.listing.by_sid(session_id) - if session: - session.nick = to_nick - - await self.controller.on_nick( - session_id, - data.get("id"), - data.get("from"), - to_nick - ) - - async def _handle_edit_message(self, packet): - """ - From api.euphoria.io: - An edit-message-event indicates that a message in the room has been - modified or deleted. If the client offers a user interface and the - indicated message is currently displayed, it should update its display - accordingly. - - The event packet includes a snapshot of the message post-edit. - """ - - data = packet.get("data") - message = Message.from_dict(data) - - await self.controller.on_edit_message( - data.get("edit_id"), - message - ) - - async def _handle_part(self, packet): - """ - From api.euphoria.io: - A part-event indicates a session just disconnected from the room. - """ - - data = packet.get("data") - session = Session.from_dict(data) - - # update self.listing - self.listing.remove(session.session_id) - - await self.controller.on_part(session) - - async def _handle_ping(self, packet): - """ - From api.euphoria.io: - A ping-event represents a server-to-client ping. The client should send - back a ping-reply with the same value for the time field as soon as - possible (or risk disconnection). - """ - - data = packet.get("data") - - await self.controller.on_ping( - data.get("time"), - data.get("next") - ) - - async def _handle_pm_initiate(self, packet): - """ - From api.euphoria.io: - The pm-initiate-event informs the client that another user wants to - chat with them privately. - """ - - data = packet.get("data") - - await self.controller.on_pm_initiate( - data.get("from"), - data.get("from_nick"), - data.get("from_room"), - data.get("pm_id") - ) - - async def _handle_send(self, packet): - """ - From api.euphoria.io: - A send-event indicates a message received by the room from another - session. - """ - - data = packet.get("data") - message = Message.from_dict(data) - - await self.controller.on_send(message) - - async def _handle_snapshot(self, packet): - """ - From api.euphoria.io: - A snapshot-event indicates that a session has successfully joined a - room. It also offers a snapshot of the room’s state and recent history. - """ - - data = packet.get("data") - - sessions = [Session.from_dict(d) for d in data.get("listing")] - messages = [Message.from_dict(d) for d in data.get("log")] - - # update self.listing - for session in sessions: - self.listing.add(session) - - self.session.nick = data.get("nick", None) - - self.pm_with_nick = data.get("pm_with_nick", None), - self.pm_with_user_id = data.get("pm_with_user_id", None) - - await self.controller.on_connected() - - await self.controller.on_snapshot( - data.get("identity"), - data.get("session_id"), - self.version, - sessions, # listing - messages, # log - nick=self.session.nick, - pm_with_nick=self.pm_with_nick, - pm_with_user_id=self.pm_with_user_id - ) + pass + +# COMMUNICATION WITH CONNECTION + + async def _receive_packet(self, ptype, data, error, throttled): + pass # TODO + + async def _disconnected(self): + pass # TODO + +# SOME USEFUL PUBLIC METHODS + + @staticmethod + def format_room_url(roomname, private=False, human=False): + if private: + roomname = f"pm:{roomname}" + + url = f"wss://euphoria.io/room/{roomname}/ws" + + if human: + url = f"{url}?h=1" + + return url + + async def connected(self): + pass + +# REST OF THE IMPLEMENTATION + + async def _run(self): + pass + + async def _send_while_connected(*args, **kwargs): + while True: + try: + await self.connected() + if not self._status != Room._CONNECTED: continue # TODO: Figure out a good solution + return await self._connection.send(*args, **kwargs) + except RoomDisconnected: + pass # Just try again + + +class Inhabitant: + """ + TODO + """ + +# ROOM EVENTS +# These functions are called by the room when something happens. +# They're launched via asyncio.create_task(), so they don't block execution of the room. +# Just overwrite the events you need (make sure to keep the arguments the same though). + + async def disconnected(self, room): + pass + + async def connected(self, room, log): + pass + + async def join(self, room, session): + pass + + async def part(self, room, session): + pass + + async def nick(self, room, sid, uid, from_nick, to_nick): + pass + + async def send(self, room, message): + pass + + async def pm(self, room, from_uid, from_nick, from_room, pm_id): + pass