From 97b98c29f7d48a4faae24f69dc0750c67e0504c7 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 1 Sep 2017 22:12:11 +0000 Subject: [PATCH 01/12] Start the rewrite (again) --- yaboli/TestBot.py | 15 + yaboli/asynciotest.py | 25 ++ yaboli/connection.py | 283 +++++------------ yaboli/controller.py | 122 ++++++++ yaboli/room.py | 707 +++++++----------------------------------- 5 files changed, 351 insertions(+), 801 deletions(-) create mode 100644 yaboli/TestBot.py create mode 100644 yaboli/asynciotest.py create mode 100644 yaboli/controller.py diff --git a/yaboli/TestBot.py b/yaboli/TestBot.py new file mode 100644 index 0000000..6c470fe --- /dev/null +++ b/yaboli/TestBot.py @@ -0,0 +1,15 @@ +import asyncio +from controller import Bot + + + +class TestBot(Bot): + def __init__(self): + pass + + async def on_connected(self): + await self.room.set_nick("TestBot") + +if __name__ == "__main__": + bot = TestBot() + asyncio.get_event_loop().run_until_complete(bot.run()) diff --git a/yaboli/asynciotest.py b/yaboli/asynciotest.py new file mode 100644 index 0000000..7d619a0 --- /dev/null +++ b/yaboli/asynciotest.py @@ -0,0 +1,25 @@ +import asyncio + +async def create(): + await asyncio.sleep(3.0) + print("(1) create file") + +async def write(): + await asyncio.sleep(1.0) + print("(2) write into file") + +async def close(): + print("(3) close file") + +async def test(): + await create() + await write() + await close() + await asyncio.sleep(2.0) + loop.stop() + +loop = asyncio.get_event_loop() +asyncio.ensure_future(test()) +loop.run_forever() +print("Pending tasks at exit: %s" % asyncio.Task.all_tasks(loop)) +loop.close() diff --git a/yaboli/connection.py b/yaboli/connection.py index 57731b8..5ab703f 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -1,229 +1,96 @@ +import logging +logging.basicConfig(level=logging.DEBUG) +import asyncio +asyncio.get_event_loop().set_debug(True) + import json -import time -import threading -import websocket -from websocket import WebSocketException as WSException +import websockets +from websockets import ConnectionClosed -from . import callbacks -class Connection(): - """ - Stays connected to a room in its own thread. - Callback functions are called when a packet is received. - - Callbacks: - - all the message types from api.euphoria.io - These pass the packet data as argument to the called functions. - The other callbacks don't pass any special arguments. - - "connect" - - "disconnect" - - "stop" - """ - - ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" - - def __init__(self, room, url_format=None): - """ - room - name of the room to connect to + +class Connection: + def __init__(self, url, packet_hook, cookie=None): + self.url = url + self.cookie = cookie + self.packet_hook = packet_hook - """ - - self.room = room - - if not url_format: - url_format = self.ROOM_FORMAT - self._url = url_format.format(self.room) - - self._stopping = False + stopped = False self._ws = None - self._thread = None - self._send_id = 0 - self._callbacks = callbacks.Callbacks() - self._id_callbacks = callbacks.Callbacks() + self._pid = 0 + self._pending_responses = {} - def _connect(self, tries=-1, delay=10): - """ - _connect(tries, delay) -> bool + async def run(self): + self._ws = await websockets.connect(self.url) - tries - maximum number of retries - -1 -> retry indefinitely - - Returns True on success, False on failure. - - Connect to the room. - """ - - while tries != 0: - try: - self._ws = websocket.create_connection( - self._url, - enable_multithread=True - ) - - self._callbacks.call("connect") - - return True - except WSException: - if tries > 0: - tries -= 1 - if tries != 0: - time.sleep(delay) - return False - - def disconnect(self): - """ - disconnect() -> None - - Reconnect to the room. - WARNING: To completely disconnect, use stop(). - """ - - if self._ws: - self._ws.close() + try: + while True: + response = await self._ws.recv() + asyncio.ensure_future(self._handle_json(response)) + except websockets.ConnectionClosed: + pass + finally: + await self._ws.close() # just to make sure it's closed self._ws = None - - self._callbacks.call("disconnect") + stopped = True + + for futures in self._pending_responses: + for future in futures: + future.set_error(ConnectionClosed) + future.cancel() - def launch(self): - """ - launch() -> Thread + async def stop(self): + if not stopped and self._ws: + await self._ws.close() + + async def send(ptype, data=None, await_response=True): + if stopped: + raise ConnectionClosed - Connect to the room and spawn a new thread running run. - """ + pid = self._new_pid() + packet["type"] = ptype + packet["data"] = data + packet["id"] = pid - if self._connect(tries=1): - self._thread = threading.Thread(target=self._run, - name="{}-{}".format(self.room, int(time.time()))) - self._thread.start() - return self._thread + if await_response: + wait_for = self._wait_for_response(pid) + await self._ws.send(json.dumps(packet)) + await wait_for + return wait_for.result() else: - self.stop() + await self._ws.send(json.dumps(packet)) - def _run(self): - """ - _run() -> None - - Receive messages. - """ - - while not self._stopping: - try: - self._handle_json(self._ws.recv()) - except (WSException, ConnectionResetError): - if not self._stopping: - self.disconnect() - self._connect() + def _new_pid(self): + self._pid += 1 + return self._pid - def stop(self): - """ - stop() -> None + async def _handle_json(text): + packet = json.loads(text) - Close the connection to the room. - Joins the thread launched by self.launch(). - """ + # Deal with pending responses + pid = packet.get("id") + for future in self._pending_responses.pop(pid, []): + future.set_result(packet) - self._stopping = True - self.disconnect() - - self._callbacks.call("stop") - - if self._thread and self._thread != threading.current_thread(): - self._thread.join() + # Pass packet onto room + await self.packet_hook(packet) - def next_id(self): - """ - next_id() -> id + def _wait_for_response(pid): + future = asyncio.Future() - Returns the id that will be used for the next package. - """ + if pid not in self._pending_responses: + self._pending_responses[pid] = [] + self._pending_responses[pid].append(future) - return str(self._send_id) + return future + +def do_nothing(*args, **kwargs): + pass + +def run(): + conn = Connection("wss://echo.websocket.org", do_nothing) + loop = asyncio.get_event_loop() + #loop.call_later(3, conn.stop) - def add_callback(self, ptype, callback, *args, **kwargs): - """ - add_callback(ptype, callback, *args, **kwargs) -> None - - Add a function to be called when a packet of type ptype is received. - """ - - self._callbacks.add(ptype, callback, *args, **kwargs) - - def add_id_callback(self, pid, callback, *args, **kwargs): - """ - add_id_callback(pid, callback, *args, **kwargs) -> None - - Add a function to be called when a packet with id pid is received. - """ - - self._id_callbacks.add(pid, callback, *args, **kwargs) - - def add_next_callback(self, callback, *args, **kwargs): - """ - add_next_callback(callback, *args, **kwargs) -> None - - Add a function to be called when the answer to the next message sent is received. - """ - - self._id_callbacks.add(self.next_id(), callback, *args, **kwargs) - - def _handle_json(self, data): - """ - handle_json(data) -> None - - Handle incoming 'raw' data. - """ - - packet = json.loads(data) - self._handle_packet(packet) - - def _handle_packet(self, packet): - """ - _handle_packet(ptype, data) -> None - - Handle incoming packets - """ - - if "data" in packet: - data = packet["data"] - else: - data = None - - if "error" in packet: - error = packet["error"] - else: - error = None - - self._callbacks.call(packet["type"], data, error) - - if "id" in packet: - self._id_callbacks.call(packet["id"], data, error) - self._id_callbacks.remove(packet["id"]) - - def _send_json(self, data): - """ - _send_json(data) -> None - - Send 'raw' json. - """ - - if self._ws: - try: - self._ws.send(json.dumps(data)) - except WSException: - self.disconnect() - - def send_packet(self, ptype, **kwargs): - """ - send_packet(ptype, **kwargs) -> None - - Send a formatted packet. - """ - - packet = { - "type": ptype, - "data": kwargs or None, - "id": str(self._send_id) - } - self._send_id += 1 - self._send_json(packet) + loop.run_until_complete(asyncio.ensure_future(conn.run())) diff --git a/yaboli/controller.py b/yaboli/controller.py new file mode 100644 index 0000000..dde19df --- /dev/null +++ b/yaboli/controller.py @@ -0,0 +1,122 @@ +from .room import Room + + + +class Controller: + """ + Callback order: + + on_start - self.room not available + while running: + + on_ping - always possible (until on_disconnected) + on_bounce - self.room only session + on_hello - self.room only session + + on_connected - self.room session and chat room (fully connected) + on_snapshot - self.room session and chat room + - self.room session and chat room + + on_disconnected - self.room not connected to room any more + on_stop - self.room not available + + """ + + def __init__(self, roomname, human=False, cookie=None): + """ + roomname - name of room to connect to + human - whether the human flag should be set on connections + cookie - cookie to use in HTTP request, if any + """ + + self.roomname = roomname + self.human = human + self.cookie = cookie + + self.room = None + self.running = True + + async def run(self): + await self.on_start() + + while self.running: + self.room = Room(self.roomname, self, self.human, self.cookie) + await self.room.run() + self.room = None + + await self.on_end() + + async def stop(self): + if self.running: + self.running = False + + if self.room: + await self.room.stop() + + # room successfully connected + async def on_connected(self): + """ + Client has successfully (re-)joined the room. + + Use: Actions that are meant to happen upon (re-)connecting to a room, + such as resetting the message history. + """ + + pass + + async def on_disconnected(self): + """ + Client has disconnected from the room. + + This is the last time the old self.room can be accessed. + Use: Reconfigure self before next connection. + Need to store information from old room? + """ + + pass + + async def on_bounce(self, reason=None, auth_options=None, agent_id=None, ip=None): + pass + + async def on_disconnect(self, reason): + pass + + async def on_hello(self, user_id, session, room_is_private, version, account=None, account_has_access=None, account_email_verified=None): + pass + + async def on_join(self, session): + pass + + async def on_login(self, account_id): + pass + + async def on_logout(self): + pass + + async def on_network(self, ntype, server_id, server_era): + pass + + async def on_nick(self, session_id, user_id, from, to): + pass + + async def on_edit_message(self, edit_id, message): + pass + + async def on_part(self, session): + pass + + async def on_ping(self, ptime, pnext): + """ + Default implementation, refer to api.euphoria.io + """ + + await self.room.ping_reply(ptime) + + async def on_pm_initiate(self, from, from_nick, from_room, pm_id): + pass + + async def on_send(self, message): + pass + + async def on_snapshot(self, user_id, snapshot_id, version, listing, log, nick=None, pm_with_nick=None, pm_with_user_id=None): + pass diff --git a/yaboli/room.py b/yaboli/room.py index 360e0e4..7d4dc0c 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,617 +1,138 @@ -import time +import asyncio +from .connection import Connection -from . import connection -from . import message -from . import messages -from . import session -from . import sessions -from . import callbacks - -class Room(): - """ - Connects to and provides more abstract access to a room on euphoria. +class Room: + ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" + HUMAN_FORMAT = f"{ROOM_FORMAT}?h=1" - callback (values passed) - description - ---------------------------------------------------------------------------------- - delete (message) - message has been deleted - edit (message) - message has been edited - identity - own session or nick has changed - join (session) - user has joined the room - message (message) - message has been sent - messages - message data has changed - nick (session, old, new) - user has changed their nick - part (session) - user has left the room - ping - ping event has happened - room - room info has changed - sessions - session data has changed - change - room has been changed - """ - - def __init__(self, room=None, nick=None, password=None, message_limit=500): - """ - room - name of the room to connect to - nick - nick to assume, None -> no nick - password - room password (in case the room is private) - message_limit - maximum amount of messages that will be stored at a time - None - no limit - """ + def __init__(self, roomname, controller, human=False, cookie=None): + self.roomname = roomname + self.controller = controller + self.human = human + self.cookie = cookie - self.room = room - self.password = password - self.room_is_private = None - self.pm_with_nick = None - self.pm_with_user = None - - self.nick = nick + # 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.message_limit = message_limit + self.sessions = None - self.ping_last = 0 - self.ping_next = 0 - self.ping_offset = 0 # difference between server and local time + self._callbacks = {} + self._add_callbacks() - self._messages = None - self._sessions = None - - self._callbacks = callbacks.Callbacks() - - self._con = None - - if self.room: - self.change(self.room, password=self.password) - - def launch(self): - """ - launch() -> Thread - - Open connection in a new thread (see connection.Connection.launch). - """ - - return self._con.launch() - - def stop(self): - """ - stop() -> None - - Close connection to room. - """ - - self._con.stop() - - def change(self, room, password=None): - """ - change(room) -> None - - Leave current room (if already connected) and join new room. - Clears all messages and sessions. - A call to launch() is necessary to start a new thread again. - """ - - if self._con: - self._con.stop() - - self.room = room - self.password = password - self.room_is_private = None - self.pm_with_nick = None - self.pm_with_user = None - - self.session = None - - self.ping_last = 0 - self.ping_next = 0 - self.ping_offset = 0 # difference between server and local time - - self._messages = messages.Messages(message_limit=self.message_limit) - self._sessions = sessions.Sessions() - - self._con = connection.Connection(self.room) - - self._con.add_callback("bounce-event", self._handle_bounce_event) - self._con.add_callback("disconnect-event", self._handle_disconnect_event) - self._con.add_callback("hello-event", self._handle_hello_event) - self._con.add_callback("join-event", self._handle_join_event) - self._con.add_callback("network-event", self._handle_network_event) - self._con.add_callback("nick-event", self._handle_nick_event) - self._con.add_callback("edit-message-event", self._handle_edit_message_event) - self._con.add_callback("part-event", self._handle_part_event) - self._con.add_callback("ping-event", self._handle_ping_event) - self._con.add_callback("send-event", self._handle_send_event) - self._con.add_callback("snapshot-event", self._handle_snapshot_event) - - self._callbacks.call("change") - - def add_callback(self, event, callback, *args, **kwargs): - """ - add_callback(ptype, callback, *args, **kwargs) -> None - - Add a function to be called when a certain event happens. - """ - - self._callbacks.add(event, callback, *args, **kwargs) - - def get_msg(self, mid): - """ - get_msg(message_id) -> Message - - Returns the message with the given id, if found. - """ - - return self._messages.get(mid) - - def get_msg_parent(self, mid): - """ - get_msg_parent(message_id) -> Message - - Returns the message's parent, if found. - """ - - return self._messages.get_parent(mid) - - def get_msg_children(self, mid): - """ - get_msg_children(message_id) -> list - - Returns a sorted list of children of the given message, if found. - """ - - return self._messages.get_children(mid) - - def get_msg_top_level(self): - """ - get_msg_top_level() -> list - - Returns a sorted list of top-level messages. - """ - - return self._messages.get_top_level() - - def get_msg_oldest(self): - """ - get_msg_oldest() -> Message - - Returns the oldest message, if found. - """ - - return self._messages.get_oldest() - - def get_msg_youngest(self): - """ - get_msg_youngest() -> Message - - Returns the youngest message, if found. - """ - - return self._messages.get_youngest() - - def get_session(self, sid): - """ - get_session(session_id) -> Session - - Returns the session with that id. - """ - - return self._sessions.get(sid) - - def get_sessions(self): - """ - get_sessions() -> list - - Returns the full list of sessions. - """ - - return self._sessions.get_all() - - def get_people(self): - """ - get_people() -> list - - Returns a list of all non-bot and non-lurker sessions. - """ - - return self._sessions.get_people() - - def get_accounts(self): - """ - get_accounts() -> list - - Returns a list of all logged-in sessions. - """ - - return self._sessions.get_accounts() - - def get_agents(self): - """ - get_agents() -> list - - Returns a list of all sessions who are not signed into an account and not bots or lurkers. - """ - - return self._sessions.get_agents() - - def get_bots(self): - """ - get_bots() -> list - - Returns a list of all bot sessions. - """ - - return self._sessions.get_bots() - - def get_lurkers(self): - """ - get_lurkers() -> list - - Returns a list of all lurker sessions. - """ - - return self._sessions.get_lurkers() - - def set_nick(self, nick): - """ - set_nick(nick) -> None - - Change your nick. - """ - - self.nick = nick - - if not self.session or self.session.name != self.nick: - self._con.add_next_callback(self._handle_nick_reply) - self._con.send_packet("nick", name=nick) - - def mentionable(self, nick=None): - """ - mentionable() - - A mentionable version of the nick. - The nick defaults to the bot's nick. - """ - - if nick is None: - nick = self.nick - - return "".join(c for c in nick if not c in ".!?;&<'\"" and not c.isspace()) - - def send_message(self, content, parent=None): - """ - send_message(content, parent) -> None - - Send a message. - """ - - self._con.add_next_callback(self._handle_send_reply) - self._con.send_packet("send", content=content, parent=parent) - - def authenticate(self, password=None): - """ - authenticate(passsword) -> None - - Try to authenticate so you can enter the room. - """ - - self.password = password - - self._con.add_next_callback(self._handle_auth_reply) - self._con.send_packet("auth", type="passcode", passcode=self.password) - - def update_sessions(self): - """ - update_sessions() -> None - - Resets and then updates the list of sessions. - """ - - self._con.add_next_callback(self._handle_who_reply) - self._con.send_packet("who") - - def load_msgs(self, number=50): - """ - load_msgs(number) -> None - - Request a certain number of older messages from the server. - """ - - self._con.add_next_callback(self._handle_log_reply) - self._con.send_packet("log", n=number, before=self.get_msg_oldest().id) - - def load_msg(self, mid): - """ - load_msg(message_id) -> None - - Request an untruncated version of the message with that id. - """ - - self._con.add_next_callback(self._handle_get_message_reply) - self._con.send_packet("get-message", id=mid) - - # ----- HANDLING OF EVENTS ----- - - def _handle_connect(self): - """ - TODO - """ - - self._callbacks.call("connect") - - def _handle_disconnect(self): - """ - TODO - """ - - self._callbacks.call("disconnect") - - def _handle_stop(self): - """ - TODO - """ - - self._callbacks.call("stop") - - def _handle_bounce_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "bounce-event", error) - self.stop() - return - - if self.password is not None: - self.authenticate(self.password) + if human: + url = HUMAN_FORMAT.format(self.roomname) else: - self.stop() + url = ROOM_FORMAT.format(self.roomname) + self._conn = Connection(url, self._handle_packet, self.cookie) - def _handle_disconnect_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "disconnect-event", error) - self.stop() - return - - self._con.disconnect() + async def run(self): + await self._conn.run() - def _handle_hello_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "hello-event", error) - self.stop() - return - - self.session = session.Session.from_data(data["session"]) - self._sessions.add(self.session) - self._callbacks.call("identity") - self._callbacks.call("sessions") - - self.room_is_private = data["room_is_private"] - self._callbacks.call("room") + async def stop(self): + await self._conn.stop() - def _handle_join_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "join-event", error) - self.update_sessions() - return - - ses = session.Session.from_data(data) - self._sessions.add(ses) - self._callbacks.call("join", ses) - self._callbacks.call("sessions") + # CATEGORY: SESSION COMMANDS - def _handle_network_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "network-event", error) - return - - if data["type"] == "partition": - self._sessions.remove_on_network_partition(data["server_id"], data["server_era"]) - self._callbacks.call("sessions") + async def auth(atype, passcode): + pass # TODO - def _handle_nick_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "nick-event", error) - self.update_sessions() - return - - ses = self.get_session(data["session_id"]) - if ses: - ses.name = data["to"] - self._callbacks.call("nick", ses, data["from"], data["to"]) - self._callbacks.call("sessions") + async def ping_reply(time): + pass # TODO - def _handle_edit_message_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "edit-message-event", error) - return - - msg = message.Message.from_data(data) - if msg: - self._messages.add(msg) - - if msg.deleted: - self._callbacks.call("delete", msg) - elif msg.edited: - self._callbacks.call("edit", msg) - - self._callbacks.call("messages") + # CATEGORY: CHAT ROOM COMMANDS - def _handle_part_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "part-event", error) - self.update_sessions() - return - - ses = session.Session.from_data(data) - if ses: - self._sessions.remove(ses.session_id) - - self._callbacks.call("part", ses) - self._callbacks.call("sessions") + async def get_message(message_id): + pass # TODO - def _handle_ping_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "ping-event", error) - return - - self.ping_last = data["time"] - self.ping_next = data["next"] - self.ping_offset = self.ping_last - time.time() - - self._con.send_packet("ping-reply", time=self.ping_last) - self._callbacks.call("ping") + async def log(n, before=None): + pass # TODO - def _handle_send_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "send-event", error) - return - - msg = message.Message.from_data(data) - self._callbacks.call("message", msg) - - self._messages.add(msg) - self._callbacks.call("messages") + async def nick(name): + pass # TODO - def _handle_snapshot_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "snapshot-event", error) - self.stop() - return - - self.set_nick(self.nick) - - if "pm_with_nick" in data or "pm_with_user_id" in data: - if "pm_with_nick" in data: - self.pm_with_nick = data["pm_with_nick"] - if "pm_with_user_id" in data: - self.pm_with_user_id = data["pm_with_user_id"] - self._callbacks.call("room") - - self._sessions.remove_all() - for sesdata in data["listing"]: - self._sessions.add_from_data(sesdata) - self._callbacks.call("sessions") - - self._messages.remove_all() - for msgdata in data["log"]: - self._messages.add_from_data(msgdata) - self._callbacks.call("messages") + async def pm_initiate(user_id): + pass # TODO - # ----- HANDLING OF REPLIES ----- + async def send(content, parent=None): + pass # TODO - def _handle_auth_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "auth-reply", error) - self.stop() - return - - if not data["success"]: - self._con.stop() + async def who() + pass # TODO - def _handle_get_message_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "get-message-reply", error) - return - - self._messages.add_from_data(data) - self._callbacks.call("messages") + # CATEGORY: ACCOUNT COMMANDS + # NYI, and probably never will - def _handle_log_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "log-reply", error) - return - - for msgdata in data["log"]: - self._messages.add_from_data(msgdata) - self._callbacks.call("messages") + # CATEGORY: ROOM HOST COMMANDS + # NYI, and probably never will - def _handle_nick_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "nick-reply", error) - return - - if "to" in data: - self.session.name = self.nick - self._callbacks.call("identity") - - if data["to"] != self.nick: - self.set_nick(self.nick) + # CATEGORY: STAFF COMMANDS + # NYI, and probably never will - def _handle_send_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "send-reply", error) - return - - self._messages.add_from_data(data) - self._callbacks.call("messages") - def _handle_who_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "who-reply", error) - return - - self._sessions.remove_all() - for sesdata in data["listing"]: - self._sessions.add_from_data(sesdata) - self._callbacks.call("sessions") + + # All the private functions for dealing with stuff + + def _add_callbacks(self): + self._callbacks["bounce-event"] = self._handle_bounce + self._callbacks["disconnect-event"] = self._handle_disconnect + self._callbacks["hello-event"] = self._handle_hello + self._callbacks["join-event"] = self._handle_join + self._callbacks["login-event"] = self._handle_login + self._callbacks["logout-event"] = self._handle_logout + self._callbacks["network-event"] = self._handle_network + self._callbacks["nick-event"] = self._handle_nick + self._callbacks["edit-message-event"] = self._handle_edit_message + self._callbacks["part-event"] = self._handle_part + self._callbacks["ping-event"] = self._handle_ping + self._callbacks["pm-initiate-event"] = self._handle_pm_initiate + self._callbacks["send-event"] = self._handle_send + self._callbacks["snapshot-event"] = self._handle_snapshot + + async def _handle_packet(self, packet): + ptype = packet.get("type") + callback = self._callbacks.get(ptype) + if callback: + await callback(packet) + + async def _handle_bounce(self, packet): + pass # TODO + + async def _handle_disconnect(self, packet): + pass # TODO + + async def _handle_hello(self, packet): + pass # TODO + + async def _handle_join(self, packet): + pass # TODO + + async def _handle_login(self, packet): + pass # TODO + + async def _handle_logout(self, packet): + pass # TODO + + async def _handle_network(self, packet): + pass # TODO + + async def _handle_nick(self, packet): + pass # TODO + + async def _handle_edit_message(self, packet): + pass # TODO + + async def _handle_part(self, packet): + pass # TODO + + async def _handle_ping(self, packet): + pass # TODO + + async def _handle_pm_initiate(self, packet): + pass # TODO + + async def _handle_send(self, packet): + pass # TODO + + async def _handle_snapshot(self, packet): + pass # TODO From d0ad542b722699a871fcde22cc8177376ab72be3 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 2 Sep 2017 08:55:50 +0000 Subject: [PATCH 02/12] Fix some bugs. The TestBot can now connect to a room and stay connected. --- yaboli/TestBot.py | 20 +++++++++----- yaboli/connection.py | 6 ++-- yaboli/controller.py | 21 +++++++++----- yaboli/room.py | 66 ++++++++++++++++++++++++++++++++++---------- yaboli/utils.py | 66 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 31 deletions(-) create mode 100644 yaboli/utils.py diff --git a/yaboli/TestBot.py b/yaboli/TestBot.py index 6c470fe..a98f922 100644 --- a/yaboli/TestBot.py +++ b/yaboli/TestBot.py @@ -1,15 +1,21 @@ import asyncio -from controller import Bot +#from controller import Bot +from controller import Controller -class TestBot(Bot): - def __init__(self): - pass +#class TestBot(Bot): +class TestBot(Controller): + def __init__(self, roomname): + super().__init__(roomname) - async def on_connected(self): - await self.room.set_nick("TestBot") + #async def on_connected(self): + #await self.room.set_nick("TestBot") + + async def on_hello(self, user_id, session, room_is_private, version, account=None, + account_has_access=None, account_email_verified=None): + print(repr(session.user_id), repr(session.session_id), repr(session.name)) if __name__ == "__main__": - bot = TestBot() + bot = TestBot("test") asyncio.get_event_loop().run_until_complete(bot.run()) diff --git a/yaboli/connection.py b/yaboli/connection.py index 5ab703f..f5d4b79 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -44,7 +44,7 @@ class Connection: if not stopped and self._ws: await self._ws.close() - async def send(ptype, data=None, await_response=True): + async def send(self, ptype, data=None, await_response=True): if stopped: raise ConnectionClosed @@ -65,7 +65,7 @@ class Connection: self._pid += 1 return self._pid - async def _handle_json(text): + async def _handle_json(self, text): packet = json.loads(text) # Deal with pending responses @@ -76,7 +76,7 @@ class Connection: # Pass packet onto room await self.packet_hook(packet) - def _wait_for_response(pid): + def _wait_for_response(self, pid): future = asyncio.Future() if pid not in self._pending_responses: diff --git a/yaboli/controller.py b/yaboli/controller.py index dde19df..d778a41 100644 --- a/yaboli/controller.py +++ b/yaboli/controller.py @@ -1,4 +1,4 @@ -from .room import Room +from room import Room @@ -53,7 +53,12 @@ class Controller: if self.room: await self.room.stop() - # room successfully connected + async def on_start(self): + pass + + async def on_stop(self): + pass + async def on_connected(self): """ Client has successfully (re-)joined the room. @@ -81,7 +86,8 @@ class Controller: async def on_disconnect(self, reason): pass - async def on_hello(self, user_id, session, room_is_private, version, account=None, account_has_access=None, account_email_verified=None): + async def on_hello(self, user_id, session, room_is_private, version, account=None, + account_has_access=None, account_email_verified=None): pass async def on_join(self, session): @@ -96,7 +102,7 @@ class Controller: async def on_network(self, ntype, server_id, server_era): pass - async def on_nick(self, session_id, user_id, from, to): + async def on_nick(self, session_id, user_id, from_name, to_name): pass async def on_edit_message(self, edit_id, message): @@ -109,14 +115,15 @@ class Controller: """ Default implementation, refer to api.euphoria.io """ - + await self.room.ping_reply(ptime) - async def on_pm_initiate(self, from, from_nick, from_room, pm_id): + async def on_pm_initiate(self, from_id, from_nick, from_room, pm_id): pass async def on_send(self, message): pass - async def on_snapshot(self, user_id, snapshot_id, version, listing, log, nick=None, pm_with_nick=None, pm_with_user_id=None): + async def on_snapshot(self, user_id, snapshot_id, version, listing, log, nick=None, + pm_with_nick=None, pm_with_user_id=None): pass diff --git a/yaboli/room.py b/yaboli/room.py index 7d4dc0c..ee33641 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,5 +1,6 @@ import asyncio -from .connection import Connection +from connection import Connection +import utils class Room: ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" @@ -15,15 +16,22 @@ class Room: # with differently by different controllers. # If you need to keep track of messages, use utils.Log. self.session = None - self.sessions = None + self.account = None + self.listing = utils.Listing() + + # Various room information + 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._callbacks = {} self._add_callbacks() if human: - url = HUMAN_FORMAT.format(self.roomname) + url = self.HUMAN_FORMAT.format(self.roomname) else: - url = ROOM_FORMAT.format(self.roomname) + url = self.ROOM_FORMAT.format(self.roomname) self._conn = Connection(url, self._handle_packet, self.cookie) async def run(self): @@ -34,30 +42,30 @@ class Room: # CATEGORY: SESSION COMMANDS - async def auth(atype, passcode): + async def auth(self, atype, passcode): pass # TODO - async def ping_reply(time): + async def ping_reply(self, time): pass # TODO # CATEGORY: CHAT ROOM COMMANDS - async def get_message(message_id): + async def get_message(self, message_id): pass # TODO - async def log(n, before=None): + async def log(self, n, before=None): pass # TODO - async def nick(name): + async def nick(self, name): pass # TODO - async def pm_initiate(user_id): + async def pm_initiate(self, user_id): pass # TODO - async def send(content, parent=None): + async def send(self, content, parent=None): pass # TODO - async def who() + async def who(self): pass # TODO # CATEGORY: ACCOUNT COMMANDS @@ -102,7 +110,29 @@ class Room: pass # TODO async def _handle_hello(self, packet): - pass # TODO + """ + 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 = utils.Session.from_dict(data.get("session")) + self.account_has_access = data.get("account_has_access") + self.account_email_verified = data.get("account_email_verified") + self.room_is_private = data.get("room_is_private") + self.version = data.get("version") + + 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): pass # TODO @@ -126,7 +156,15 @@ class Room: pass # TODO async def _handle_ping(self, packet): - pass # TODO + """ + 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): pass # TODO diff --git a/yaboli/utils.py b/yaboli/utils.py new file mode 100644 index 0000000..1d7f27f --- /dev/null +++ b/yaboli/utils.py @@ -0,0 +1,66 @@ +class Session: + def __init__(self, user_id, name, server_id, server_era, session_id, is_staff=None, + is_manager=None, client_address=None, real_address=None): + self.user_id = user_id + self.name = name + self.server_id = server_id + self.server_era = server_era + self.session_id = session_id + self.is_staff = is_staff + self.is_manager = is_manager + self.client_address = client_address + self.real_address = real_address + + @classmethod + def from_dict(cls, d): + return cls( + d.get("id"), + d.get("name"), + d.get("server_id"), + d.get("server_era"), + d.get("session_id"), + d.get("is_staff"), + d.get("is_manager"), + d.get("client_address"), + d.get("real_address") + ) + + @property + def client_type(self): + # account, agent or bot + return self.user_id.split(":")[0] + +class Listing: + def __init__(self): + self._sessions = {} + + def __len__(self): + return len(self._sessions) + + def add(self, session): + self._sessions[session.session_id] = session + + def remove(self, session_id): + self._sessions.pop(session_id) + + def by_sid(self, session_id): + return self._sessions.get(session_id); + + def by_uid(self, user_id): + return [ses for ses in self._sessions if ses.user_id == user_id] + + def get_people(self): + return {uid: ses for uid, ses in self._sessions.items() + if ses.client_type in ["agent", "account"]} + + def get_accounts(self): + return {uid: ses for uid, ses in self._sessions.items() + if ses.client_type is "account"} + + def get_agents(self): + return {uid: ses for uid, ses in self._sessions.items() + if ses.client_type is "agent"} + + def get_bots(self): + return {uid: ses for uid, ses in self._sessions.items() + if ses.client_type is "bot"} From 04364c6b3feb15bf1ac0202f26fd332dbc128ea2 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 2 Sep 2017 10:37:58 +0000 Subject: [PATCH 03/12] Add basic functionality Bots can now - stay connected - set their nick --- yaboli/TestBot.py | 5 +-- yaboli/connection.py | 55 ++++++++++++++++----------------- yaboli/controller.py | 10 +++++- yaboli/room.py | 72 +++++++++++++++++++++++++++++++++++++++++--- yaboli/utils.py | 29 ++++++++++++++++++ 5 files changed, 137 insertions(+), 34 deletions(-) diff --git a/yaboli/TestBot.py b/yaboli/TestBot.py index a98f922..6eba07c 100644 --- a/yaboli/TestBot.py +++ b/yaboli/TestBot.py @@ -9,8 +9,9 @@ class TestBot(Controller): def __init__(self, roomname): super().__init__(roomname) - #async def on_connected(self): - #await self.room.set_nick("TestBot") + async def on_snapshot(self, user_id, session_id, version, listing, log, nick=None, + pm_with_nick=None, pm_with_user_id=None): + await self.room.nick("TestBot") async def on_hello(self, user_id, session, room_is_private, version, account=None, account_has_access=None, account_email_verified=None): diff --git a/yaboli/connection.py b/yaboli/connection.py index f5d4b79..2e0335b 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -15,14 +15,14 @@ class Connection: self.cookie = cookie self.packet_hook = packet_hook - stopped = False + self.stopped = False self._ws = None self._pid = 0 self._pending_responses = {} async def run(self): - self._ws = await websockets.connect(self.url) + self._ws = await websockets.connect(self.url, max_size=None) try: while True: @@ -35,31 +35,33 @@ class Connection: self._ws = None stopped = True - for futures in self._pending_responses: - for future in futures: - future.set_error(ConnectionClosed) - future.cancel() + for future in self._pending_responses: + future.set_error(ConnectionClosed) + future.cancel() # TODO: Is this needed? async def stop(self): - if not stopped and self._ws: + if not self.stopped and self._ws: await self._ws.close() async def send(self, ptype, data=None, await_response=True): - if stopped: + if self.stopped: raise ConnectionClosed pid = self._new_pid() - packet["type"] = ptype - packet["data"] = data - packet["id"] = pid + packet = { + "type": ptype, + "data": data, + "id": str(pid) + } if await_response: wait_for = self._wait_for_response(pid) - await self._ws.send(json.dumps(packet)) + + await self._ws.send(json.dumps(packet, separators=(',', ':'))) + + if await_response: await wait_for return wait_for.result() - else: - await self._ws.send(json.dumps(packet)) def _new_pid(self): self._pid += 1 @@ -70,7 +72,8 @@ class Connection: # Deal with pending responses pid = packet.get("id") - for future in self._pending_responses.pop(pid, []): + future = self._pending_responses.pop(pid, None) + if future: future.set_result(packet) # Pass packet onto room @@ -78,19 +81,17 @@ class Connection: def _wait_for_response(self, pid): future = asyncio.Future() - - if pid not in self._pending_responses: - self._pending_responses[pid] = [] - self._pending_responses[pid].append(future) + self._pending_responses[pid] = future return future -def do_nothing(*args, **kwargs): - pass +#async def handle_packet(packet): + #if packet.get("type") == "ping-event": + #await c._ws.send('{"type":"ping-reply","data":{"time":' + str(packet.get("data").get("time")) + '}}') + ##await c.send("ping-reply", {"time": packet.get("data").get("time")}, False) -def run(): - conn = Connection("wss://echo.websocket.org", do_nothing) - loop = asyncio.get_event_loop() - #loop.call_later(3, conn.stop) - - loop.run_until_complete(asyncio.ensure_future(conn.run())) +#c = Connection("wss://euphoria.io/room/test/ws", handle_packet) + +#def run(): + #loop = asyncio.get_event_loop() + #loop.run_until_complete(asyncio.ensure_future(c.run())) diff --git a/yaboli/controller.py b/yaboli/controller.py index d778a41..031416d 100644 --- a/yaboli/controller.py +++ b/yaboli/controller.py @@ -54,9 +54,17 @@ class Controller: await self.room.stop() async def on_start(self): + """ + The first callback called when the controller is run. + """ + pass async def on_stop(self): + """ + The last callback called when the controller is run. + """ + pass async def on_connected(self): @@ -124,6 +132,6 @@ class Controller: async def on_send(self, message): pass - async def on_snapshot(self, user_id, snapshot_id, version, listing, log, nick=None, + async def on_snapshot(self, user_id, session_id, version, listing, log, nick=None, pm_with_nick=None, pm_with_user_id=None): pass diff --git a/yaboli/room.py b/yaboli/room.py index ee33641..63b2aa8 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -24,6 +24,8 @@ class Room: 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 = {} self._add_callbacks() @@ -46,7 +48,15 @@ class Room: pass # TODO async def ping_reply(self, time): - pass # TODO + """ + 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 @@ -57,7 +67,28 @@ class Room: pass # TODO async def nick(self, name): - pass # TODO + """ + session_id, user_id, from_nick, to_nick = await nick(name) + + 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._conn.send("nick", data) + + session_id = response.get("session_id") + user_id = response.get("id") + from_nick = response.get("from") + to_nick = response.get("to") + + self.session.nick = to_nick + + return session_id, user_id, from_nick, to_nick async def pm_initiate(self, user_id): pass # TODO @@ -164,7 +195,11 @@ class Room: """ data = packet.get("data") - await self.controller.on_ping(data.get("time"), data.get("next")) + + await self.controller.on_ping( + data.get("time"), + data.get("next") + ) async def _handle_pm_initiate(self, packet): pass # TODO @@ -173,4 +208,33 @@ class Room: pass # TODO async def _handle_snapshot(self, packet): - pass # TODO + """ + 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") + + for session_data in data.get("listing"): + session = utils.Session.from_dict(session_data) + self.listing.add(session) + + log = [utils.Message.from_dict(d) for d in data.get("log")] + + self.session.nick = data.get("nick") + + self.pm_with_nick = data.get("pm_with_nick"), + self.pm_with_user_id = data.get("pm_with_user_id") + + await self.controller.on_connected() + + await self.controller.on_snapshot( + data.get("identity"), + data.get("session_id"), + self.version, + self.listing, + log, + nick=self.session.nick, + pm_with_nick=self.pm_with_nick, + pm_with_user_id=self.pm_with_user_id + ) diff --git a/yaboli/utils.py b/yaboli/utils.py index 1d7f27f..a9f0e6f 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -64,3 +64,32 @@ class Listing: def get_bots(self): return {uid: ses for uid, ses in self._sessions.items() if ses.client_type is "bot"} + +class Message(): + def __init__(self, message_id, time, sender, content, parent=None, previous_edit_id=None, + encryption_key=None, edited=None, deleted=None, truncated=None): + self.message_id = message_id + self.time = time + self.sender = sender + self.content = content + self.parent = parent + self.previous_edit_id = previous_edit_id + self.encryption_key = encryption_key + self.edited = edited + self.deleted = deleted + self.truncated = truncated + + @classmethod + def from_dict(cls, d): + return cls( + d.get("id"), + d.get("time"), + Session.from_dict(d.get("sender")), + d.get("content"), + d.get("parent"), + d.get("previous_edit_id"), + d.get("encryption_key"), + d.get("edited"), + d.get("deleted"), + d.get("truncated") + ) From dfad3241fbed0f33446ea83f51e29082f7916f73 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 2 Sep 2017 12:27:38 +0000 Subject: [PATCH 04/12] Make more events work (and use an annoying bot to test them) --- yaboli/TestBot.py | 33 ++++++++-- yaboli/connection.py | 20 ++++-- yaboli/controller.py | 2 +- yaboli/room.py | 152 +++++++++++++++++++++++++++++++++++++------ yaboli/utils.py | 42 ++++++++---- 5 files changed, 208 insertions(+), 41 deletions(-) diff --git a/yaboli/TestBot.py b/yaboli/TestBot.py index 6eba07c..5ef772f 100644 --- a/yaboli/TestBot.py +++ b/yaboli/TestBot.py @@ -1,6 +1,7 @@ import asyncio #from controller import Bot from controller import Controller +from utils import * @@ -12,10 +13,34 @@ class TestBot(Controller): async def on_snapshot(self, user_id, session_id, version, listing, log, nick=None, pm_with_nick=None, pm_with_user_id=None): await self.room.nick("TestBot") - - async def on_hello(self, user_id, session, room_is_private, version, account=None, - account_has_access=None, account_email_verified=None): - print(repr(session.user_id), repr(session.session_id), repr(session.name)) + + async def on_send(self, message): + await self.room.send("Hey, a message!", message.message_id) + + async def on_join(self, session): + if session.nick != "": + await self.room.send(f"Hey, a @{mention(session.nick)}!") + else: + await self.room.send("Hey, a lurker!") + + async def on_nick(self, session_id, user_id, from_nick, to_nick): + if from_nick != "" and to_nick != "": + if from_nick == to_nick: + await self.room.send(f"You didn't even change your nick, @{mention(to_nick)} :(") + else: + await self.room.send(f"Bye @{mention(from_nick)}, hi @{mention(to_nick)}") + elif from_nick != "": + await self.room.send(f"Bye @{mention(from_nick)}? This message should never appear...") + elif to_nick != "": + await self.room.send(f"Hey, a @{mention(to_nick)}!") + else: + await self.room.send("I have no idea how you did that. This message should never appear...") + + async def on_part(self, session): + if session.nick != "": + await self.room.send(f"Bye, you @{mention(session.nick)}!") + else: + await self.room.send("Bye, you lurker!") if __name__ == "__main__": bot = TestBot("test") diff --git a/yaboli/connection.py b/yaboli/connection.py index 2e0335b..8488651 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -36,8 +36,8 @@ class Connection: stopped = True for future in self._pending_responses: - future.set_error(ConnectionClosed) - future.cancel() # TODO: Is this needed? + #future.set_error(ConnectionClosed) + future.cancel() async def stop(self): if not self.stopped and self._ws: @@ -92,6 +92,16 @@ class Connection: #c = Connection("wss://euphoria.io/room/test/ws", handle_packet) -#def run(): - #loop = asyncio.get_event_loop() - #loop.run_until_complete(asyncio.ensure_future(c.run())) +async def await_future(f): + await f + print(f.result()) + +def run(): + f = asyncio.Future() + #f.set_result("Hello World!") + f.cancel() + #f.set_result("Hello World!") + + loop = asyncio.get_event_loop() + loop.run_until_complete(await_future(f)) + #loop.run_until_complete(c.run()) diff --git a/yaboli/controller.py b/yaboli/controller.py index 031416d..2a98b72 100644 --- a/yaboli/controller.py +++ b/yaboli/controller.py @@ -110,7 +110,7 @@ class Controller: async def on_network(self, ntype, server_id, server_era): pass - async def on_nick(self, session_id, user_id, from_name, to_name): + async def on_nick(self, session_id, user_id, from_nick, to_nick): pass async def on_edit_message(self, edit_id, message): diff --git a/yaboli/room.py b/yaboli/room.py index 63b2aa8..cc7ef4f 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -49,6 +49,7 @@ class Room: async def ping_reply(self, 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. @@ -70,6 +71,7 @@ class Room: """ 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. @@ -79,7 +81,9 @@ class Room: """ data = {"name": name} + response = await self._conn.send("nick", data) + self._check_for_errors(response) session_id = response.get("session_id") user_id = response.get("id") @@ -94,7 +98,28 @@ class Room: pass # TODO async def send(self, content, parent=None): - pass # TODO + """ + 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._conn.send("send", data) + self._check_for_errors(response) + + message = utils.Message.from_dict(response.get("data")) + return message async def who(self): pass # TODO @@ -129,16 +154,52 @@ class Room: self._callbacks["snapshot-event"] = self._handle_snapshot async def _handle_packet(self, packet): + self._check_for_errors(packet) + ptype = packet.get("type") callback = self._callbacks.get(ptype) if callback: - await callback(packet) + try: + await callback(packet) + except asyncio.CancelledError as e: + # TODO: log error + print("HEHEHEHEY, CANCELLEDERROR", e) + pass + + def _check_for_errors(self, packet): + # TODO: log throttled + + if "error" in packet: + raise utils.ResponseError(response.get("error")) async def _handle_bounce(self, packet): - pass # TODO + """ + 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): - pass # TODO + """ + 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): """ @@ -150,10 +211,11 @@ class Room: data = packet.get("data") self.session = utils.Session.from_dict(data.get("session")) - self.account_has_access = data.get("account_has_access") - self.account_email_verified = data.get("account_email_verified") 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"), @@ -166,7 +228,18 @@ class Room: ) async def _handle_join(self, packet): - pass # TODO + """ + From api.euphoria.io: + A join-event indicates a session just joined the room. + """ + + data = packet.get("data") + session = utils.Session.from_dict(data) + + # update self.listing + self.listing.add(session) + + await self.controller.on_join(session) async def _handle_login(self, packet): pass # TODO @@ -178,13 +251,43 @@ class Room: pass # TODO async def _handle_nick(self, packet): - pass # TODO + """ + 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): pass # TODO async def _handle_part(self, packet): - pass # TODO + """ + From api.euphoria.io: + A part-event indicates a session just disconnected from the room. + """ + + data = packet.get("data") + session = utils.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): """ @@ -205,26 +308,37 @@ class Room: pass # TODO async def _handle_send(self, packet): - pass # TODO + """ + From api.euphoria.io: + A send-event indicates a message received by the room from another + session. + """ + + data = packet.get("data") + message = utils.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") - for session_data in data.get("listing"): - session = utils.Session.from_dict(session_data) + sessions = [utils.Session.from_dict(d) for d in data.get("listing")] + messages = [utils.Message.from_dict(d) for d in data.get("log")] + + # update self.listing + for session in sessions: self.listing.add(session) - - log = [utils.Message.from_dict(d) for d in data.get("log")] - self.session.nick = data.get("nick") + self.session.nick = data.get("nick", None) - self.pm_with_nick = data.get("pm_with_nick"), - self.pm_with_user_id = data.get("pm_with_user_id") + 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() @@ -232,8 +346,8 @@ class Room: data.get("identity"), data.get("session_id"), self.version, - self.listing, - log, + sessions, # listing + messages, # log nick=self.session.nick, pm_with_nick=self.pm_with_nick, pm_with_user_id=self.pm_with_user_id diff --git a/yaboli/utils.py b/yaboli/utils.py index a9f0e6f..3401d4c 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,8 +1,23 @@ +import re + +__all__ = ["mention", "mention_reduced", "similar", "Session", "Listing", "Message", "Log"] + + + +def mention(nick): + return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace()) + +def mention_reduced(nick): + return mention(nick).lower() + +def similar(nick1, nick2): + return mention_reduced(nick1) == mention_reduced(nick2) + class Session: - def __init__(self, user_id, name, server_id, server_era, session_id, is_staff=None, + def __init__(self, user_id, nick, server_id, server_era, session_id, is_staff=None, is_manager=None, client_address=None, real_address=None): self.user_id = user_id - self.name = name + self.nick = nick self.server_id = server_id self.server_era = server_era self.session_id = session_id @@ -19,10 +34,10 @@ class Session: d.get("server_id"), d.get("server_era"), d.get("session_id"), - d.get("is_staff"), - d.get("is_manager"), - d.get("client_address"), - d.get("real_address") + d.get("is_staff", None), + d.get("is_manager", None), + d.get("client_address", None), + d.get("real_address", None) ) @property @@ -86,10 +101,13 @@ class Message(): d.get("time"), Session.from_dict(d.get("sender")), d.get("content"), - d.get("parent"), - d.get("previous_edit_id"), - d.get("encryption_key"), - d.get("edited"), - d.get("deleted"), - d.get("truncated") + d.get("parent", None), + d.get("previous_edit_id", None), + d.get("encryption_key", None), + d.get("edited", None), + d.get("deleted", None), + d.get("truncated", None) ) + +class Log: + pass # TODO From 6cc8094e0dbd4674d7d25b751b9a7dd82025ca00 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 2 Sep 2017 12:58:39 +0000 Subject: [PATCH 05/12] Clean up module structure --- yaboli/TestBot.py => TestBot.py | 7 +- yaboli/__init__.py | 16 +- yaboli/asynciotest.py | 25 -- yaboli/bot.py | 600 -------------------------------- yaboli/botmanager.py | 149 -------- yaboli/connection.py | 4 +- yaboli/controller.py | 4 +- yaboli/exceptions.py | 41 --- yaboli/message.py | 99 ------ yaboli/messages.py | 154 -------- yaboli/room.py | 26 +- yaboli/session.py | 71 ---- yaboli/sessions.py | 146 -------- yaboli/utils.py | 12 +- 14 files changed, 39 insertions(+), 1315 deletions(-) rename yaboli/TestBot.py => TestBot.py (92%) delete mode 100644 yaboli/asynciotest.py delete mode 100644 yaboli/bot.py delete mode 100644 yaboli/botmanager.py delete mode 100644 yaboli/exceptions.py delete mode 100644 yaboli/message.py delete mode 100644 yaboli/messages.py delete mode 100644 yaboli/session.py delete mode 100644 yaboli/sessions.py diff --git a/yaboli/TestBot.py b/TestBot.py similarity index 92% rename from yaboli/TestBot.py rename to TestBot.py index 5ef772f..f774af0 100644 --- a/yaboli/TestBot.py +++ b/TestBot.py @@ -1,12 +1,11 @@ import asyncio -#from controller import Bot -from controller import Controller -from utils import * +import yaboli +from yaboli.utils import * #class TestBot(Bot): -class TestBot(Controller): +class TestBot(yaboli.Controller): def __init__(self, roomname): super().__init__(roomname) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 8aed845..3abbecc 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,10 +1,6 @@ -from .bot import Bot -from .botmanager import BotManager -from .callbacks import Callbacks -from .connection import Connection -from .exceptions import * -from .session import Session -from .message import Message -from .sessions import Sessions -from .messages import Messages -from .room import Room +from .connection import * +from .room import * +from .controller import * +from .utils import * + +__all__ = connection.__all__ + room.__all__ + controller.__all__ + utils.__all__ diff --git a/yaboli/asynciotest.py b/yaboli/asynciotest.py deleted file mode 100644 index 7d619a0..0000000 --- a/yaboli/asynciotest.py +++ /dev/null @@ -1,25 +0,0 @@ -import asyncio - -async def create(): - await asyncio.sleep(3.0) - print("(1) create file") - -async def write(): - await asyncio.sleep(1.0) - print("(2) write into file") - -async def close(): - print("(3) close file") - -async def test(): - await create() - await write() - await close() - await asyncio.sleep(2.0) - loop.stop() - -loop = asyncio.get_event_loop() -asyncio.ensure_future(test()) -loop.run_forever() -print("Pending tasks at exit: %s" % asyncio.Task.all_tasks(loop)) -loop.close() diff --git a/yaboli/bot.py b/yaboli/bot.py deleted file mode 100644 index 68ee2f8..0000000 --- a/yaboli/bot.py +++ /dev/null @@ -1,600 +0,0 @@ -import time - -from . import callbacks -from . import exceptions -from . import room - -class Bot(): - """ - Empty bot class that can be built upon. - Takes care of extended botrulez. - """ - - def __init__(self, roomname, nick="yaboli", password=None, manager=None, - created_in=None, created_by=None): - """ - roomname - name of the room to connect to - nick - nick to assume, None -> no nick - password - room password (in case the room is private) - created_in - room the bot was created in - created_by - nick of the person the bot was created by - """ - - self.start_time = time.time() - - self.created_by = created_by - self.created_in = created_in - - self.manager = manager - - # modify/customize this in your __init__() function (or anywhere else you want, for that matter) - self.bot_description = ("This bot complies with the botrulez™ (https://github.com/jedevc/botrulez),\n" - "plus a few extra commands.") - - self.helptexts = {} - self.detailed_helptexts = {} - - self.room = room.Room(roomname, nick=nick, password=password) - self.room.add_callback("message", self.on_message) - - self.commands = callbacks.Callbacks() - self.bot_specific_commands = [] - - self.add_command("clone", self.clone_command, "Clone this bot to another room.", # possibly add option to set nick? - ("!clone @bot [ [ --pw= ] ]\n" - " : the name of the room to clone the bot to\n" - "--pw : the room's password\n\n" - "Clone this bot to the room specified.\n" - "If the target room is passworded, you can use the --pw option to set\n" - "a password for the bot to use.\n" - "If no room is specified, this will use the current room and password."), - bot_specific=False) - - self.add_command("help", self.help_command, "Show help information about the bot.", - ("!help @bot [ -s | -c | ]\n" - "-s : general syntax help\n" - "-c : only list the commands\n" - " : any command from !help @bot -c\n\n" - "Shows detailed help for a command if you specify a command name.\n" - "Shows a list of commands and short description if no arguments are given.")) - - self.add_command("kill", self.kill_command, "Kill (stop) the bot.", - ("!kill @bot [ -r ]\n" - "-r : restart the bot (will change the id)\n\n" - "The bot disconnects from the room and stops.")) - - self.add_command("ping", self.ping_command, "Replies 'Pong!'.", - ("!ping @bot\n\n" - "This command was originally used to help distinguish bots from\n" - "people. Since the Great UI Change, this is no longer necessary as\n" - "bots and people are displayed separately.")) - - self.add_command("restart", self.restart_command, "Restart the bot (shorthand for !kill @bot -r).", - ("!restart @bot\n\n" - "Restart the bot.\n" - "Short for !kill @bot -r")) - - self.add_command("send", self.send_command, "Send the bot to another room.", - ("!send @bot [ --pw= ]\n" - "--pw : the room's password\n\n" - "Sends this bot to the room specified. If the target room is passworded,\n" - "you can use the --pw option to set a password for the bot to use.")) - - self.add_command("uptime", self.uptime_command, "Show bot uptime since last (re-)start.", - ("!uptime @bot [ -i s]\n" - "-i : show more detailed information\n\n" - "Shows the bot's uptime since the last start or restart.\n" - "Shows additional information (i.e. id) if the -i flag is set.")) - - - self.add_command("show", self.show_command, detailed_helptext="You've found a hidden command! :)") - - self.room.launch() - - def stop(self): - """ - stop() -> None - - Kill this bot. - """ - - self.room.stop() - - def add_command(self, command, function, helptext=None, detailed_helptext=None, - bot_specific=True): - """ - add_command(command, function, helptext, detailed_helptext, bot_specific) -> None - - Subscribe a function to a command and add a help text. - If no help text is provided, the command won't be displayed by the !help command. - This overwrites any previously added command. - - You can "hide" commands by specifying only the detailed helptext, - or no helptext at all. - - If the command is not bot specific, no id has to be specified if there are multiple bots - with the same nick in a room. - """ - - command = command.lower() - - self.commands.remove(command) - self.commands.add(command, function) - - if helptext and not command in self.helptexts: - self.helptexts[command] = helptext - elif not helptext and command in self.helptexts: - self.helptexts.pop(command) - - if detailed_helptext and not command in self.detailed_helptexts: - self.detailed_helptexts[command] = detailed_helptext - elif not detailed_helptext and command in self.detailed_helptexts: - self.detailed_helptexts.pop(command) - - if bot_specific and not command in self.bot_specific_commands: - self.bot_specific_commands.append(command) - elif not bot_specific and command in self.bot_specific_commands: - self.bot_specific_commands.remove(command) - - def call_command(self, message): - """ - call_command(message) -> None - - Calls all functions subscribed to the command with the arguments supplied in the message. - Deals with the situation that multiple bots of the same type and nick are in the same room. - """ - - try: - command, bot_id, nick, arguments, flags, options = self.parse(message.content) - except exceptions.ParseMessageException: - return - else: - command = command.lower() - nick = self.room.mentionable(nick).lower() - - if not self.commands.exists(command): - return - - if not nick == self.mentionable().lower(): - return - - if bot_id is not None: # id specified - if self.manager.get(bot_id) == self: - self.commands.call(command, message, arguments, flags, options) - else: - return - - else: # no id specified - bots = self.manager.get_similar(self.roomname(), nick) - if self.manager.get_id(self) == min(bots): # only one bot should act - # either the bot is unique or the command is not bot-specific - if not command in self.bot_specific_commands or len(bots) == 1: - self.commands.call(command, message, arguments, flags, options) - - else: # user has to select a bot - msg = ("There are multiple bots with that nick in this room. To select one,\n" - "please specify its id (from the list below) as follows:\n" - "!{} @{} [your arguments...]\n").format(command, nick) - - for bot_id in sorted(bots): - bot = bots[bot_id] - msg += "\n{} - @{} ({})".format(bot_id, bot.mentionable(), bot.creation_info()) - - self.room.send_message(msg, parent=message.id) - - def roomname(self): - """ - roomname() -> roomname - - The room the bot is connected to. - """ - - return self.room.room - - def password(self): - """ - password() -> password - - The current room's password. - """ - - return self.room.password - - def nick(self): - """ - nick() -> nick - - The bot's full nick. - """ - - return self.room.nick - - def mentionable(self): - """ - mentionable() -> nick - - The bot's nick in a mentionable format. - """ - - return self.room.mentionable() - - def creation_info(self): - """ - creation_info() -> str - - Formatted info about the bot's creation - """ - - info = "created {}".format(self.format_date()) - - if self.created_by: - info += " by @{}".format(self.room.mentionable(self.created_by)) - - if self.created_in: - info += " in &{}".format(self.created_in) - - return info - - def format_date(self, seconds=None): - """ - format_date(seconds) -> str - - Format a time in epoch format to the format specified in self.date_format. - Defaults to self.start_time. - """ - - if seconds is None: - seconds = self.start_time - - return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(seconds)) - - def format_delta(self, delta=None): - """ - format_delta(delta) -> str - - Format a difference in seconds to the following format: - [- ][d ][h ][m ]s - Defaults to the current uptime if no delta is specified. - """ - - if not delta: - delta = time.time() - self.start_time - - delta = int(delta) - uptime = "" - - if delta < 0: - uptime += "- " - delta = -delta - - if delta >= 24*60*60: - uptime +="{}d ".format(delta//(24*60*60)) - delta %= 24*60*60 - - if delta >= 60*60: - uptime += "{}h ".format(delta//(60*60)) - delta %= 60*60 - - if delta >= 60: - uptime += "{}m ".format(delta//60) - delta %= 60 - - uptime += "{}s".format(delta) - - return uptime - - def parse_command(self, message): - """ - parse_command(message_content) -> command, bot_id, nick, argpart - - Parse the "!command[ bot_id] @botname[ argpart]" part of a command. - """ - - # command name (!command) - split = message.split(maxsplit=1) - - if len(split) < 2: - raise exceptions.ParseMessageException("Not enough arguments") - elif split[0][:1] != "!": - raise exceptions.ParseMessageException("Not a command") - - command = split[0][1:] - message = split[1] - split = message.split(maxsplit=1) - - # bot id - try: - bot_id = int(split[0]) - except ValueError: - bot_id = None - else: - if len(split) <= 1: - raise exceptions.ParseMessageException("No bot nick") - - message = split[1] - split = message.split(maxsplit=1) - - # bot nick (@mention) - if split[0][:1] != "@": - raise exceptions.ParseMessageException("No bot nick") - - nick = split[0][1:] - - # arguments to the command - if len(split) > 1: - argpart = split[1] - else: - argpart = None - - return command, bot_id, nick, argpart - - def parse_arguments(self, argstr): - """ - parse_arguments(argstr) -> arguments, flags, options - - Parse the argument part of a command. - """ - - argstr += " " # so the last argument will also be captured - - escaping = False - quot_marks = None - type_signs = 0 - option = None - word = "" - - arguments = [] - flags = "" - options = {} - - for char in argstr: - - # backslash-escaping - if escaping: - word += char - escaping = False - elif char == "\\": - escaping = True - - # quotation mark escaped strings - elif quot_marks: - if char == quot_marks: - quot_marks = None - else: - word += char - elif char in ['"', "'"]: - quot_marks = char - - # type signs - elif char == "-": - if type_signs < 2 and not word: - type_signs += 1 - else: - word += char - - # "=" in options - elif char == "=" and type_signs == 2 and word and not option: - option = word - word = "" - - # space - evaluate information collected so far - elif char == " ": - if word: - if type_signs == 0: # argument - arguments.append(word) - elif type_signs == 1: # flag(s) - for flag in word: - if not flag in flags: - flags += flag - elif type_signs == 2: # option - if option: - options[option] = word - else: - options[word] = True - - # reset all state variables - escaping = False - quot_marks = None - type_signs = 0 - option = None - word = "" - - # all other chars and situations - else: - word += char - - return arguments, flags, options - - def parse(self, message): - """ - parse(message_content) -> bool - - Parse a message. - """ - - command, bot_id, nick, argpart = self.parse_command(message) - - if argpart: - arguments, flags, options = self.parse_arguments(argpart) - else: - arguments = [] - flags = "" - options = {} - - return command, bot_id, nick, arguments, flags, options - - # ----- HANDLING OF EVENTS ----- - - def on_message(self, message): - """ - on_message(message) -> None - - Gets called when a message is received (see __init__). - If you want to add a command to your bot, consider using add_command instead of overwriting - this function. - """ - - self.call_command(message) - - # ----- COMMANDS ----- - - def clone_command(self, message, arguments, flags, options): - """ - clone_command(message, *arguments, flags, options) -> None - - Create a new bot. - """ - - if not arguments: - room = self.roomname() - password = self.room.password - else: - room = arguments[0] - - if room[:1] == "&": - room = room[1:] - - if "pw" in options and options["pw"] is not True: - password = options["pw"] - else: - password = None - - try: - bot = self.manager.create(room, password=password, created_in=self.roomname(), - created_by=message.sender.name) - except exceptions.CreateBotException: - self.room.send_message("Bot could not be cloned.", parent=message.id) - else: - self.room.send_message("Cloned @{} to &{}.".format(bot.mentionable(), room), - parent=message.id) - - def help_command(self, message, arguments, flags, options): - """ - help_command(message, *arguments, flags, options) -> None - - Show help about the bot. - """ - - if arguments: # detailed help for one command - command = arguments[0] - if command[:1] == "!": - command = command[1:] - - if command in self.detailed_helptexts: - msg = "Detailed help for !{}:\n".format(command) - msg += self.detailed_helptexts[command] - else: - msg = "No detailed help text found for !{}.".format(command) - if command in self.helptexts: - msg += "\n\n" + self.helptexts[command] - - elif "s" in flags: # detailed syntax help - msg = ("SYNTAX HELP PLACEHOLDER") - - else: # just list all commands - msg = "" - - if not "c" in flags: - msg += self.bot_description + "\n\n" - - msg += "This bot supports the following commands:" - for command in sorted(self.helptexts): - helptext = self.helptexts[command] - msg += "\n!{} - {}".format(command, helptext) - - if not "c" in flags: - msg += ("\n\nFor help on the command syntax, try: !help @{0} -s\n" - "For detailed help on a command, try: !help @{0} \n" - "(Hint: Most commands have extra functionality, which is listed in their detailed help.)") - msg = msg.format(self.mentionable()) - - self.room.send_message(msg, parent=message.id) - - def kill_command(self, message, arguments, flags, options): - """ - kill_command(message, *arguments, flags, options) -> None - - stop the bot. - """ - - if "r" in flags: - bot = self.manager.create(self.roomname()) - bot.created_by = self.created_by - bot.created_in = self.created_in - - self.room.send_message("/me exits.", message.id) - - self.manager.remove(self.manager.get_id(self)) - - def ping_command(self, message, arguments, flags, options): - """ - ping_command(message, *arguments, flags, options) -> None - - Send a "Pong!" reply on a !ping command. - """ - - self.room.send_message("Pong!", parent=message.id) - - def restart_command(self, message, arguments, flags, options): - """ - restart_command(message, *arguments, flags, options) -> None - - Restart the bot (shorthand for !kill @bot -r). - """ - - self.commands.call("kill", message, [], "r", {}) - - def send_command(self, message, arguments, flags, options): - """ - _command(message, *arguments, flags, options) -> None - - Send this bot to another room. - """ - - if not arguments: - return - else: - room = arguments[0] - - if room[:1] == "&": - room = room[1:] - - if "pw" in options and options["pw"] is not True: - password = options["pw"] - else: - password = None - - self.room.send_message("/me moves to &{}.".format(room), parent=message.id) - - self.room.change(room, password=password) - self.room.launch() - - def show_command(self, message, arguments, flags, options): - """ - show_command(message, arguments, flags, options) -> None - - Show arguments, flags and options. - """ - - msg = "arguments: {}\nflags: {}\noptions: {}".format(arguments, repr(flags), options) - self.room.send_message(msg, parent=message.id) - - def uptime_command(self, message, arguments, flags, options): - """ - uptime_command(message, arguments, flags, options) -> None - - Show uptime and other info. - """ - - stime = self.format_date() - utime = self.format_delta() - - if "i" in flags: - msg = "uptime: {} ({})".format(stime, utime) - msg += "\nid: {}".format(self.manager.get_id(self)) - msg += "\n{}".format(self.creation_info()) - - else: - msg = "/me is up since {} ({}).".format(stime, utime) - - self.room.send_message(msg, message.id) diff --git a/yaboli/botmanager.py b/yaboli/botmanager.py deleted file mode 100644 index 1a91b91..0000000 --- a/yaboli/botmanager.py +++ /dev/null @@ -1,149 +0,0 @@ -import json - -from . import bot -from . import exceptions - -class BotManager(): - """ - Keep track of multiple bots in different rooms. - """ - - def __init__(self, bot_class, default_nick="yaboli", max_bots=100, - bots_file="bots.json", data_file="data.json"): - """ - bot_class - class to create instances of - default_nick - default nick for all bots to assume when no nick is specified - max_bots - maximum number of bots allowed to exist simultaneously - None or 0 - no limit - bots_file - file the bot backups are saved to - None - no bot backups - data_file - file the bot data is saved to - - None - bot data isn't saved - """ - - self.bot_class = bot_class - self.max_bots = max_bots - self.default_nick = default_nick - - self.bots_file = bots_file - self.data_file = data_file - - self._bots = {} - self._bot_id = 0 - self._bot_data = {} - - self._load_bots() - - def create(self, room, password=None, nick=None, created_in=None, created_by=None): - """ - create(room, password, nick) -> bot - - Create a new bot in room. - """ - - if nick is None: - nick = self.default_nick - - if self.max_bots and len(self._bots) >= self.max_bots: - raise exceptions.CreateBotException("max_bots limit hit") - else: - bot = self.bot_class(room, nick=nick, password=password, manager=self, - created_in=created_in, created_by=created_by) - self._bots[self._bot_id] = bot - self._bot_id += 1 - - self._save_bots() - - return bot - - def remove(self, bot_id): - """ - remove(bot_id) -> None - - Kill a bot and remove it from the list of bots. - """ - - if bot_id in self._bots: - self._bots[bot_id].stop() - self._bots.pop(bot_id) - - self._save_bots() - - def get(self, bot_id): - """ - get(self, bot_id) -> bot - - Return bot with that id, if found. - """ - - if bot_id in self._bots: - return self._bots[bot_id] - - def get_id(self, bot): - """ - get_id(bot) -> bot_id - - Return the bot id, if the bot is known. - """ - - for bot_id, own_bot in self._bots.items(): - if bot == own_bot: - return bot_id - - def get_similar(self, room, nick): - """ - get_by_room(room, nick) -> dict - - Collect all bots that are connected to the room and have that nick. - """ - - return {bot_id: bot for bot_id, bot in self._bots.items() - if bot.roomname() == room and bot.mentionable().lower() == nick.lower()} - - def _load_bots(self): - """ - _load_bots() -> None - - Load and create bots from self.bots_file. - """ - - if not self.bots_file: - return - - try: - with open(self.bots_file) as f: - bots = json.load(f) - except FileNotFoundError: - pass - else: - for bot_info in bots: - bot = self.create(bot_info["room"], password=bot_info["password"], - nick=bot_info["nick"]) - bot.created_in = bot_info["created_in"] - bot.created_by = bot_info["created_by"] - - def _save_bots(self): - """ - _save_bots() -> None - - Save all current bots to self.bots_file. - """ - - if not self.bots_file: - return - - bots = [] - - for bot_id, bot in self._bots.items(): - bot_info = {} - - bot_info["room"] = bot.roomname() - bot_info["password"] = bot.password() - bot_info["nick"] = bot.nick() - bot_info["created_in"] = bot.created_in - bot_info["created_by"] = bot.created_by - - bots.append(bot_info) - - with open(self.bots_file, "w") as f: - json.dump(bots, f) diff --git a/yaboli/connection.py b/yaboli/connection.py index 8488651..c8a4ecb 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -5,7 +5,9 @@ asyncio.get_event_loop().set_debug(True) import json import websockets -from websockets import ConnectionClosed +#from websockets import ConnectionClosed + +__all__ = ["Connection"] diff --git a/yaboli/controller.py b/yaboli/controller.py index 2a98b72..6684de0 100644 --- a/yaboli/controller.py +++ b/yaboli/controller.py @@ -1,4 +1,6 @@ -from room import Room +from .room import Room + +__all__ = ["Controller"] diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py deleted file mode 100644 index f48993d..0000000 --- a/yaboli/exceptions.py +++ /dev/null @@ -1,41 +0,0 @@ -class YaboliException(Exception): - """ - Generic yaboli exception class. - """ - - pass - -class BotManagerException(YaboliException): - """ - Generic BotManager exception class. - """ - - pass - -class CreateBotException(BotManagerException): - """ - This exception will be raised when BotManager could not create a bot. - """ - - pass - -class BotNotFoundException(BotManagerException): - """ - This exception will be raised when BotManager could not find a bot. - """ - - pass - -class BotException(YaboliException): - """ - Generic Bot exception class. - """ - - pass - -class ParseMessageException(BotException): - """ - This exception will be raised when a failure parsing a message occurs. - """ - - pass diff --git a/yaboli/message.py b/yaboli/message.py deleted file mode 100644 index 7e64bc5..0000000 --- a/yaboli/message.py +++ /dev/null @@ -1,99 +0,0 @@ -import time - -from . import session - -class Message(): - """ - This class keeps track of message details. - """ - - def __init__(self, id, time, sender, content, parent=None, edited=None, deleted=None, - truncated=None): - """ - id - message id - time - time the message was sent (epoch) - sender - session of the sender - content - content of the message - parent - id of the parent message, or None - edited - time of last edit (epoch) - deleted - time of deletion (epoch) - truncated - message was truncated - """ - - self.id = id - self.time = time - self.sender = sender - self.content = content - self.parent = parent - self.edited = edited - self.deleted = deleted - self.truncated = truncated - - @classmethod - def from_data(self, data): - """ - Creates and returns a message created from the data. - NOTE: This also creates a session object using the data in "sender". - - data - a euphoria message: http://api.euphoria.io/#message - """ - - sender = session.Session.from_data(data["sender"]) - parent = data["parent"] if "parent" in data else None - edited = data["edited"] if "edited" in data else None - deleted = data["deleted"] if "deleted" in data else None - truncated = data["truncated"] if "truncated" in data else None - - return self( - data["id"], - data["time"], - sender, - data["content"], - parent=parent, - edited=edited, - deleted=deleted, - truncated=truncated - ) - - def time_formatted(self, date=False): - """ - time_formatted(date=False) -> str - - date - include date in format - - Time in a readable format: - With date: YYYY-MM-DD HH:MM:SS - Without date: HH:MM:SS - """ - - if date: - return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(self.time)) - else: - return time.strftime("%H:%M:%S", time.gmtime(self.time)) - - def is_edited(self): - """ - is_edited() -> bool - - Has this message been edited? - """ - - return True if self.edited is not None else False - - def is_deleted(self): - """ - is_deleted() -> bool - - Has this message been deleted? - """ - - return True if self.deleted is not None else False - - def is_truncated(self): - """ - is_truncated() -> bool - - Has this message been truncated? - """ - - return True if self.truncated is not None else False diff --git a/yaboli/messages.py b/yaboli/messages.py deleted file mode 100644 index 8382a69..0000000 --- a/yaboli/messages.py +++ /dev/null @@ -1,154 +0,0 @@ -import operator - -from . import message - -class Messages(): - """ - Message storage class which preserves thread hierarchy. - """ - - def __init__(self, message_limit=500): - """ - message_limit - maximum amount of messages that will be stored at a time - None - no limit - """ - - self.message_limit = message_limit - - self._by_id = {} - self._by_parent = {} - - def _sort(self, msgs): - """ - _sort(messages) -> None - - Sorts a list of messages by their id, in place. - """ - - msgs.sort(key=operator.attrgetter("id")) - - def add_from_data(self, data): - """ - add_from_data(data) -> None - - Create a message from "raw" data and add it. - """ - - mes = message.Message.from_data(data) - - self.add(mes) - - def add(self, mes): - """ - add(message) -> None - - Add a message to the structure. - """ - - self.remove(mes.id) - - self._by_id[mes.id] = mes - - if mes.parent: - if not mes.parent in self._by_parent: - self._by_parent[mes.parent] = [] - self._by_parent[mes.parent].append(mes) - - if self.message_limit and len(self._by_id) > self.message_limit: - self.remove(self.get_oldest().id) - - def remove(self, mid): - """ - remove(message_id) -> None - - Remove a message from the structure. - """ - - mes = self.get(mid) - if mes: - if mes.id in self._by_id: - self._by_id.pop(mes.id) - - parent = self.get_parent(mes.id) - if parent and mes in self.get_children(parent.id): - self._by_parent[mes.parent].remove(mes) - - def remove_all(self): - """ - remove_all() -> None - - Removes all messages. - """ - - self._by_id = {} - self._by_parent = {} - - def get(self, mid): - """ - get(message_id) -> Message - - Returns the message with the given id, if found. - """ - - if mid in self._by_id: - return self._by_id[mid] - - def get_oldest(self): - """ - get_oldest() -> Message - - Returns the oldest message, if found. - """ - - oldest = None - for mid in self._by_id: - if oldest is None or mid < oldest: - oldest = mid - return self.get(oldest) - - def get_youngest(self): - """ - get_youngest() -> Message - - Returns the youngest message, if found. - """ - - youngest = None - for mid in self._by_id: - if youngest is None or mid > youngest: - youngest = mid - return self.get(youngest) - - def get_parent(self, mid): - """ - get_parent(message_id) -> str - - Returns the message's parent, if found. - """ - - mes = self.get(mid) - if mes: - return self.get(mes.parent) - - def get_children(self, mid): - """ - get_children(message_id) -> list - - Returns a sorted list of children of the given message, if found. - """ - - if mid in self._by_parent: - children = self._by_parent[mid][:] - self._sort(children) - return children - - def get_top_level(self): - """ - get_top_level() -> list - - Returns a sorted list of top-level messages. - """ - - msgs = [self.get(mid) for mid in self._by_id if not self.get(mid).parent] - self._sort(msgs) - return msgs diff --git a/yaboli/room.py b/yaboli/room.py index cc7ef4f..782085b 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,6 +1,10 @@ import asyncio -from connection import Connection -import utils +from .connection import * +from .utils import * + +__all__ = ["Room"] + + class Room: ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" @@ -17,7 +21,7 @@ class Room: # If you need to keep track of messages, use utils.Log. self.session = None self.account = None - self.listing = utils.Listing() + self.listing = Listing() # Various room information self.account_has_access = None @@ -118,7 +122,7 @@ class Room: response = await self._conn.send("send", data) self._check_for_errors(response) - message = utils.Message.from_dict(response.get("data")) + message = Message.from_dict(response.get("data")) return message async def who(self): @@ -170,7 +174,7 @@ class Room: # TODO: log throttled if "error" in packet: - raise utils.ResponseError(response.get("error")) + raise ResponseError(response.get("error")) async def _handle_bounce(self, packet): """ @@ -210,7 +214,7 @@ class Room: """ data = packet.get("data") - self.session = utils.Session.from_dict(data.get("session")) + 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) @@ -234,7 +238,7 @@ class Room: """ data = packet.get("data") - session = utils.Session.from_dict(data) + session = Session.from_dict(data) # update self.listing self.listing.add(session) @@ -282,7 +286,7 @@ class Room: """ data = packet.get("data") - session = utils.Session.from_dict(data) + session = Session.from_dict(data) # update self.listing self.listing.remove(session.session_id) @@ -315,7 +319,7 @@ class Room: """ data = packet.get("data") - message = utils.Message.from_dict(data) + message = Message.from_dict(data) await self.controller.on_send(message) @@ -328,8 +332,8 @@ class Room: data = packet.get("data") - sessions = [utils.Session.from_dict(d) for d in data.get("listing")] - messages = [utils.Message.from_dict(d) for d in data.get("log")] + 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: diff --git a/yaboli/session.py b/yaboli/session.py deleted file mode 100644 index e649655..0000000 --- a/yaboli/session.py +++ /dev/null @@ -1,71 +0,0 @@ -class Session(): - """ - This class keeps track of session details. - """ - - def __init__(self, id, name, server_id, server_era, session_id, is_staff=None, is_manager=None): - """ - id - agent/account id - name - name of the client when the SessionView was captured - server_id - id of the server - server_era - era of the server - session_id - session id (unique across euphoria) - is_staff - client is staff - is_manager - client is manager - """ - - self.id = id - self.name = name - self.server_id = server_id - self.server_era = server_era - self.session_id = session_id - self.staff = is_staff - self.manager = is_manager - - @classmethod - def from_data(self, data): - """ - Creates and returns a session created from the data. - - data - a euphoria SessionView: http://api.euphoria.io/#sessionview - """ - - is_staff = data["is_staff"] if "is_staff" in data else None - is_manager = data["is_manager"] if "is_manager" in data else None - - return self( - data["id"], - data["name"], - data["server_id"], - data["server_era"], - data["session_id"], - is_staff, - is_manager - ) - - def session_type(self): - """ - session_type() -> str - - The session's type (bot, account, agent). - """ - - return self.id.split(":")[0] - - def is_staff(self): - """ - is_staff() -> bool - - Is a user staff? - """ - - return self.staff and True or False - - def is_manager(self): - """ - is_manager() -> bool - - Is a user manager? - """ - - return self.staff and True or False diff --git a/yaboli/sessions.py b/yaboli/sessions.py deleted file mode 100644 index b00cf75..0000000 --- a/yaboli/sessions.py +++ /dev/null @@ -1,146 +0,0 @@ -from . import session - -class Sessions(): - """ - Keeps track of sessions. - """ - - def __init__(self): - """ - TODO - """ - self._sessions = {} - - def add_from_data(self, data): - """ - add_raw(data) -> None - - Create a session from "raw" data and add it. - """ - - ses = session.Session.from_data(data) - - self._sessions[ses.session_id] = ses - - def add(self, ses): - """ - add(session) -> None - - Add a session. - """ - - self._sessions[ses.session_id] = ses - - def get(self, sid): - """ - get(session_id) -> Session - - Returns the session with that id. - """ - - if sid in self._sessions: - return self._sessions[sid] - - def remove(self, sid): - """ - remove(session) -> None - - Remove a session. - """ - - if sid in self._sessions: - self._sessions.pop(sid) - - def remove_on_network_partition(self, server_id, server_era): - """ - remove_on_network_partition(server_id, server_era) -> None - - Removes all sessions matching the server_id/server_era combo. - http://api.euphoria.io/#network-event - """ - - sesnew = {} - for sid in self._sessions: - ses = self.get(sid) - if not (ses.server_id == server_id and ses.server_era == server_era): - sesnew[sid] = ses - self._sessions = sesnew - - def remove_all(self): - """ - remove_all() -> None - - Removes all sessions. - """ - - self._sessions = {} - - def get_all(self): - """ - get_all() -> list - - Returns the full list of sessions. - """ - - return [ses for sid, ses in self._sessions.items()] - - def get_people(self): - """ - get_people() -> list - - Returns a list of all non-bot and non-lurker sessions. - """ - - # not a list comprehension because that would span several lines too - people = [] - for sid in self._sessions: - ses = self.get(sid) - if ses.session_type() in ["agent", "account"] and ses.name: - people.append(ses) - return people - - def _get_by_type(self, tp): - """ - _get_by_type(session_type) -> list - - Returns a list of all non-lurker sessions with that type. - """ - - return [ses for sid, ses in self._sessions.items() - if ses.session_type() == tp and ses.name] - - def get_accounts(self): - """ - get_accounts() -> list - - Returns a list of all logged-in sessions. - """ - - return self._get_by_type("account") - - def get_agents(self): - """ - get_agents() -> list - - Returns a list of all sessions who are not signed into an account and not bots or lurkers. - """ - - return self._get_by_type("agent") - - def get_bots(self): - """ - get_bots() -> list - - Returns a list of all bot sessions. - """ - - return self._get_by_type("bot") - - def get_lurkers(self): - """ - get_lurkers() -> list - - Returns a list of all lurker sessions. - """ - - return [ses for sid, ses in self._sessions.items() if not ses.name] diff --git a/yaboli/utils.py b/yaboli/utils.py index 3401d4c..9ec3682 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,6 +1,9 @@ -import re - -__all__ = ["mention", "mention_reduced", "similar", "Session", "Listing", "Message", "Log"] +__all__ = [ + "mention", "mention_reduced", "similar", + "Session", "Listing", + "Message", "Log", + "ResponseError" +] @@ -111,3 +114,6 @@ class Message(): class Log: pass # TODO + +class ResponseError(Exception): + pass From a199af40d9ce6bfc12ffa4ae020334b7b8dafc4f Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 2 Sep 2017 13:31:17 +0000 Subject: [PATCH 06/12] Implement all basic room commands --- yaboli/connection.py | 24 +------ yaboli/room.py | 158 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 133 insertions(+), 49 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index c8a4ecb..821f302 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -52,9 +52,10 @@ class Connection: pid = self._new_pid() packet = { "type": ptype, - "data": data, "id": str(pid) } + if data: + packet["data"] = data if await_response: wait_for = self._wait_for_response(pid) @@ -86,24 +87,3 @@ class Connection: self._pending_responses[pid] = future return future - -#async def handle_packet(packet): - #if packet.get("type") == "ping-event": - #await c._ws.send('{"type":"ping-reply","data":{"time":' + str(packet.get("data").get("time")) + '}}') - ##await c.send("ping-reply", {"time": packet.get("data").get("time")}, False) - -#c = Connection("wss://euphoria.io/room/test/ws", handle_packet) - -async def await_future(f): - await f - print(f.result()) - -def run(): - f = asyncio.Future() - #f.set_result("Hello World!") - f.cancel() - #f.set_result("Hello World!") - - loop = asyncio.get_event_loop() - loop.run_until_complete(await_future(f)) - #loop.run_until_complete(c.run()) diff --git a/yaboli/room.py b/yaboli/room.py index 782085b..66c91ad 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -48,12 +48,33 @@ class Room: # CATEGORY: SESSION COMMANDS - async def auth(self, atype, passcode): - pass # TODO + 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): """ - From api.euphoria.io: + 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. @@ -66,16 +87,52 @@ class Room: # CATEGORY: CHAT ROOM COMMANDS async def get_message(self, message_id): - pass # TODO + """ + 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): - pass # TODO + """ + 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: + 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. @@ -86,24 +143,45 @@ class Room: data = {"name": name} - response = await self._conn.send("nick", data) - self._check_for_errors(response) + response = await self._send_packet("nick", data) + rdata = response.get("data") - session_id = response.get("session_id") - user_id = response.get("id") - from_nick = response.get("from") - to_nick = response.get("to") + 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): - pass # TODO + """ + 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): """ - From api.euphoria.io: + 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. @@ -119,14 +197,34 @@ class Room: if parent: data["parent"] = parent - response = await self._conn.send("send", data) - self._check_for_errors(response) + response = await self._send_packet("send", data) + rdata = response.get("data") - message = Message.from_dict(response.get("data")) + message = Message.from_dict(rdata) return message async def who(self): - pass # TODO + """ + 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: + self.listing.add(session) + + return sessions # CATEGORY: ACCOUNT COMMANDS # NYI, and probably never will @@ -157,6 +255,12 @@ class Room: self._callbacks["send-event"] = self._handle_send self._callbacks["snapshot-event"] = self._handle_snapshot + async def _send_packet(self, *args, **kwargs): + response = await self._conn.send(*args, **kwargs) + self._check_for_errors(response) + + return response + async def _handle_packet(self, packet): self._check_for_errors(packet) @@ -178,7 +282,7 @@ class Room: async def _handle_bounce(self, packet): """ - From api.euphoria.io: + From api.euphoria.io: A bounce-event indicates that access to a room is denied. """ @@ -193,7 +297,7 @@ class Room: async def _handle_disconnect(self, packet): """ - From api.euphoria.io: + From api.euphoria.io: A disconnect-event indicates that the session is being closed. The client will subsequently be disconnected. @@ -207,7 +311,7 @@ class Room: async def _handle_hello(self, packet): """ - From api.euphoria.io: + 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. @@ -233,7 +337,7 @@ class Room: async def _handle_join(self, packet): """ - From api.euphoria.io: + From api.euphoria.io: A join-event indicates a session just joined the room. """ @@ -256,7 +360,7 @@ class Room: async def _handle_nick(self, packet): """ - From api.euphoria.io: + From api.euphoria.io: nick-event announces a nick change by another session in the room. """ @@ -281,7 +385,7 @@ class Room: async def _handle_part(self, packet): """ - From api.euphoria.io: + From api.euphoria.io: A part-event indicates a session just disconnected from the room. """ @@ -295,7 +399,7 @@ class Room: async def _handle_ping(self, packet): """ - From api.euphoria.io: + 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). @@ -313,7 +417,7 @@ class Room: async def _handle_send(self, packet): """ - From api.euphoria.io: + From api.euphoria.io: A send-event indicates a message received by the room from another session. """ @@ -325,7 +429,7 @@ class Room: async def _handle_snapshot(self, packet): """ - From api.euphoria.io: + 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. """ From 676f9d395bc9590901e8342e61bf03ca64d5c405 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 2 Sep 2017 16:37:31 +0000 Subject: [PATCH 07/12] Implement the rest of the events --- yaboli/room.py | 69 +++++++++++++++++++++++++++++++++++++++++++++---- yaboli/utils.py | 4 +++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/yaboli/room.py b/yaboli/room.py index 66c91ad..0d42d4e 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -350,13 +350,46 @@ class Room: await self.controller.on_join(session) async def _handle_login(self, packet): - pass # TODO + """ + 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): - pass # TODO + """ + 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): - pass # TODO + """ + 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): """ @@ -381,7 +414,20 @@ class Room: ) async def _handle_edit_message(self, packet): - pass # TODO + """ + 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(message) async def _handle_part(self, packet): """ @@ -413,7 +459,20 @@ class Room: ) async def _handle_pm_initiate(self, packet): - pass # TODO + """ + 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): """ diff --git a/yaboli/utils.py b/yaboli/utils.py index 9ec3682..6cb77a9 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -61,6 +61,10 @@ class Listing: def remove(self, session_id): self._sessions.pop(session_id) + def remove_combo(self, server_id, 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} + def by_sid(self, session_id): return self._sessions.get(session_id); From 34e1ae4b8f464e09b87900fdef3a9823f4fa8971 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 3 Sep 2017 12:27:28 +0000 Subject: [PATCH 08/12] Fix connection reply bug Now, packet ids are always strings, except for self._pid. --- yaboli/connection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 821f302..b934c15 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -49,10 +49,10 @@ class Connection: if self.stopped: raise ConnectionClosed - pid = self._new_pid() + pid = str(self._new_pid()) packet = { "type": ptype, - "id": str(pid) + "id": pid } if data: packet["data"] = data From 1c3b9d0a2008a05f2068bfeb1e622cfae16faf5e Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 4 Sep 2017 16:21:29 +0000 Subject: [PATCH 09/12] Rework spawning and task structure Still not working: See TestBot.py --- TestBot.py | 63 ++++++++++---------- yaboli/__init__.py | 7 +++ yaboli/connection.py | 105 +++++++++++++++++++++++++++------ yaboli/controller.py | 134 ++++++++++++++++++++++++++++++++----------- yaboli/room.py | 36 ++++++++++-- 5 files changed, 252 insertions(+), 93 deletions(-) diff --git a/TestBot.py b/TestBot.py index f774af0..2ebdca8 100644 --- a/TestBot.py +++ b/TestBot.py @@ -6,41 +6,36 @@ from yaboli.utils import * #class TestBot(Bot): class TestBot(yaboli.Controller): - def __init__(self, roomname): - super().__init__(roomname) - - async def on_snapshot(self, user_id, session_id, version, listing, log, nick=None, - pm_with_nick=None, pm_with_user_id=None): - await self.room.nick("TestBot") + def __init__(self, nick): + super().__init__(nick=nick) async def on_send(self, message): - await self.room.send("Hey, a message!", message.message_id) - - async def on_join(self, session): - if session.nick != "": - await self.room.send(f"Hey, a @{mention(session.nick)}!") - else: - await self.room.send("Hey, a lurker!") - - async def on_nick(self, session_id, user_id, from_nick, to_nick): - if from_nick != "" and to_nick != "": - if from_nick == to_nick: - await self.room.send(f"You didn't even change your nick, @{mention(to_nick)} :(") - else: - await self.room.send(f"Bye @{mention(from_nick)}, hi @{mention(to_nick)}") - elif from_nick != "": - await self.room.send(f"Bye @{mention(from_nick)}? This message should never appear...") - elif to_nick != "": - await self.room.send(f"Hey, a @{mention(to_nick)}!") - else: - await self.room.send("I have no idea how you did that. This message should never appear...") - - async def on_part(self, session): - if session.nick != "": - await self.room.send(f"Bye, you @{mention(session.nick)}!") - else: - await self.room.send("Bye, you lurker!") + if message.content == "!spawnevil": + bot = TestBot("TestSpawn") + task, reason = await bot.connect("test") + second = await self.room.send("We have " + ("a" if task else "no") + " task. Reason: " + reason, message.message_id) + if task: + await bot.stop() + await self.room.send("Stopped." if task.done() else "Still running (!)", second.message_id) + + await self.room.send("All's over now.", message.message_id) + + elif message.content == "!tree": + messages = [message] + newmessages = [] + for i in range(2): + for m in messages: + for j in range(2): + newm = await self.room.send(f"{m.content}.{j}", m.message_id) + newmessages.append(newm) + messages = newmessages + newmessages = [] + +async def run_bot(): + bot = TestBot("TestSummoner") + task, reason = await bot.connect("test") + if task: + await task if __name__ == "__main__": - bot = TestBot("test") - asyncio.get_event_loop().run_until_complete(bot.run()) + asyncio.get_event_loop().run_until_complete(run_bot()) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 3abbecc..a0717a9 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,3 +1,10 @@ +import logging +#logging.basicConfig(level=logging.DEBUG) +logging.basicConfig(level=logging.INFO) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + from .connection import * from .room import * from .controller import * diff --git a/yaboli/connection.py b/yaboli/connection.py index b934c15..cba46bb 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -1,5 +1,6 @@ import logging -logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + import asyncio asyncio.get_event_loop().set_debug(True) @@ -17,37 +18,67 @@ class Connection: self.cookie = cookie self.packet_hook = packet_hook - self.stopped = False - self._ws = None - self._pid = 0 + self._pid = 0 # successive packet ids + self._spawned_tasks = set() self._pending_responses = {} + #self._stopping = False + self._runtask = None - async def run(self): - self._ws = await websockets.connect(self.url, max_size=None) + 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. + """ + + await self.stop() + + tries_left = max_tries + while tries_left > 0: + tries_left -= 1 + try: + self._ws = await websockets.connect(self.url, max_size=None) + except (websockets.InvalidURI, websockets.InvalidHandshake): + self._ws = None + if tries_left > 0: + await asyncio.sleep(delay) + else: + self._runtask = asyncio.ensure_future(self._run()) + return self._runtask + + async def _run(self): + """ + Listen for packets and deal with them accordingly. + """ try: while True: - response = await self._ws.recv() - asyncio.ensure_future(self._handle_json(response)) + await self._handle_next_message() except websockets.ConnectionClosed: pass finally: - await self._ws.close() # just to make sure it's closed - self._ws = None - stopped = True + self._clean_up_futures() + self._clean_up_tasks() - for future in self._pending_responses: - #future.set_error(ConnectionClosed) - future.cancel() + await self._ws.close() # just to make sure + self._ws = None async def stop(self): - if not self.stopped and self._ws: + """ + Close websocket connection and wait for running task to stop. + """ + + if self._ws: await self._ws.close() + + if self._runtask: + await self._runtask async def send(self, ptype, data=None, await_response=True): - if self.stopped: - raise ConnectionClosed + if not self._ws: + raise asyncio.CancelledError pid = str(self._new_pid()) packet = { @@ -60,7 +91,8 @@ class Connection: if await_response: wait_for = self._wait_for_response(pid) - await self._ws.send(json.dumps(packet, separators=(',', ':'))) + 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 @@ -70,11 +102,32 @@ class Connection: 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") + pid = packet.get("id", None) future = self._pending_responses.pop(pid, None) if future: future.set_result(packet) @@ -82,6 +135,20 @@ class Connection: # Pass packet onto room await self.packet_hook(packet) + def _track_task(self, task): + self._spawned_tasks.add(task) + + # only keep running tasks + #tasks = set() + #for task in self._spawned_tasks: + #if not task.done(): + #logger.debug(f"Keeping task: {task}") + #tasks.add(task) + #else: + #logger.debug(f"Deleting task: {task}") + #self._spawned_tasks = tasks + #self._spawned_tasks = {task for task in self._spawned_tasks if not task.done()} # TODO: Reenable + def _wait_for_response(self, pid): future = asyncio.Future() self._pending_responses[pid] = future diff --git a/yaboli/controller.py b/yaboli/controller.py index 6684de0..97e626f 100644 --- a/yaboli/controller.py +++ b/yaboli/controller.py @@ -1,5 +1,8 @@ +import asyncio +import logging from .room import Room +logger = logging.getLogger(__name__) __all__ = ["Controller"] @@ -24,50 +27,103 @@ class Controller: """ - def __init__(self, roomname, human=False, cookie=None): + def __init__(self, nick, human=False, cookie=None): """ roomname - name of room to connect to human - whether the human flag should be set on connections cookie - cookie to use in HTTP request, if any """ - - self.roomname = roomname + self.nick = nick self.human = human self.cookie = cookie + self.roomname = "test" + self.password = None + self.room = None - self.running = True + self._connect_result = None - async def run(self): - await self.on_start() + def _create_room(self, roomname): + return Room(roomname, self, human=self.human, cookie=self.cookie) + + def _set_connect_result(self, result): + logger.debug(f"Attempting to set connect result to {result}") + if self._connect_result and not self._connect_result.done(): + logger.debug(f"Setting connect result to {result}") + self._connect_result.set_result(result) + + async def connect(self, roomname, password=None, timeout=10): + """ + task, reason = await connect(roomname, password=None, timeout=10) - while self.running: - self.room = Room(self.roomname, self, self.human, self.cookie) - await self.room.run() + Connect to a room and authenticate, if necessary. + + roomname - name of the room to connect to + password - password for the room, if needed + timeout - wait this long for a reply from the server + + Returns: + task - the task running the bot, or None on failure + reason - the reason for failure + "no room" = could not establish connection, room doesn't exist + "auth option" = can't authenticate with a password + "no password" = password needed to connect to room + "wrong password" = password given does not work + "disconnected" = connection closed before client could access the room + "success" = no failure + """ + + logger.info(f"Attempting to connect to &{roomname}") + + # make sure nothing is running any more + try: + await self.stop() + except asyncio.CancelledError: + logger.error("Calling connect from the controller itself.") + raise + + self.password = password + self.room = self._create_room(roomname) + + # prepare for if connect() is successful + self._connect_result = asyncio.Future() + + # attempt to connect to the room + task = await self.room.connect() + if not task: + logger.warn(f"Could not connect to &{roomname}.") self.room = None + return None, "no room" - await self.on_end() + # connection succeeded, now we need to know whether we can log in + # wait for success/authentication/disconnect + # TODO: add a timeout + await self._connect_result + result = self._connect_result.result() + logger.debug(f"&{roomname}._connect_result: {result!r}") + + # deal with result + if result == "success": + logger.info(f"Successfully connected to &{roomname}.") + return task, result + else: # not successful for some reason + logger.warn(f"Could not join &{roomname}: {result!r}") + await self.stop() + return None, result async def stop(self): - if self.running: - self.running = False + if self.room: + logger.info(f"&{self.room.roomname}: Stopping") + await self.room.stop() + logger.debug(f"&{self.room.roomname}: Stopped. Deleting room") + self.room = None + + async def set_nick(self, nick): + if nick != self.nick: + _, _, _, to_nick = await self.room.nick(nick) - if self.room: - await self.room.stop() - - async def on_start(self): - """ - The first callback called when the controller is run. - """ - - pass - - async def on_stop(self): - """ - The last callback called when the controller is run. - """ - - pass + if to_nick != nick: + logger.warn(f"&{self.room.roomname}: Could not set nick to {nick!r}, set to {to_nick!r} instead.") async def on_connected(self): """ @@ -77,7 +133,7 @@ class Controller: such as resetting the message history. """ - pass + self._set_connect_result("success") async def on_disconnected(self): """ @@ -88,10 +144,18 @@ class Controller: Need to store information from old room? """ - pass + logger.debug(f"on_disconnected: self.room is {self.room}") + self._set_connect_result("disconnected") - async def on_bounce(self, reason=None, auth_options=None, agent_id=None, ip=None): - pass + async def on_bounce(self, reason=None, auth_options=[], agent_id=None, ip=None): + if "passcode" not in auth_options: + self._set_connect_result("auth option") + elif self.password is None: + self._set_connect_result("no password") + else: + success, reason = await self.room.auth("passcode", passcode=self.password) + if not success: + self._set_connect_result("wrong password") async def on_disconnect(self, reason): pass @@ -125,7 +189,8 @@ class Controller: """ Default implementation, refer to api.euphoria.io """ - + + logger.debug(f"&{self.room.roomname}: Pong!") await self.room.ping_reply(ptime) async def on_pm_initiate(self, from_id, from_nick, from_room, pm_id): @@ -136,4 +201,5 @@ class Controller: async def on_snapshot(self, user_id, session_id, version, listing, log, nick=None, pm_with_nick=None, pm_with_user_id=None): - pass + if nick != self.nick: + await self.room.nick(self.nick) diff --git a/yaboli/room.py b/yaboli/room.py index 0d42d4e..3d90c25 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,7 +1,9 @@ import asyncio +import logging from .connection import * from .utils import * +logger = logging.getLogger(__name__) __all__ = ["Room"] @@ -34,17 +36,40 @@ class Room: self._callbacks = {} self._add_callbacks() + self._stopping = False + self._runtask = None + if human: url = self.HUMAN_FORMAT.format(self.roomname) else: url = self.ROOM_FORMAT.format(self.roomname) self._conn = Connection(url, self._handle_packet, self.cookie) - async def run(self): - await self._conn.run() + async def connect(self, max_tries=10, delay=60): + 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): + while not self._stopping: + await task + await self.controller.on_disconnected() + + task = await self._conn.connect(max_tries=max_tries, delay=delay) + if not task: + return + + self.stopping = False async def stop(self): + self._stopping = True await self._conn.stop() + + if self._runtask: + await self._runtask + + # CATEGORY: SESSION COMMANDS @@ -270,12 +295,11 @@ class Room: try: await callback(packet) except asyncio.CancelledError as e: - # TODO: log error - print("HEHEHEHEY, CANCELLEDERROR", e) - pass + logger.info(f"&{self.roomname}: Callback of type {ptype!r} cancelled.") def _check_for_errors(self, packet): - # TODO: log throttled + 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(response.get("error")) From b8bb75a89706f83131bda8c3ced09f264ecabd32 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 4 Sep 2017 16:32:32 +0000 Subject: [PATCH 10/12] Fix previous "lingering bot" bug When using !spawnevil on @TestSummoner, the @TestSpawn which was created would reconnect to the room because of faulty logic in room._run(). This would cause a few errors to occur. Fixed now! --- yaboli/connection.py | 4 +++- yaboli/controller.py | 4 ++-- yaboli/room.py | 9 +++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index cba46bb..b0a108d 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -33,6 +33,8 @@ class Connection: Returns the task listening for packets, or None if the attempt failed. """ + logger.debug(f"Attempting to connect, max_tries={max_tries}") + await self.stop() tries_left = max_tries @@ -147,7 +149,7 @@ class Connection: #else: #logger.debug(f"Deleting task: {task}") #self._spawned_tasks = tasks - #self._spawned_tasks = {task for task in self._spawned_tasks if not task.done()} # TODO: Reenable + self._spawned_tasks = {task for task in self._spawned_tasks if not task.done()} # TODO: Reenable def _wait_for_response(self, pid): future = asyncio.Future() diff --git a/yaboli/controller.py b/yaboli/controller.py index 97e626f..062c7a0 100644 --- a/yaboli/controller.py +++ b/yaboli/controller.py @@ -113,9 +113,9 @@ class Controller: async def stop(self): if self.room: - logger.info(f"&{self.room.roomname}: Stopping") + logger.info(f"@{self.nick}: Stopping") await self.room.stop() - logger.debug(f"&{self.room.roomname}: Stopped. Deleting room") + logger.debug(f"@{self.nick}: Stopped. Deleting room") self.room = None async def set_nick(self, nick): diff --git a/yaboli/room.py b/yaboli/room.py index 3d90c25..ad9e32f 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -53,12 +53,13 @@ class Room: async def _run(self, task, max_tries=10, delay=60): 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() - - task = await self._conn.connect(max_tries=max_tries, delay=delay) - if not task: - return self.stopping = False From 053573e3cbe0fe94074b23628c2c7eb073d27fd3 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 4 Sep 2017 19:56:17 +0000 Subject: [PATCH 11/12] Add Bot class - Use rewrite-2 callbacks modified for async - Utility function for running singular controller - Update TestBot to work with new system --- TestBot.py | 61 +++++++++++--------- yaboli/__init__.py | 3 +- yaboli/bot.py | 138 ++++++++++++++++++++++++++++++++++++++++++++ yaboli/callbacks.py | 42 ++++++-------- yaboli/room.py | 41 +++++++------ yaboli/utils.py | 15 +++++ 6 files changed, 227 insertions(+), 73 deletions(-) create mode 100644 yaboli/bot.py diff --git a/TestBot.py b/TestBot.py index 2ebdca8..be41127 100644 --- a/TestBot.py +++ b/TestBot.py @@ -1,41 +1,48 @@ -import asyncio import yaboli from yaboli.utils import * #class TestBot(Bot): -class TestBot(yaboli.Controller): +class TestBot(yaboli.Bot): def __init__(self, nick): super().__init__(nick=nick) + + self.register_callback("tree", self.command_tree, specific=False) - async def on_send(self, message): - if message.content == "!spawnevil": - bot = TestBot("TestSpawn") - task, reason = await bot.connect("test") - second = await self.room.send("We have " + ("a" if task else "no") + " task. Reason: " + reason, message.message_id) - if task: - await bot.stop() - await self.room.send("Stopped." if task.done() else "Still running (!)", second.message_id) + #async def on_send(self, message): + #if message.content == "!spawnevil": + #bot = TestBot("TestSpawn") + #task, reason = await bot.connect("test") + #second = await self.room.send("We have " + ("a" if task else "no") + " task. Reason: " + reason, message.message_id) + #if task: + #await bot.stop() + #await self.room.send("Stopped." if task.done() else "Still running (!)", second.message_id) - await self.room.send("All's over now.", message.message_id) + #await self.room.send("All's over now.", message.message_id) - elif message.content == "!tree": - messages = [message] + #elif message.content == "!tree": + #messages = [message] + #newmessages = [] + #for i in range(2): + #for m in messages: + #for j in range(2): + #newm = await self.room.send(f"{m.content}.{j}", m.message_id) + #newmessages.append(newm) + #messages = newmessages + #newmessages = [] + + async def command_tree(self, message, args): + messages = [message] + newmessages = [] + for i in range(2): + for m in messages: + for j in range(2): + newm = await self.room.send(f"{message.content}.{j}", m.message_id) + newmessages.append(newm) + messages = newmessages newmessages = [] - for i in range(2): - for m in messages: - for j in range(2): - newm = await self.room.send(f"{m.content}.{j}", m.message_id) - newmessages.append(newm) - messages = newmessages - newmessages = [] - -async def run_bot(): - bot = TestBot("TestSummoner") - task, reason = await bot.connect("test") - if task: - await task if __name__ == "__main__": - asyncio.get_event_loop().run_until_complete(run_bot()) + bot = TestBot("TestSummoner") + run_controller(bot, "test") diff --git a/yaboli/__init__.py b/yaboli/__init__.py index a0717a9..5e86afe 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -5,9 +5,10 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) +from .bot import * from .connection import * -from .room import * from .controller import * +from .room import * from .utils import * __all__ = connection.__all__ + room.__all__ + controller.__all__ + utils.__all__ diff --git a/yaboli/bot.py b/yaboli/bot.py new file mode 100644 index 0000000..4b787ef --- /dev/null +++ b/yaboli/bot.py @@ -0,0 +1,138 @@ +import asyncio +import logging +import re +from .callbacks import * +from .controller import * + +logger = logging.getLogger(__name__) +__all__ = ["Bot"] + + + +class Bot(Controller): + # ^ and $ not needed since we're doing a re.fullmatch + SPECIFIC_RE = r"!(\S+)\s+@(\S+)([\S\s]*)" + GENERIC_RE = r"!(\S+)([\S\s]*)" + + def __init__(self, nick): + super().__init__(nick) + + self._callbacks = Callbacks() + self.register_default_callbacks() + + def register_callback(self, event, callback, specific=True): + self._callbacks.add((event, specific), callback) + + async def on_send(self, message): + parsed = self.parse_message(message.content) + if not parsed: + return + command, args = parsed + + # general callback (specific set to False) + general = asyncio.ensure_future( + self._callbacks.call((command, False), message, args) + ) + + if len(args) > 0: + mention = args[0] + args = args[1:] + if mention[:1] == "@" and similar(mention[1:], self.nick): + # specific callback (specific set to True) + await self._callbacks.call((command, True), message, args) + + await general + + def parse_message(self, content): + """ + (command, args) = parse_message(content) + + Returns None, not a (None, None) tuple, when message could not be parsed + """ + + match = re.fullmatch(self.GENERIC_RE, content) + if not match: + return None + + command = match.group(1) + argstr = match.group(2) + args = self.parse_args(argstr) + + return command, args + + def parse_args(self, text): + """ + Use single- and double-quotes bash-style to include whitespace in arguments. + A backslash always escapes the next character. + Any non-escaped whitespace separates arguments. + + Returns a list of arguments. + Deals with unclosed quotes and backslashes without crashing. + """ + + escape = False + quote = None + args = [] + arg = "" + + for character in text: + if escape: + arg += character + escape = False + elif character == "\\": + escape = True + elif quote: + if character == quote: + quote = None + else: + arg += character + elif character in "'\"": + quote = character + elif character.isspace() and len(arg) > 0: + args.append(arg) + arg = "" + else: + arg += character + + #if escape or quote: + #return None # syntax error + + if len(arg) > 0: + args.append(arg) + + return args + + def parse_flags(self, arglist): + flags = "" + args = [] + kwargs = {} + + for arg in arglist: + # kwargs (--abc, --foo=bar) + if arg[:2] == "--": + arg = arg[2:] + if "=" in arg: + s = arg.split("=", maxsplit=1) + kwargs[s[0]] = s[1] + else: + kwargs[arg] = None + # flags (-x, -rw) + elif arg[:1] == "-": + arg = arg[1:] + flags += arg + # args (normal arguments) + else: + args.append(arg) + + return flags, args, kwargs + + + + # BOTRULEZ COMMANDS + + def register_default_callbacks(self): + self.register_callback("ping", self.command_ping) + self.register_callback("ping", self.command_ping, specific=False) + + async def command_ping(self, message, args): + await self.room.send("Pong!", message.message_id) diff --git a/yaboli/callbacks.py b/yaboli/callbacks.py index 04c36b8..71902d5 100644 --- a/yaboli/callbacks.py +++ b/yaboli/callbacks.py @@ -1,30 +1,27 @@ +import asyncio + +__all__ = ["Callbacks"] + + + class Callbacks(): """ - Manage callbacks + Manage callbacks asynchronously """ def __init__(self): self._callbacks = {} - def add(self, event, callback, *args, **kwargs): + def add(self, event, callback): """ - add(event, callback, *args, **kwargs) -> None + add(event, callback) -> None Add a function to be called on event. - The function will be called with *args and **kwargs. - Certain arguments might be added, depending on the event. """ if not event in self._callbacks: self._callbacks[event] = [] - - callback_info = { - "callback": callback, - "args": args, - "kwargs": kwargs - } - - self._callbacks[event].append(callback_info) + self._callbacks[event].append(callback) def remove(self, event): """ @@ -36,21 +33,18 @@ class Callbacks(): if event in self._callbacks: del self._callbacks[event] - def call(self, event, *args): + async def call(self, event, *args, **kwargs): """ - call(event) -> None + await call(event) -> None - Call all callbacks subscribed to the event with *args and the arguments specified when the - callback was added. + Call all callbacks subscribed to the event with *args and **kwargs". """ - if event in self._callbacks: - for c_info in self._callbacks[event]: - c = c_info["callback"] - args = c_info["args"] + args - kwargs = c_info["kwargs"] - - c(*args, **kwargs) + tasks = [asyncio.ensure_future(callback(*args, **kwargs)) + for callback in self._callbacks.get(event, [])] + + for task in tasks: + await task def exists(self, event): """ diff --git a/yaboli/room.py b/yaboli/room.py index ad9e32f..3902110 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,5 +1,6 @@ import asyncio import logging +from .callbacks import * from .connection import * from .utils import * @@ -33,7 +34,7 @@ class Room: self.pm_with_nick = None self.pm_with_user_id = None - self._callbacks = {} + self._callbacks = Callbacks() self._add_callbacks() self._stopping = False @@ -266,20 +267,20 @@ class Room: # All the private functions for dealing with stuff def _add_callbacks(self): - self._callbacks["bounce-event"] = self._handle_bounce - self._callbacks["disconnect-event"] = self._handle_disconnect - self._callbacks["hello-event"] = self._handle_hello - self._callbacks["join-event"] = self._handle_join - self._callbacks["login-event"] = self._handle_login - self._callbacks["logout-event"] = self._handle_logout - self._callbacks["network-event"] = self._handle_network - self._callbacks["nick-event"] = self._handle_nick - self._callbacks["edit-message-event"] = self._handle_edit_message - self._callbacks["part-event"] = self._handle_part - self._callbacks["ping-event"] = self._handle_ping - self._callbacks["pm-initiate-event"] = self._handle_pm_initiate - self._callbacks["send-event"] = self._handle_send - self._callbacks["snapshot-event"] = self._handle_snapshot + 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): response = await self._conn.send(*args, **kwargs) @@ -291,12 +292,10 @@ class Room: self._check_for_errors(packet) ptype = packet.get("type") - callback = self._callbacks.get(ptype) - if callback: - try: - await callback(packet) - except asyncio.CancelledError as e: - logger.info(f"&{self.roomname}: Callback of type {ptype!r} cancelled.") + try: + await self._callbacks.call(ptype, packet) + except asyncio.CancelledError as e: + logger.info(f"&{self.roomname}: Callback of type {ptype!r} cancelled.") def _check_for_errors(self, packet): if packet.get("throttled", False): diff --git a/yaboli/utils.py b/yaboli/utils.py index 6cb77a9..eaf8d16 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,4 +1,7 @@ +import asyncio + __all__ = [ + "run_controller", "mention", "mention_reduced", "similar", "Session", "Listing", "Message", "Log", @@ -7,6 +10,18 @@ __all__ = [ +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 + + 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()) From 4deb1870ee83c13d91515c98bd6c3e39275312ef Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 4 Sep 2017 20:50:37 +0000 Subject: [PATCH 12/12] Implement (almost) all mandatory botrulez commands For the specific !help command, there will be a separate system. --- yaboli/bot.py | 44 +++++++++++++++++++++++++++++++++++++++++--- yaboli/utils.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index 4b787ef..a274d77 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,8 +1,10 @@ import asyncio import logging import re +import time from .callbacks import * from .controller import * +from .utils import * logger = logging.getLogger(__name__) __all__ = ["Bot"] @@ -17,8 +19,15 @@ class Bot(Controller): def __init__(self, nick): super().__init__(nick) + self.start_time = time.time() + self._callbacks = Callbacks() self.register_default_callbacks() + + # settings (modify in your bot's __init__) + self.general_help = None # None -> does not respond to general help + self.killable = True + self.kill_message = "/me *poof*" # how to respond to !kill, whether killable or not def register_callback(self, event, callback, specific=True): self._callbacks.add((event, specific), callback) @@ -88,9 +97,10 @@ class Bot(Controller): arg += character elif character in "'\"": quote = character - elif character.isspace() and len(arg) > 0: - args.append(arg) - arg = "" + elif character.isspace(): + if len(arg) > 0: + args.append(arg) + arg = "" else: arg += character @@ -133,6 +143,34 @@ class Bot(Controller): def register_default_callbacks(self): self.register_callback("ping", self.command_ping) self.register_callback("ping", self.command_ping, specific=False) + self.register_callback("help", self.command_help) + self.register_callback("help", self.command_help_general, specific=False) + self.register_callback("uptime", self.command_uptime) + self.register_callback("kill", self.command_kill) + # TODO: maybe !restart command async def command_ping(self, message, args): await self.room.send("Pong!", message.message_id) + + async def command_help(self, message, args): + await self.room.send("", message.message_id) + + async def command_help_general(self, message, args): + if self.general_help is not None: + await self.room.send(self.general_help, message.message_id) + + async def command_uptime(self, message, args): + now = time.time() + startformat = format_time(self.start_time) + deltaformat = format_time_delta(now - self.start_time) + text = f"/me has been up since {startformat} ({deltaformat})" + await self.room.send(text, message.message_id) + + async def command_kill(self, message, args): + logging.warn(f"Kill attempt in &{self.room.roomname}: {message.content!r}") + + if self.kill_message is not None: + await self.room.send(self.kill_message, message.message_id) + + if self.killable: + await self.stop() diff --git a/yaboli/utils.py b/yaboli/utils.py index eaf8d16..0f619ed 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,8 +1,10 @@ import asyncio +import time __all__ = [ "run_controller", "mention", "mention_reduced", "similar", + "format_time", "format_time_delta", "Session", "Listing", "Message", "Log", "ResponseError" @@ -31,6 +33,41 @@ def mention_reduced(nick): def similar(nick1, nick2): return mention_reduced(nick1) == mention_reduced(nick2) +def format_time(timestamp): + return time.strftime( + "%Y-%m-%d %H:%M:%S UTC", + time.gmtime(timestamp) + ) + +def format_time_delta(delta): + if delta < 0: + result = "-" + else: + result = "" + + delta = int(delta) + + second = 1 + minute = second*60 + hour = minute*60 + day = hour*24 + + if delta >= day: + result += f"{delta//day}d " + delta = delta%day + + if delta >= hour: + result += f"{delta//hour}h " + delta = delta%day + + if delta >= minute: + result += f"{delta//minute}m " + delta = delta%minute + + result += f"{delta}s" + + return result + class Session: def __init__(self, user_id, nick, server_id, server_era, session_id, is_staff=None, is_manager=None, client_address=None, real_address=None):