diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 681e168..97b805a 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -13,9 +13,13 @@ logging.getLogger(__name__).setLevel(logging.DEBUG) from .cookiejar import * from .connection import * from .exceptions import * +from .room import * +from utils import * __all__ = ( connection.__all__ + cookiejar.__all__ + - exceptions.__all__ + exceptions.__all__ + + room.__all__ + + utils.__all__ ) diff --git a/yaboli/connection.py b/yaboli/connection.py index 94cfc57..679425c 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -4,7 +4,7 @@ import logging import socket import websockets -from .exceptions import ConnectionClosed +from .exceptions import * logger = logging.getLogger(__name__) @@ -33,7 +33,7 @@ class Connection: async def send(self, ptype, data=None, await_response=True): if not self._ws: - raise exceptions.ConnectionClosed + raise ConnectionClosed #raise asyncio.CancelledError pid = str(self._new_pid()) @@ -63,9 +63,16 @@ class Connection: """ self._stopped = True - if self._ws: - await self._ws.close() # _run() does the cleaning up now. + await self.reconnect() # _run() does the cleaning up now. await self._runtask + + async def reconnect(self): + """ + Reconnect to the url. + """ + + if self._ws: + await self._ws.close() async def _connect(self, tries): """ @@ -116,6 +123,8 @@ class Connection: - make sure the ping task has finished """ + asyncio.create_task(self.disconnect_callback()) + # stop ping task if self._pingtask: self._pingtask.cancel() @@ -131,7 +140,7 @@ class Connection: # 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")) + future.set_exception(ConnectionClosed("No server response")) self._pending_responses = {} async def _run(self): @@ -164,7 +173,7 @@ class Connection: await asyncio.sleep(self.ping_delay) except asyncio.TimeoutError: logger.warning("Ping timed out.") - await self._ws.close() # trigger a reconnect attempt + await self.reconnect() except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): pass diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py index 4aaa8e0..f9cce45 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -2,3 +2,12 @@ __all__ = ["ConnectionClosed"] class ConnectionClosed(Exception): pass + +class RoomException(Exception): + pass + +class AuthenticationRequired(RoomException): + pass + +class RoomClosed(RoomException): + pass diff --git a/yaboli/room.py b/yaboli/room.py index cfc6ad7..0e78eaa 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,3 +1,8 @@ +from .connection import * +from .exceptions import * +from .utils import * + + __all__ == ["Room", "Inhabitant"] @@ -6,18 +11,24 @@ class Room: TODO """ + CONNECTED = 1 + DISCONNECTED = 2 + EXITED = 3 + 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.password = password + self.human = human + self.session = None self.account = None - self.listing = None # TODO + self.listing = Listing() + self.account_has_access = None self.account_email_verified = None self.room_is_private = None @@ -25,10 +36,20 @@ class Room: self.pm_with_nick = None self.pm_with_user_id = None - #asyncio.create_task(self._run()) + self._inhabitant = inhabitant + self._status = Room.DISCONNECTED + + # TODO: Allow for all parameters of Connection() to be specified in Room(). + self._connection = Connection( + self.format_room_url(self.roomname, human=self.human), + self._receive_packet, + self._disconnected, + cookiejar + ) async def exit(self): - pass + self._status = Room.EXITED + await self._connection.stop() # ROOM COMMANDS # These always return a response from the server. @@ -72,11 +93,137 @@ class Room: # COMMUNICATION WITH CONNECTION - async def _receive_packet(self, ptype, data, error, throttled): - pass # TODO - async def _disconnected(self): - pass # TODO + # While disconnected, keep the last known session info, listing etc. + # All of this is instead reset when the hello/snapshot events are received. + self.status = Room.DISCONNECTED + self._connected_future = asyncio.Future() + + await self._inhabitant.disconnected(self) + + async def _receive_packet(self, ptype, data, error, throttled): + # Ignoring errors and throttling for now + functions = { + "bounce-event": self._event_bounce, + #"disconnect-event": self._event_disconnect, # Not important, can ignore + "hello-event": self._event_hello, + "join-event": self._event_join, + #"login-event": self._event_login, + #"logout-event": self._event_logout, + "network-event": self._event_network, + "nick-event": self._event_nick, + #"edit-message-event": self._event_edit_message, + "part-event": self._event_part, + "ping-event": self._event_ping, + "pm-initiate-event": self._event_pm_initiate, + "send-event": self._event_send, + "snapshot-event": self._event_snapshot, + } + + function = functions.get(ptype) + if function: + await function(data) + + async def _event_bounce(self, data): + if self.password is not None: + try: + response = await self._connection.send("auth", type=passcode, passcode=self.password) + rdata = response.get("data") + success = rdata.get("success") + if not success: + reason = rdata.get("reason") + raise AuthenticationRequired(f"Could not join &{self.roomname}: {reason}") + except ConnectionClosed: + pass + else: + raise AuthenticationRequired(f"&{self.roomname} is password locked but no password was given") + + async def _event_hello(self, 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) + + async def _event_join(self, data): + session = Session.from_dict(data) + self.listing.add(session) + await self._inhabitant.join(self, session) + + async def _event_network(self, data): + server_id = data.get("server_id") + server_era = data.get("server_era") + + sessions = self.listing.remove_combo(server_id, server_era) + for session in sessions: + await self._inhabitant.part(self, session) + + async def _event_nick(self, data): + sid = data.get("session_id") + uid = data.get("user_id") + from_nick = data.get("from") + to_nick = data.get("to") + + session = self.listing.by_sid(sid) + if session: + session.nick = to_nick + + await self._inhabitant.nick(self, sid, uid, from_nick, to_nick) + + async def _event_part(self, data): + session = Session.from_dict(data) + self.listing.remove(session.sid) + await self._inhabitant.part(self, session) + + async def _event_ping(self, data): + try: + self._connection.send() + except exceptions.ConnectionClosed: + pass + + async def _event_pm_initiate(self, data): + from_uid = data.get("from") + from_nick = data.get("from_nick") + from_room = data.get("from_room") + pm_id = data.get("pm_id") + + await self._inhabitant.pm(self, from_uid, from_nick, from_room, pm_id) + + async def _event_send(self, data): + pass # TODO X + # TODO: Figure out a way to bring fast-forwarding into this + + async def _event_snapshot(self, data): + # update listing + self.listing = Listing() + sessions = [Session.from_dict(d) for d in data.get("listing")] + for session in sessions: + self.listing.add(session) + + # update (and possibly set) nick + new_nick = data.get("nick", None) + if self.session: + prev_nick = self.session.nick + if new_nick != prev_nick: + self.nick(prev_nick) + self.session.nick = new_nick + + # update more room info + self.pm_with_nick = data.get("pm_with_nick", None), + self.pm_with_user_id = data.get("pm_with_user_id", None) + + # Now, we're finally connected again! + self.status = Room.CONNECTED + if not self._connected_future.done(): # should always be True, I think + self._connected_future.set_result(None) + + # Let's let the inhabitant know. + log = [Message.from_dict(m) for m in data.get("log")] + await self._inhabitant.connected(self, log) + + # TODO: Figure out a way to bring fast-forwarding into this + # Should probably happen where this comment is # SOME USEFUL PUBLIC METHODS @@ -93,21 +240,20 @@ class Room: return url async def connected(self): - pass + await self._connected_future # REST OF THE IMPLEMENTATION - async def _run(self): - pass - async def _send_while_connected(*args, **kwargs): while True: + if self._status == Room.CLOSED: + raise RoomClosed() + 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 + except ConnectionClosed: + pass # just try again class Inhabitant: @@ -138,5 +284,8 @@ class Inhabitant: async def send(self, room, message): pass + async def fast_forward(self, room, message): + pass + async def pm(self, room, from_uid, from_nick, from_room, pm_id): pass diff --git a/yaboli/utils.py b/yaboli/utils.py index 0f81076..d6ded92 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,55 +1,54 @@ import asyncio -import logging +#import logging import time -logger = logging.getLogger(__name__) +#logger = logging.getLogger(__name__) __all__ = [ - "run_controller", "run_bot", + #"run_controller", "run_bot", "mention", "mention_reduced", "similar", "format_time", "format_time_delta", "Session", "Listing", "Message", "Log", - "ResponseError" ] -def run_controller(controller, room): - """ - Helper function to run a singular controller. - """ - - async def run(): - task, reason = await controller.connect(room) - if task: - await task - else: - logger.warn(f"Could not connect to &{room}: {reason!r}") - - asyncio.get_event_loop().run_until_complete(run()) - -def run_bot(bot_class, room, *args, **kwargs): - """ - Helper function to run a bot. To run Multibots, use the MultibotKeeper. - This restarts the bot when it is explicitly restarted through Bot.restart(). - """ - - async def run(): - while True: - logger.info(f"Creating new instance and connecting to &{room}") - bot = bot_class(*args, **kwargs) - task, reason = await bot.connect(room) - if task: - await task - else: - logger.warn(f"Could not connect to &{room}: {reason!r}") - - if bot.restarting: - logger.info(f"Restarting in &{room}") - else: - break - - asyncio.get_event_loop().run_until_complete(run()) +#def run_controller(controller, room): +# """ +# Helper function to run a singular controller. +# """ +# +# async def run(): +# task, reason = await controller.connect(room) +# if task: +# await task +# else: +# logger.warn(f"Could not connect to &{room}: {reason!r}") +# +# asyncio.get_event_loop().run_until_complete(run()) +# +#def run_bot(bot_class, room, *args, **kwargs): +# """ +# Helper function to run a bot. To run Multibots, use the MultibotKeeper. +# This restarts the bot when it is explicitly restarted through Bot.restart(). +# """ +# +# async def run(): +# while True: +# logger.info(f"Creating new instance and connecting to &{room}") +# bot = bot_class(*args, **kwargs) +# task, reason = await bot.connect(room) +# if task: +# await task +# else: +# logger.warn(f"Could not connect to &{room}: {reason!r}") +# +# if bot.restarting: +# logger.info(f"Restarting in &{room}") +# else: +# break +# +# asyncio.get_event_loop().run_until_complete(run()) def mention(nick): return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace()) @@ -157,8 +156,13 @@ class Listing: self._sessions.pop(session_id) def remove_combo(self, server_id, server_era): + removed = [ses for ses in self._sessions.items() + if ses.server_id == server_id and ses.server_era == server_era] + self._sessions = {i: ses for i, ses in self._sessions.items() if ses.server_id != server_id and ses.server_era != server_era} + + return removed def by_sid(self, session_id): return self._sessions.get(session_id); @@ -226,9 +230,3 @@ class Message(): d.get("deleted", None), d.get("truncated", None) ) - -class Log: - pass # TODO - -class ResponseError(Exception): - pass