From 3945c6ae459089245ea96b1ff8fbd17516598cca Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 5 Sep 2017 19:37:14 +0000 Subject: [PATCH 001/145] Allow for bots to be restarted and fix botrulez commands --- TestBot.py | 17 ++++++++++++----- yaboli/bot.py | 45 +++++++++++++++++++++++++++++++++++++-------- yaboli/utils.py | 29 ++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/TestBot.py b/TestBot.py index be41127..cfb902d 100644 --- a/TestBot.py +++ b/TestBot.py @@ -3,12 +3,12 @@ from yaboli.utils import * -#class TestBot(Bot): class TestBot(yaboli.Bot): def __init__(self, nick): super().__init__(nick=nick) self.register_callback("tree", self.command_tree, specific=False) + self.register_callback("stree", self.command_simple_tree, specific=False) #async def on_send(self, message): #if message.content == "!spawnevil": @@ -36,13 +36,20 @@ class TestBot(yaboli.Bot): messages = [message] newmessages = [] for i in range(2): - for m in messages: + for msg in messages: for j in range(2): - newm = await self.room.send(f"{message.content}.{j}", m.message_id) + newm = await self.room.send(f"{msg.content}.{j}", msg.message_id) newmessages.append(newm) messages = newmessages newmessages = [] + + async def command_simple_tree(self, message, args): + root = await self.room.send("root message", message.message_id) + branch1 = await self.room.send("branch 1", root.message_id) + branch2 = await self.room.send("branch 2", root.message_id) + await self.room.send("branch 1.1", branch1.message_id) + await self.room.send("branch 2.1", branch2.message_id) + await self.room.send("branch 1.2", branch1.message_id) if __name__ == "__main__": - bot = TestBot("TestSummoner") - run_controller(bot, "test") + run_bot(TestBot, "test", "TestSummoner") diff --git a/yaboli/bot.py b/yaboli/bot.py index a274d77..075e991 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -19,6 +19,7 @@ class Bot(Controller): def __init__(self, nick): super().__init__(nick) + self.restarting = False # whoever runs the bot can check if a restart is necessary self.start_time = time.time() self._callbacks = Callbacks() @@ -26,12 +27,26 @@ class Bot(Controller): # settings (modify in your bot's __init__) self.general_help = None # None -> does not respond to general help + self.specific_help = "No help available" self.killable = True self.kill_message = "/me *poof*" # how to respond to !kill, whether killable or not + self.restartable = True + self.restart_message = "/me temporary *poof*" # how to respond to !restart, whether restartable or not def register_callback(self, event, callback, specific=True): self._callbacks.add((event, specific), callback) + async def restart(self): + # After calling this, the bot is stopped, not yet restarted. + self.restarting = True + await self.stop() + + def noargs(func): + async def wrapper(self, message, args): + if not args: + return await func(self, message) + return wrapper + async def on_send(self, message): parsed = self.parse_message(message.content) if not parsed: @@ -138,7 +153,7 @@ class Bot(Controller): - # BOTRULEZ COMMANDS + # BOTRULEZ AND YABOLI-SPECIFIC COMMANDS def register_default_callbacks(self): self.register_callback("ping", self.command_ping) @@ -147,19 +162,24 @@ class Bot(Controller): 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 + self.register_callback("restart", self.command_restart) - async def command_ping(self, message, args): + @noargs + async def command_ping(self, message): await self.room.send("Pong!", message.message_id) - async def command_help(self, message, args): - await self.room.send("", message.message_id) + @noargs # TODO: specific command help (!help @bot ping) + async def command_help(self, message): + if self.specific_help: + await self.room.send(self.specific_help, message.message_id) - async def command_help_general(self, message, args): + @noargs + async def command_help_general(self, message): if self.general_help is not None: await self.room.send(self.general_help, message.message_id) - async def command_uptime(self, message, args): + @noargs + async def command_uptime(self, message): now = time.time() startformat = format_time(self.start_time) deltaformat = format_time_delta(now - self.start_time) @@ -167,10 +187,19 @@ class Bot(Controller): 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}") + logging.warn(f"Kill attempt by @{mention(message.sender.nick)} 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() + + async def command_restart(self, message, args): + logging.warn(f"Restart attempt by @{mention(message.sender.nick)} in &{self.room.roomname}: {message.content!r}") + + if self.restart_message is not None: + await self.room.send(self.restart_message, message.message_id) + + if self.restartable: + await self.restart() diff --git a/yaboli/utils.py b/yaboli/utils.py index 0f619ed..f1ff82e 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,8 +1,10 @@ import asyncio +import logging import time +logger = logging.getLogger(__name__) __all__ = [ - "run_controller", + "run_controller", "run_bot", "mention", "mention_reduced", "similar", "format_time", "format_time_delta", "Session", "Listing", @@ -21,6 +23,31 @@ def run_controller(controller, room): task, reason = await controller.connect(room) if task: await task + else: + logger.warn(f"Could not connect to &{room}: {reason!r}") + + asyncio.get_event_loop().run_until_complete(run()) + +def run_bot(bot_class, room, *args, **kwargs): + """ + Helper function to run a bot. To run Multibots, use the MultibotKeeper. + This restarts the bot when it is explicitly restarted through Bot.restart(). + """ + + async def run(): + while True: + logger.info(f"Creating new instance and connecting to &{room}") + bot = bot_class(*args, **kwargs) + task, reason = await bot.connect(room) + if task: + await task + else: + logger.warn(f"Could not connect to &{room}: {reason!r}") + + if bot.restarting: + logger.info(f"Restarting in &{room}") + else: + break asyncio.get_event_loop().run_until_complete(run()) From d783abf01497abe4f0e050efa7039cb6c4279a38 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 5 Sep 2017 22:33:52 +0000 Subject: [PATCH 002/145] Generalize querying sessions in listing --- yaboli/utils.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/yaboli/utils.py b/yaboli/utils.py index f1ff82e..716b797 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -150,21 +150,29 @@ class Listing: 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(self, types=["agent", "account", "bot"], lurker=None): + sessions = [] + for uid, ses in self._sessions.items(): + if ses.client_type not in types: + continue + + is_lurker = not ses.nick # "" or None + if lurker is None or lurker == is_lurker: + sessions.append(ses) + + return sessions - def get_accounts(self): - return {uid: ses for uid, ses in self._sessions.items() - if ses.client_type is "account"} + #def get_people(self): + #return self.get(types=["agent", "account"]) - def get_agents(self): - return {uid: ses for uid, ses in self._sessions.items() - if ses.client_type is "agent"} + #def get_accounts(self): + #return self.get(types=["account"]) - def get_bots(self): - return {uid: ses for uid, ses in self._sessions.items() - if ses.client_type is "bot"} + #def get_agents(self): + #return self.get(types=["agent"]) + + #def get_bots(self): + #return self.get(types=["bot"]) class Message(): def __init__(self, message_id, time, sender, content, parent=None, previous_edit_id=None, From 80b8a997fe104ba63120aa8758eb27467d1abd26 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 5 Sep 2017 22:34:25 +0000 Subject: [PATCH 003/145] Fix typo --- yaboli/room.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yaboli/room.py b/yaboli/room.py index 3902110..3f17968 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -302,7 +302,7 @@ class Room: logger.warn(f"&{self.roomname}: Throttled for reason: {packet.get('throttled_reason', 'no reason')!r}") if "error" in packet: - raise ResponseError(response.get("error")) + raise ResponseError(packet.get("error")) async def _handle_bounce(self, packet): """ From ea1f68fb84abff21e9603f846169bb203a1d2e8c Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 5 Sep 2017 22:35:14 +0000 Subject: [PATCH 004/145] Fix sudden amnesia regarding own name When set_nick is called, self.nick is now modified. --- yaboli/controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/yaboli/controller.py b/yaboli/controller.py index 062c7a0..212e371 100644 --- a/yaboli/controller.py +++ b/yaboli/controller.py @@ -121,6 +121,7 @@ class Controller: async def set_nick(self, nick): if nick != self.nick: _, _, _, to_nick = await self.room.nick(nick) + self.nick = to_nick if to_nick != nick: logger.warn(f"&{self.room.roomname}: Could not set nick to {nick!r}, set to {to_nick!r} instead.") From 828bb978c226cf0fc2667c4b4f873e3026f27690 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 5 Sep 2017 22:36:19 +0000 Subject: [PATCH 005/145] Reduce default error logging --- yaboli/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 5e86afe..d46b6ca 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,9 +1,11 @@ import logging #logging.basicConfig(level=logging.DEBUG) -logging.basicConfig(level=logging.INFO) +#logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.WARNING) logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) +#logger.setLevel(logging.DEBUG) +logger.setLevel(logging.INFO) from .bot import * from .connection import * From a6d4a0779f915f82364813b018f0ee9a2753bc84 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 5 Sep 2017 22:36:39 +0000 Subject: [PATCH 006/145] Add help topic and multibot drafts --- yaboli/bot.py | 49 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index 075e991..858b6e1 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -25,9 +25,11 @@ class Bot(Controller): self._callbacks = Callbacks() self.register_default_callbacks() + self._help_topics = {} + # settings (modify in your bot's __init__) - self.general_help = None # None -> does not respond to general help - self.specific_help = "No help available" + self.help_general = None # None -> does not respond to general help + self.help_specific = "No help available" self.killable = True self.kill_message = "/me *poof*" # how to respond to !kill, whether killable or not self.restartable = True @@ -36,6 +38,21 @@ class Bot(Controller): def register_callback(self, event, callback, specific=True): self._callbacks.add((event, specific), callback) + def add_help(self, topic, text, description=None): + info = (text, description) # TODO: make named tuple? + self._help_topics[topic] = info + + def get_help(self, topic): + info = self._help_topics.get(topic, ("No help available", None)) + return info[0] + + def get_help_topics(self): + topics = [] + for topic, info in sorted(self._help_topics.items()): + if info[1] is not None: + topics.append(f"{topic} - {info[1]}\n") + return "".join(topics) + async def restart(self): # After calling this, the bot is stopped, not yet restarted. self.restarting = True @@ -59,9 +76,10 @@ class Bot(Controller): ) if len(args) > 0: - mention = args[0] + name = args[0] args = args[1:] - if mention[:1] == "@" and similar(mention[1:], self.nick): + if name[:1] == "@" and similar(name[1:], self.nick): + logger.debug("Specific command!") # specific callback (specific set to True) await self._callbacks.call((command, True), message, args) @@ -82,6 +100,8 @@ class Bot(Controller): argstr = match.group(2) args = self.parse_args(argstr) + logger.debug(f"Parsed command. command={command!r}, args={args!r}") + return command, args def parse_args(self, text): @@ -170,13 +190,13 @@ class Bot(Controller): @noargs # TODO: specific command help (!help @bot ping) async def command_help(self, message): - if self.specific_help: - await self.room.send(self.specific_help, message.message_id) + if self.help_specific: + await self.room.send(self.help_specific, message.message_id) @noargs async def command_help_general(self, message): - if self.general_help is not None: - await self.room.send(self.general_help, message.message_id) + if self.help_general is not None: + await self.room.send(self.help_general, message.message_id) @noargs async def command_uptime(self, message): @@ -203,3 +223,16 @@ class Bot(Controller): if self.restartable: await self.restart() + +class Multibot(Bot): + def __init__(self, nick, keeper): + super().__init__(nick) + + self.keeper = keeper + +class MultibotKeeper(): + def __init__(self, configfile): + # TODO: load configfile + + # TODO: namedtuple botinfo (bot, task) + self._bots = {} # self._bots[roomname] = botinfo From 85bcdad91683b9df0931dbf17afcb248f648fb5b Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 6 Sep 2017 10:02:02 +0000 Subject: [PATCH 007/145] Treat connection loss correctly - detect timeout using websocket pings - catch more errors in reconnect attempts --- yaboli/connection.py | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index b0a108d..4a61f1b 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -6,6 +6,7 @@ asyncio.get_event_loop().set_debug(True) import json import websockets +import socket #from websockets import ConnectionClosed __all__ = ["Connection"] @@ -13,17 +14,20 @@ __all__ = ["Connection"] class Connection: - def __init__(self, url, packet_hook, cookie=None): + def __init__(self, url, packet_hook, cookie=None, ping_timeout=10, ping_delay=30): self.url = url - self.cookie = cookie self.packet_hook = packet_hook + self.cookie = cookie + self.ping_timeout = ping_timeout # how long to wait for websocket ping reply + self.ping_delay = ping_delay # how long to wait between pings self._ws = None self._pid = 0 # successive packet ids self._spawned_tasks = set() self._pending_responses = {} - #self._stopping = False + self._runtask = None + self._pingtask = None # pings async def connect(self, max_tries=10, delay=60): """ @@ -37,17 +41,22 @@ class Connection: await self.stop() + logger.debug(f"Stopped previously running things.") + tries_left = max_tries while tries_left > 0: + logger.info(f"Attempting to connect, {tries_left} tries left.") tries_left -= 1 try: self._ws = await websockets.connect(self.url, max_size=None) - except (websockets.InvalidURI, websockets.InvalidHandshake): + except (websockets.InvalidURI, websockets.InvalidHandshake, socket.gaierror): self._ws = None if tries_left > 0: await asyncio.sleep(delay) else: self._runtask = asyncio.ensure_future(self._run()) + self._pingtask = asyncio.ensure_future(self._ping()) + logger.debug(f"return self._runtask") return self._runtask async def _run(self): @@ -65,8 +74,29 @@ class Connection: self._clean_up_tasks() await self._ws.close() # just to make sure + await self._pingtask # should stop now that the ws is closed self._ws = None + async def _ping(self): + """ + Periodically ping the server to detect a timeout. + """ + + while True: + try: + logger.debug("Pinging...") + wait_for_reply = await self._ws.ping() + await asyncio.wait_for(wait_for_reply, self.ping_timeout) + logger.debug("Pinged!") + except asyncio.TimeoutError: + logger.warning("Ping timed out.") + await self._ws.close2() + break + except websockets.ConnectionClosed: + break + else: + await asyncio.sleep(self.ping_delay) + async def stop(self): """ Close websocket connection and wait for running task to stop. From 7cfdc0f13b230095c29186325e42da484ce894d7 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 7 Sep 2017 07:50:24 +0000 Subject: [PATCH 008/145] Fix on_edit_message callback/event --- yaboli/room.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/yaboli/room.py b/yaboli/room.py index 3f17968..ba54fee 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -451,7 +451,10 @@ class Room: data = packet.get("data") message = Message.from_dict(data) - await self.controller.on_edit_message(message) + await self.controller.on_edit_message( + data.get("edit_id"), + message + ) async def _handle_part(self, packet): """ From 9c4f5e437247f90c53d771611a0329d8543734f2 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 7 Sep 2017 07:50:51 +0000 Subject: [PATCH 009/145] Catch even more exceptions to stay connected or reconnect --- yaboli/connection.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 4a61f1b..5cfa4c3 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -73,7 +73,11 @@ class Connection: self._clean_up_futures() self._clean_up_tasks() - await self._ws.close() # just to make sure + try: + await self._ws.close() # just to make sure + except: + pass # errors are not useful here + await self._pingtask # should stop now that the ws is closed self._ws = None @@ -92,7 +96,7 @@ class Connection: logger.warning("Ping timed out.") await self._ws.close2() break - except websockets.ConnectionClosed: + except (websockets.ConnectionClosed, ConnectionResetError): break else: await asyncio.sleep(self.ping_delay) @@ -103,7 +107,10 @@ class Connection: """ if self._ws: - await self._ws.close() + try: + await self._ws.close() + except: + pass # errors not useful here if self._runtask: await self._runtask From 41d7e5b0dcc9c625e61bc7bb98355f53f33ff904 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 7 Sep 2017 22:34:05 +0000 Subject: [PATCH 010/145] Add bot ping message setting --- yaboli/bot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index 858b6e1..dfc192b 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -34,6 +34,7 @@ class Bot(Controller): self.kill_message = "/me *poof*" # how to respond to !kill, whether killable or not self.restartable = True self.restart_message = "/me temporary *poof*" # how to respond to !restart, whether restartable or not + self.ping_message = "Pong!" # as specified by the botrulez def register_callback(self, event, callback, specific=True): self._callbacks.add((event, specific), callback) @@ -186,7 +187,8 @@ class Bot(Controller): @noargs async def command_ping(self, message): - await self.room.send("Pong!", message.message_id) + if self.ping_message: + await self.room.send(self.ping_message, message.message_id) @noargs # TODO: specific command help (!help @bot ping) async def command_help(self, message): From db07cdf17b9f02bacd95510ab18728f161406ce9 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 7 Sep 2017 22:35:01 +0000 Subject: [PATCH 011/145] Clean up connect loop --- yaboli/connection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 5cfa4c3..41ccb9f 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -43,10 +43,9 @@ class Connection: logger.debug(f"Stopped previously running things.") - tries_left = max_tries - while tries_left > 0: + for tries_left in reversed(range(max_tries)): logger.info(f"Attempting to connect, {tries_left} tries left.") - tries_left -= 1 + try: self._ws = await websockets.connect(self.url, max_size=None) except (websockets.InvalidURI, websockets.InvalidHandshake, socket.gaierror): @@ -56,7 +55,8 @@ class Connection: else: self._runtask = asyncio.ensure_future(self._run()) self._pingtask = asyncio.ensure_future(self._ping()) - logger.debug(f"return self._runtask") + logger.debug(f"Started run and ping tasks") + return self._runtask async def _run(self): From 2e7b36430762c7d25139c83605e07a40f2b966bb Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 7 Sep 2017 22:35:48 +0000 Subject: [PATCH 012/145] Add database submodule for easy db access --- .gitignore | 1 + DBTest.py | 25 +++++++++++++ TemplateBot.py | 92 ++++++++++++++++++++++++++++++++++++++++++++++ yaboli/__init__.py | 1 + yaboli/database.py | 88 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+) create mode 100644 DBTest.py create mode 100644 TemplateBot.py create mode 100644 yaboli/database.py diff --git a/.gitignore b/.gitignore index 5b4d23f..66427ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ yaboli/__pycache__/ +*.db diff --git a/DBTest.py b/DBTest.py new file mode 100644 index 0000000..5322432 --- /dev/null +++ b/DBTest.py @@ -0,0 +1,25 @@ +import asyncio +import yaboli + + + +class ExampleDB(yaboli.Database): + @yaboli.Database.operation + def sample_operation(connection, *args): + print(args) + #return a + b + print("returning 15...") + return 15 + +async def run(): + db = ExampleDB("test.db") + print(db.sample_operation) + #print(db.sample_operation(1, 2)) + result = await db.sample_operation(1, 2) + print(result) + +def main(): + asyncio.get_event_loop().run_until_complete(run()) + +if __name__ == "__main__": + main() diff --git a/TemplateBot.py b/TemplateBot.py new file mode 100644 index 0000000..a4615c8 --- /dev/null +++ b/TemplateBot.py @@ -0,0 +1,92 @@ +""" +Copy this template script and modify it to create a new bot. +""" + +import yaboli +from yaboli.utils import * +import sys + + + +class YourBot(yaboli.Bot): + """ + Your bot's docstring + """ + + def __init__(self): + super().__init__("Your bot's name") + + # set help and other settings here + #self.help_general = None + #self.help_specific = "No help available" + #self.killable = True + #self.kill_message = "/me *poof*" + #self.restartable = True + #self.restart_message = "/me temporary *poof*" + + # Event callbacks - just fill in your code. + # If the function contains a super(), DON'T remove it unless you know what you're doing! + # (You can remove the function itself though.) + # When you're done, remove all unneeded functions. + + async def on_connected(self): + await super().on_connected() + + async def on_disconnected(self): + await super().on_disconnected() + + async def on_bounce(self, reason=None, auth_options=[], agent_id=None, ip=None): + await super().on_bounce(reason, auth_options, agent_id, ip) + + 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_nick, to_nick): + 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): + await super().on_ping(ptime, pnext) + + async def on_pm_initiate(self, from_id, from_nick, from_room, pm_id): + pass + + async def on_send(self, message): + await super().on_send(message) # This is where yaboli.bot reacts to commands + + async def on_snapshot(self, user_id, session_id, version, listing, log, nick=None, + pm_with_nick=None, pm_with_user_id=None): + await super().on_snapshot(user_id, session_id, version, listing, log, nick, pm_with_nick, + pm_with_user_id) + +def main(): + if len(sys.argv) == 2: + run_bot(YourBot, sys.argv[1]) + else: + print("USAGE:") + print(f" {sys.argv[0]} ") + return + +if __name__ == "__main__": + main() diff --git a/yaboli/__init__.py b/yaboli/__init__.py index d46b6ca..3d36e7d 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -10,6 +10,7 @@ logger.setLevel(logging.INFO) from .bot import * from .connection import * from .controller import * +from .database import * from .room import * from .utils import * diff --git a/yaboli/database.py b/yaboli/database.py new file mode 100644 index 0000000..c14863d --- /dev/null +++ b/yaboli/database.py @@ -0,0 +1,88 @@ +import asyncio +from functools import wraps +import sqlite3 +import threading + +__all__ = ["Database"] + + + +def shielded(afunc): + #@wraps(afunc) + async def wrapper(*args, **kwargs): + return await asyncio.shield(afunc(*args, **kwargs)) + return wrapper + +class PooledConnection: + def __init__(self, pool): + self._pool = pool + + self.connection = None + + async def open(self): + self.connection = await self._pool._request() + print(self.connection) + + async def close(self): + conn = self.connection + self.connection = None + await self._pool._return(conn) + + async def __aenter__(self): + await self.open() + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.close() + +class Pool: + def __init__(self, filename, size=10): + self.filename = filename + self.size = size + + self._available_connections = asyncio.Queue() + + for i in range(size): + conn = sqlite3.connect(self.filename, check_same_thread=False) + self._available_connections.put_nowait(conn) + + def connection(self): + return PooledConnection(self) + + async def _request(self): + return await self._available_connections.get() + + async def _return(self, conn): + await self._available_connections.put(conn) + +class Database: + def __init__(self, filename, pool_size=10, event_loop=None): + self._filename = filename + self._pool = Pool(filename, size=pool_size) + self._loop = event_loop or asyncio.get_event_loop() + + def operation(func): + @wraps(func) + @shielded + async def wrapper(self, *args, **kwargs): + async with self._pool.connection() as conn: + return await self._run_in_thread(func, conn.connection, *args, **kwargs) + return wrapper + + @staticmethod + def _target_function(loop, future, func, *args, **kwargs): + result = None + try: + result = func(*args, **kwargs) + finally: + loop.call_soon_threadsafe(future.set_result, result) + + async def _run_in_thread(self, func, *args, **kwargs): + finished = asyncio.Future() + target_args = (self._loop, finished, func, *args) + + thread = threading.Thread(target=self._target_function, args=target_args, kwargs=kwargs) + thread.start() + + await finished + return finished.result() From 023d154d37952de727fb07bb6a65ec51c425779e Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 8 Sep 2017 06:04:01 +0000 Subject: [PATCH 013/145] Fix typo --- yaboli/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 41ccb9f..f3683cb 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -94,7 +94,7 @@ class Connection: logger.debug("Pinged!") except asyncio.TimeoutError: logger.warning("Ping timed out.") - await self._ws.close2() + await self._ws.close() break except (websockets.ConnectionClosed, ConnectionResetError): break From b917fed1261aa9da09607f82ba77c12979694722 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 8 Sep 2017 07:37:42 +0000 Subject: [PATCH 014/145] Add docstrings to Room --- yaboli/room.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/yaboli/room.py b/yaboli/room.py index ba54fee..d8409b8 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -10,10 +10,41 @@ __all__ = ["Room"] class Room: + """ + This class represents a connection to a room. This basically means that one + room instance means one nick on the nick list. + + It's purpose is to provide a higher-level way of interacting with a room to + a controller. This includes converting packets received from the server to + utility classes where possible, or keeping track of current room state like + the client's nick. + It does not keep track of the room's messages, as keeping (or not keeping) + messages is highly application-dependent. If needed, messages can be kept + using the utils.Log class. + + Room implements all commands necessary for creating bots. For now, the + human flag should always be False, and the cookie None. + It also "attaches" to a controller and calls the corresponding functions + when it receives events from the server + + When connection is lost while the room is running, it will attempt to + reconnect a few times. Loss of connection is determined by self._conn. + """ + ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" HUMAN_FORMAT = f"{ROOM_FORMAT}?h=1" def __init__(self, roomname, controller, human=False, cookie=None): + """ + Create a room. To connect to the room and start a run task that listens + to packets on the connection, use connect(). + + roomname - name of the room to connect to, without a "&" in front + controller - the controller which should be notified of events + human - currently not implemented, should be False + cookie - currently not implemented, should be None + """ + self.roomname = roomname self.controller = controller self.human = human @@ -47,12 +78,32 @@ class Room: self._conn = Connection(url, self._handle_packet, self.cookie) async def connect(self, max_tries=10, delay=60): + """ + runtask = await connect(max_tries, delay) + + Attempts to connect to the room once and returns a task running + self._run, if successful, otherwise None. This can be used to detect if + a room exists. + + The max_tries and delay parameters are passed on to self._run: + max_tries - maximum number of reconnect attempts before stopping + delay - time (in seconds) between reconnect attempts + """ + task = await self._conn.connect(max_tries=1) if task: self._runtask = asyncio.ensure_future(self._run(task, max_tries=max_tries, delay=delay)) return self._runtask async def _run(self, task, max_tries=10, delay=60): + """ + await _run(max_tries, delay) + + Run and reconnect when the connection is lost or closed, unless + self._stopping is set to True. + For an explanation of the parameters, see self.connect. + """ + while not self._stopping: if task.done(): task = await self._conn.connect(max_tries=max_tries, delay=delay) @@ -65,6 +116,12 @@ class Room: self.stopping = False async def stop(self): + """ + await stop() + + Close the connection to the room without reconnecting. + """ + self._stopping = True await self._conn.stop() @@ -267,6 +324,13 @@ class Room: # All the private functions for dealing with stuff def _add_callbacks(self): + """ + _add_callbacks() + + Adds the functions that handle server events to the callbacks for that + event. + """ + self._callbacks.add("bounce-event", self._handle_bounce) self._callbacks.add("disconnect-event", self._handle_disconnect) self._callbacks.add("hello-event", self._handle_hello) @@ -283,12 +347,28 @@ class Room: self._callbacks.add("snapshot-event", self._handle_snapshot) async def _send_packet(self, *args, **kwargs): + """ + reply_packet = await _send_packet(*args, **kwargs) + + Like self._conn.send, but checks for an error on the packet and raises + the corresponding exception. + """ + response = await self._conn.send(*args, **kwargs) self._check_for_errors(response) return response async def _handle_packet(self, packet): + """ + await _handle_packet(packet) + + Call the correct callbacks to deal with packet. + + This function catches CancelledErrors and instead displays an info so + the console doesn't show stack traces when a bot loses connection. + """ + self._check_for_errors(packet) ptype = packet.get("type") @@ -296,8 +376,16 @@ class Room: await self._callbacks.call(ptype, packet) except asyncio.CancelledError as e: logger.info(f"&{self.roomname}: Callback of type {ptype!r} cancelled.") + #raise # not necessary? def _check_for_errors(self, packet): + """ + _check_for_errors(packet) + + Checks for an error on the packet and raises the corresponding + exception. + """ + if packet.get("throttled", False): logger.warn(f"&{self.roomname}: Throttled for reason: {packet.get('throttled_reason', 'no reason')!r}") From ccaaf6be3f11510e21c1028f2caa7e837c86417d Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 8 Sep 2017 07:51:09 +0000 Subject: [PATCH 015/145] Clean up debugging code --- yaboli/__init__.py | 15 ++++++++++++++- yaboli/connection.py | 23 +++++++---------------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 3d36e7d..20e6b90 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,8 +1,14 @@ +import asyncio +#asyncio.get_event_loop().set_debug(True) # uncomment for asycio debugging mode + import logging + +# general (asyncio) logging level #logging.basicConfig(level=logging.DEBUG) #logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.WARNING) +# yaboli logger level logger = logging.getLogger(__name__) #logger.setLevel(logging.DEBUG) logger.setLevel(logging.INFO) @@ -14,4 +20,11 @@ from .database import * from .room import * from .utils import * -__all__ = connection.__all__ + room.__all__ + controller.__all__ + utils.__all__ +__all__ = ( + bot.__all__ + + connection.__all__ + + controller.__all__ + + database.__all__ + + room.__all__ + + utils.__all__ +) diff --git a/yaboli/connection.py b/yaboli/connection.py index f3683cb..315af0a 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -1,14 +1,10 @@ -import logging -logger = logging.getLogger(__name__) - import asyncio -asyncio.get_event_loop().set_debug(True) - import json -import websockets +import logging import socket -#from websockets import ConnectionClosed +import websockets +logger = logging.getLogger(__name__) __all__ = ["Connection"] @@ -35,6 +31,9 @@ class Connection: Attempt to connect to a room. Returns the task listening for packets, or None if the attempt failed. + + max_tries - maximum number of reconnect attempts before stopping + delay - time (in seconds) between reconnect attempts """ logger.debug(f"Attempting to connect, max_tries={max_tries}") @@ -178,15 +177,7 @@ class Connection: 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 + self._spawned_tasks = {task for task in self._spawned_tasks if not task.done()} def _wait_for_response(self, pid): future = asyncio.Future() From 5b0f078f7a1f7f93fb09a1a824394db1abf3a219 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 8 Sep 2017 08:00:50 +0000 Subject: [PATCH 016/145] Add timeout to Controller's connect() --- yaboli/connection.py | 8 ++++---- yaboli/controller.py | 21 ++++++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 315af0a..6fc2cb7 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -77,6 +77,7 @@ class Connection: except: pass # errors are not useful here + self._pingtask.cancel() await self._pingtask # should stop now that the ws is closed self._ws = None @@ -91,14 +92,13 @@ class Connection: wait_for_reply = await self._ws.ping() await asyncio.wait_for(wait_for_reply, self.ping_timeout) logger.debug("Pinged!") + await asyncio.sleep(self.ping_delay) except asyncio.TimeoutError: logger.warning("Ping timed out.") await self._ws.close() break - except (websockets.ConnectionClosed, ConnectionResetError): - break - else: - await asyncio.sleep(self.ping_delay) + except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): + return async def stop(self): """ diff --git a/yaboli/controller.py b/yaboli/controller.py index 212e371..5c84ff8 100644 --- a/yaboli/controller.py +++ b/yaboli/controller.py @@ -27,11 +27,12 @@ class Controller: """ - def __init__(self, nick, human=False, cookie=None): + def __init__(self, nick, human=False, cookie=None, connect_timeout=10): """ - 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 + 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 + connect_timeout - time for authentication to complete """ self.nick = nick self.human = human @@ -41,6 +42,7 @@ class Controller: self.password = None self.room = None + self.connect_timeout = connect_timeout # in seconds self._connect_result = None def _create_room(self, roomname): @@ -70,6 +72,7 @@ class Controller: "no password" = password needed to connect to room "wrong password" = password given does not work "disconnected" = connection closed before client could access the room + "timeout" = timed out while waiting for server "success" = no failure """ @@ -97,9 +100,13 @@ class Controller: # 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() + try: + await asyncio.wait_for(self._connect_result, self.connect_timeout) + except asyncio.TimeoutError: + result = "timeout" + else: + result = self._connect_result.result() + logger.debug(f"&{roomname}._connect_result: {result!r}") # deal with result From da84c6685ec55a612cbe284edb4c5ad967097931 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 8 Sep 2017 11:16:44 +0000 Subject: [PATCH 017/145] Improve bot command parsing Further improvements: - add message.mid or session.uid or session.sid - use "messages" instead of "log" and "sessions" instead of "listing" where they're only lists --- yaboli/bot.py | 75 ++++++++++++++++++++++---------------------- yaboli/controller.py | 2 +- yaboli/utils.py | 24 ++++++++++++++ 3 files changed, 63 insertions(+), 38 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index dfc192b..8e6d38c 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,4 +1,5 @@ import asyncio +from collections import namedtuple import logging import re import time @@ -16,6 +17,8 @@ class Bot(Controller): SPECIFIC_RE = r"!(\S+)\s+@(\S+)([\S\s]*)" GENERIC_RE = r"!(\S+)([\S\s]*)" + ParsedMessage = namedtuple("ParsedMessage", ["command", "argstr"]) + def __init__(self, nick): super().__init__(nick) @@ -60,50 +63,48 @@ class Bot(Controller): await self.stop() def noargs(func): - async def wrapper(self, message, args): - if not args: + async def wrapper(self, message, argstr): + if not argstr: return await func(self, message) return wrapper async def on_send(self, message): - parsed = self.parse_message(message.content) - if not parsed: - return - command, args = parsed + wait = [] - # general callback (specific set to False) - general = asyncio.ensure_future( - self._callbacks.call((command, False), message, args) - ) + specific = self.parse_message(message.content, specific=True) + if specific: + wait.append(self._callbacks.call( + (specific.command, True), + message, + specific.argstr + )) - if len(args) > 0: - name = args[0] - args = args[1:] - if name[:1] == "@" and similar(name[1:], self.nick): - logger.debug("Specific command!") - # specific callback (specific set to True) - await self._callbacks.call((command, True), message, args) + general = self.parse_message(message.content, specific=False) + if general: + wait.append(self._callbacks.call( + (general.command, False), + message, + general.argstr + )) - await general + if wait: + await asyncio.wait(wait) - def parse_message(self, content): + def parse_message(self, content, specific=True): """ - (command, args) = parse_message(content) + ParsedMessage = 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) - - logger.debug(f"Parsed command. command={command!r}, args={args!r}") - - return command, args + if specific: + match = re.fullmatch(self.SPECIFIC_RE, content) + if match and similar(match.group(2), self.nick): + return self.ParsedMessage(match.group(1), match.group(3)) + else: + match = re.fullmatch(self.GENERIC_RE, content) + if match: + return self.ParsedMessage(match.group(1), match.group(2)) def parse_args(self, text): """ @@ -188,17 +189,17 @@ class Bot(Controller): @noargs async def command_ping(self, message): if self.ping_message: - await self.room.send(self.ping_message, message.message_id) + await self.room.send(self.ping_message, message.mid) @noargs # TODO: specific command help (!help @bot ping) async def command_help(self, message): if self.help_specific: - await self.room.send(self.help_specific, message.message_id) + await self.room.send(self.help_specific, message.mid) @noargs async def command_help_general(self, message): if self.help_general is not None: - await self.room.send(self.help_general, message.message_id) + await self.room.send(self.help_general, message.mid) @noargs async def command_uptime(self, message): @@ -206,13 +207,13 @@ class Bot(Controller): 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) + await self.room.send(text, message.mid) async def command_kill(self, message, args): logging.warn(f"Kill attempt by @{mention(message.sender.nick)} in &{self.room.roomname}: {message.content!r}") if self.kill_message is not None: - await self.room.send(self.kill_message, message.message_id) + await self.room.send(self.kill_message, message.mid) if self.killable: await self.stop() @@ -221,7 +222,7 @@ class Bot(Controller): logging.warn(f"Restart attempt by @{mention(message.sender.nick)} in &{self.room.roomname}: {message.content!r}") if self.restart_message is not None: - await self.room.send(self.restart_message, message.message_id) + await self.room.send(self.restart_message, message.mid) if self.restartable: await self.restart() diff --git a/yaboli/controller.py b/yaboli/controller.py index 5c84ff8..14faec3 100644 --- a/yaboli/controller.py +++ b/yaboli/controller.py @@ -207,7 +207,7 @@ class Controller: async def on_send(self, message): pass - async def on_snapshot(self, user_id, session_id, version, listing, log, nick=None, + async def on_snapshot(self, user_id, session_id, version, sessions, messages, nick=None, pm_with_nick=None, pm_with_user_id=None): if nick != self.nick: await self.room.nick(self.nick) diff --git a/yaboli/utils.py b/yaboli/utils.py index 716b797..760ca0b 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -108,6 +108,22 @@ class Session: self.client_address = client_address self.real_address = real_address + @property + def uid(self): + return self.user_id + + @uid.setter + def uid(self, new_uid): + self.user_id = new_uid + + @property + def sid(self): + return self.session_id + + @sid.setter + def sid(self, new_sid): + self.session_id = new_sid + @classmethod def from_dict(cls, d): return cls( @@ -188,6 +204,14 @@ class Message(): self.deleted = deleted self.truncated = truncated + @property + def mid(self): + return self.message_id + + @mid.setter + def mid(self, new_mid): + self.message_id = new_mid + @classmethod def from_dict(cls, d): return cls( From 405a9b81a51861112ef3cd659c24a9d8430f943c Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 8 Sep 2017 13:53:35 +0000 Subject: [PATCH 018/145] Add triggers and rename callbacks to commands --- yaboli/bot.py | 46 +++++++++++++++++++++++++++------------------ yaboli/callbacks.py | 9 +++++++++ 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index 8e6d38c..88166c9 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -25,8 +25,9 @@ class Bot(Controller): self.restarting = False # whoever runs the bot can check if a restart is necessary self.start_time = time.time() - self._callbacks = Callbacks() - self.register_default_callbacks() + self._commands = Callbacks() + self._triggers = Callbacks() + self.register_default_commands() self._help_topics = {} @@ -39,8 +40,11 @@ class Bot(Controller): self.restart_message = "/me temporary *poof*" # how to respond to !restart, whether restartable or not self.ping_message = "Pong!" # as specified by the botrulez - def register_callback(self, event, callback, specific=True): - self._callbacks.add((event, specific), callback) + def register_command(self, command, callback, specific=True): + self._commands.add((command, specific), callback) + + def register_trigger(self, regex, callback): + self._triggers.add(regex, callback) def add_help(self, topic, text, description=None): info = (text, description) # TODO: make named tuple? @@ -71,22 +75,28 @@ class Bot(Controller): async def on_send(self, message): wait = [] + # get specific command to call (if any) specific = self.parse_message(message.content, specific=True) if specific: - wait.append(self._callbacks.call( + wait.append(self._commands.call( (specific.command, True), - message, - specific.argstr + message, specific.argstr )) + # get generic command to call (if any) general = self.parse_message(message.content, specific=False) if general: - wait.append(self._callbacks.call( + wait.append(self._commands.call( (general.command, False), - message, - general.argstr + message, general.argstr )) + # find triggers to call (if any) + for trigger in self._triggers.list(): + match = re.fullmatch(trigger, message.content) + if match: + wait.append(self._triggers.call(trigger, message, match)) + if wait: await asyncio.wait(wait) @@ -177,14 +187,14 @@ class Bot(Controller): # BOTRULEZ AND YABOLI-SPECIFIC COMMANDS - 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) - self.register_callback("restart", self.command_restart) + def register_default_commands(self): + self.register_command("ping", self.command_ping) + self.register_command("ping", self.command_ping, specific=False) + self.register_command("help", self.command_help) + self.register_command("help", self.command_help_general, specific=False) + self.register_command("uptime", self.command_uptime) + self.register_command("kill", self.command_kill) + self.register_command("restart", self.command_restart) @noargs async def command_ping(self, message): diff --git a/yaboli/callbacks.py b/yaboli/callbacks.py index 71902d5..9f88a49 100644 --- a/yaboli/callbacks.py +++ b/yaboli/callbacks.py @@ -54,3 +54,12 @@ class Callbacks(): """ return event in self._callbacks + + def list(self): + """ + list() -> callbacks + + Returns a list of current callbacks + """ + + return list(self._callbacks.keys()) From 6628b27ec31323f581d455805507894cabb8e64c Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 8 Sep 2017 15:45:59 +0000 Subject: [PATCH 019/145] Improve help topic system --- yaboli/bot.py | 105 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 92 insertions(+), 13 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index 88166c9..a728afe 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -18,6 +18,7 @@ class Bot(Controller): GENERIC_RE = r"!(\S+)([\S\s]*)" ParsedMessage = namedtuple("ParsedMessage", ["command", "argstr"]) + TopicHelp = namedtuple("TopicHelp", ["text", "visible"]) def __init__(self, nick): super().__init__(nick) @@ -30,6 +31,7 @@ class Bot(Controller): self.register_default_commands() self._help_topics = {} + self.add_default_help_topics() # settings (modify in your bot's __init__) self.help_general = None # None -> does not respond to general help @@ -46,20 +48,57 @@ class Bot(Controller): def register_trigger(self, regex, callback): self._triggers.add(regex, callback) - def add_help(self, topic, text, description=None): - info = (text, description) # TODO: make named tuple? + def add_help(self, topic, text, visible=True): + info = self.TopicHelp(text, visible) self._help_topics[topic] = info def get_help(self, topic): - info = self._help_topics.get(topic, ("No help available", None)) - return info[0] + info = self._help_topics.get(topic, None) + if info: + return self.format_help(info.text) - def get_help_topics(self): - topics = [] + def format_help(self, helptext): + return helptext.format( + nick=mention(self.nick) + ) + + def list_help_topics(self, max_characters=100): + # Magic happens here to ensure that the resulting lines are always + # max_characters or less characters long. + + lines = [] + curline = "" + wrapper = None + for topic, info in sorted(self._help_topics.items()): - if info[1] is not None: - topics.append(f"{topic} - {info[1]}\n") - return "".join(topics) + if not info.visible: + continue + + if wrapper: + curline += "," + lines.append(curline) + curline = wrapper + wrapper = None + + if not curline: + curline = topic + elif len(curline) + len(f", {topic},") <= max_characters: + curline += f", {topic}" + elif len(curline) + len(f", {topic}") <= max_characters: + wrapper = topic + else: + curline += "," + lines.append(curline) + curline = topic + + if wrapper: + curline += "," + lines.append(curline) + lines.append(wrapper) + elif curline: + lines.append(curline) + + return "\n".join(lines) async def restart(self): # After calling this, the bot is stopped, not yet restarted. @@ -196,15 +235,55 @@ class Bot(Controller): self.register_command("kill", self.command_kill) self.register_command("restart", self.command_restart) + def add_default_help_topics(self): + self.add_help("botrulez", ( + "This bot complies with the botrulez at https://github.com/jedevc/botrulez.\n" + "It implements the standard commands, and additionally !kill and !restart.\n\n" + "Standard commands:\n" + " !ping, !ping @{nick} - reply with a short pong message\n" + " !help, !help @{nick} - reply with help about the bot\n" + " !uptime @{nick} - reply with the bot's uptime\n\n" + "Non-standard commands:\n" + " !kill @{nick} - terminate this bot instance\n" + " !restart @{nick} - restart this bot instance\n\n" + "Command extensions:\n" + " !help @{nick} [ ...] - provide help on the topics listed" + )) + + self.add_help("yaboli", ( + "Yaboli is \"Yet Another BOt LIbrary for euphoria\", written by @Garmy in Python.\n" + "It relies heavily on the asyncio module from the standard library and uses f-strings.\n" + "Because of this, Python version >= 3.6 is required.\n\n" + "Github: https://github.com/Garmelon/yaboli" + )) + @noargs async def command_ping(self, message): if self.ping_message: await self.room.send(self.ping_message, message.mid) - @noargs # TODO: specific command help (!help @bot ping) - async def command_help(self, message): - if self.help_specific: - await self.room.send(self.help_specific, message.mid) + async def command_help(self, message, argstr): + args = self.parse_args(argstr.lower()) + if not args: + if self.help_specific: + await self.room.send( + self.format_help(self.help_specific), + message.mid + ) + else: + # collect all valid topics + messages = [] + for topic in sorted(set(args)): + text = self.get_help(topic) + if text: + messages.append(f"Topic: {topic}\n{text}") + + # print result in separate messages + if messages: + for text in messages: + await self.room.send(text, message.mid) + else: + await self.room.send("None of those topics found.", message.mid) @noargs async def command_help_general(self, message): From 31b3f715e00e246b6fa729358f5c0a7d1b4e9546 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 8 Sep 2017 15:53:03 +0000 Subject: [PATCH 020/145] Remove testing scripts --- DBTest.py | 25 ------------------------- TestBot.py | 55 ------------------------------------------------------ 2 files changed, 80 deletions(-) delete mode 100644 DBTest.py delete mode 100644 TestBot.py diff --git a/DBTest.py b/DBTest.py deleted file mode 100644 index 5322432..0000000 --- a/DBTest.py +++ /dev/null @@ -1,25 +0,0 @@ -import asyncio -import yaboli - - - -class ExampleDB(yaboli.Database): - @yaboli.Database.operation - def sample_operation(connection, *args): - print(args) - #return a + b - print("returning 15...") - return 15 - -async def run(): - db = ExampleDB("test.db") - print(db.sample_operation) - #print(db.sample_operation(1, 2)) - result = await db.sample_operation(1, 2) - print(result) - -def main(): - asyncio.get_event_loop().run_until_complete(run()) - -if __name__ == "__main__": - main() diff --git a/TestBot.py b/TestBot.py deleted file mode 100644 index cfb902d..0000000 --- a/TestBot.py +++ /dev/null @@ -1,55 +0,0 @@ -import yaboli -from yaboli.utils import * - - - -class TestBot(yaboli.Bot): - def __init__(self, nick): - super().__init__(nick=nick) - - self.register_callback("tree", self.command_tree, specific=False) - self.register_callback("stree", self.command_simple_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) - - #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 command_tree(self, message, args): - messages = [message] - newmessages = [] - for i in range(2): - for msg in messages: - for j in range(2): - newm = await self.room.send(f"{msg.content}.{j}", msg.message_id) - newmessages.append(newm) - messages = newmessages - newmessages = [] - - async def command_simple_tree(self, message, args): - root = await self.room.send("root message", message.message_id) - branch1 = await self.room.send("branch 1", root.message_id) - branch2 = await self.room.send("branch 2", root.message_id) - await self.room.send("branch 1.1", branch1.message_id) - await self.room.send("branch 2.1", branch2.message_id) - await self.room.send("branch 1.2", branch1.message_id) - -if __name__ == "__main__": - run_bot(TestBot, "test", "TestSummoner") From ba9c5d38cc82076793ec8644177252ff091bf933 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 10 Sep 2017 12:54:17 +0000 Subject: [PATCH 021/145] Fix time delta format --- yaboli/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yaboli/utils.py b/yaboli/utils.py index 760ca0b..6206560 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -85,7 +85,7 @@ def format_time_delta(delta): if delta >= hour: result += f"{delta//hour}h " - delta = delta%day + delta = delta%hour if delta >= minute: result += f"{delta//minute}m " From be48e67b3a90831de3954f46262534734eae1a96 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 10 Sep 2017 12:58:40 +0000 Subject: [PATCH 022/145] Remove debugging print --- yaboli/database.py | 1 - 1 file changed, 1 deletion(-) diff --git a/yaboli/database.py b/yaboli/database.py index c14863d..1d6e359 100644 --- a/yaboli/database.py +++ b/yaboli/database.py @@ -21,7 +21,6 @@ class PooledConnection: async def open(self): self.connection = await self._pool._request() - print(self.connection) async def close(self): conn = self.connection From 16e99107298806288716cefe6bbcc2f91a3878cf Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 22 Oct 2017 10:40:24 +0000 Subject: [PATCH 023/145] Fix bug where own session is added to sessions --- yaboli/room.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/yaboli/room.py b/yaboli/room.py index d8409b8..873dcd5 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -306,7 +306,8 @@ class Room: # update self.listing self.listing = Listing() for session in sessions: - self.listing.add(session) + if not session.sid == self.session.sid: + self.listing.add(session) return sessions From 156e9088efd987ca6e38742bb3b3597c2e0dadc5 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 26 Oct 2017 16:34:47 +0000 Subject: [PATCH 024/145] Fix missing parentheses on call to items() --- yaboli/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yaboli/utils.py b/yaboli/utils.py index 6206560..0f81076 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -157,7 +157,7 @@ class Listing: self._sessions.pop(session_id) def remove_combo(self, server_id, server_era): - self._sessions = {i: ses for i, ses in self._sessions.items + 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): From 5952c4c6bd86a55bc761be135d0679f9503b9828 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 16 Feb 2018 23:30:25 +0000 Subject: [PATCH 025/145] Allow for pre-compiled regex triggers --- yaboli/bot.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index a728afe..0672e70 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -46,7 +46,10 @@ class Bot(Controller): self._commands.add((command, specific), callback) def register_trigger(self, regex, callback): - self._triggers.add(regex, callback) + self._triggers.add(re.compile(regex), callback) + + def register_trigger_compiled(self, comp_regex, callback): + self._triggers.add(comp_regex, callback) def add_help(self, topic, text, visible=True): info = self.TopicHelp(text, visible) @@ -132,7 +135,7 @@ class Bot(Controller): # find triggers to call (if any) for trigger in self._triggers.list(): - match = re.fullmatch(trigger, message.content) + match = trigger.fullmatch(message.content) if match: wait.append(self._triggers.call(trigger, message, match)) From c728ff331a762777ce9e5d00e62a859f4e1423dc Mon Sep 17 00:00:00 2001 From: jeremyredhead Date: Mon, 19 Feb 2018 20:50:28 +0000 Subject: [PATCH 026/145] Add a SetupTools setup.py file This will making installing & using yaboli easier --- setup.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 setup.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c46f97b --- /dev/null +++ b/setup.py @@ -0,0 +1,10 @@ +from setuptools import setup + +setup(name='yaboli', + version='1.0', + description='Yet Another BOt LIbrary for euphoria.io', + author='Garmelon', + url='https://github.com/Garmelon/yaboli', + packages=['yaboli'], + install_requires=['websockets'] +) From 6b65bef5e0c036a971335c63808530434061d94e Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 25 Jul 2018 16:02:38 +0000 Subject: [PATCH 027/145] Start rewrite --- yaboli/__init__.py | 29 +- yaboli/connection.py | 285 +++++++++-------- yaboli/cookiejar.py | 65 ++++ yaboli/exceptions.py | 4 + yaboli/room.py | 747 ++++++++----------------------------------- 5 files changed, 354 insertions(+), 776 deletions(-) create mode 100644 yaboli/cookiejar.py create mode 100644 yaboli/exceptions.py diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 20e6b90..681e168 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,30 +1,21 @@ +# ---------- BEGIN DEV SECTION ---------- import asyncio -#asyncio.get_event_loop().set_debug(True) # uncomment for asycio debugging mode - import logging -# general (asyncio) logging level -#logging.basicConfig(level=logging.DEBUG) -#logging.basicConfig(level=logging.INFO) -logging.basicConfig(level=logging.WARNING) +# asyncio debugging +asyncio.get_event_loop().set_debug(True) # uncomment for asycio debugging mode +logging.getLogger("asyncio").setLevel(logging.DEBUG) # yaboli logger level -logger = logging.getLogger(__name__) -#logger.setLevel(logging.DEBUG) -logger.setLevel(logging.INFO) +logging.getLogger(__name__).setLevel(logging.DEBUG) +# ----------- END DEV SECTION ----------- -from .bot import * +from .cookiejar import * from .connection import * -from .controller import * -from .database import * -from .room import * -from .utils import * +from .exceptions import * __all__ = ( - bot.__all__ + connection.__all__ + - controller.__all__ + - database.__all__ + - room.__all__ + - utils.__all__ + cookiejar.__all__ + + exceptions.__all__ ) diff --git a/yaboli/connection.py b/yaboli/connection.py index 6fc2cb7..94cfc57 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -4,120 +4,38 @@ import logging import socket import websockets +from .exceptions import ConnectionClosed + + logger = logging.getLogger(__name__) __all__ = ["Connection"] - class Connection: - def __init__(self, url, packet_hook, cookie=None, ping_timeout=10, ping_delay=30): + def __init__(self, url, packet_callback, disconnect_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10): self.url = url - self.packet_hook = packet_hook - self.cookie = cookie + self.packet_callback = packet_callback + self.disconnect_callback = disconnect_callback + self.cookiejar = cookiejar self.ping_timeout = ping_timeout # how long to wait for websocket ping reply self.ping_delay = ping_delay # how long to wait between pings - + self.reconnect_attempts = reconnect_attempts + self._ws = None self._pid = 0 # successive packet ids - self._spawned_tasks = set() + #self._spawned_tasks = set() self._pending_responses = {} - - self._runtask = None - self._pingtask = None # pings - - async def connect(self, max_tries=10, delay=60): - """ - success = await connect(max_tries=10, delay=60) - - Attempt to connect to a room. - Returns the task listening for packets, or None if the attempt failed. - - max_tries - maximum number of reconnect attempts before stopping - delay - time (in seconds) between reconnect attempts - """ - - logger.debug(f"Attempting to connect, max_tries={max_tries}") - - await self.stop() - - logger.debug(f"Stopped previously running things.") - - for tries_left in reversed(range(max_tries)): - logger.info(f"Attempting to connect, {tries_left} tries left.") - try: - self._ws = await websockets.connect(self.url, max_size=None) - except (websockets.InvalidURI, websockets.InvalidHandshake, socket.gaierror): - self._ws = None - if tries_left > 0: - await asyncio.sleep(delay) - else: - self._runtask = asyncio.ensure_future(self._run()) - self._pingtask = asyncio.ensure_future(self._ping()) - logger.debug(f"Started run and ping tasks") - - return self._runtask - - async def _run(self): - """ - Listen for packets and deal with them accordingly. - """ - - try: - while True: - await self._handle_next_message() - except websockets.ConnectionClosed: - pass - finally: - self._clean_up_futures() - self._clean_up_tasks() - - try: - await self._ws.close() # just to make sure - except: - pass # errors are not useful here - - self._pingtask.cancel() - await self._pingtask # should stop now that the ws is closed - self._ws = None - - async def _ping(self): - """ - Periodically ping the server to detect a timeout. - """ - - while True: - try: - logger.debug("Pinging...") - wait_for_reply = await self._ws.ping() - await asyncio.wait_for(wait_for_reply, self.ping_timeout) - logger.debug("Pinged!") - await asyncio.sleep(self.ping_delay) - except asyncio.TimeoutError: - logger.warning("Ping timed out.") - await self._ws.close() - break - except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): - return - - async def stop(self): - """ - Close websocket connection and wait for running task to stop. - """ - - if self._ws: - try: - await self._ws.close() - except: - pass # errors not useful here - - if self._runtask: - await self._runtask - + self._stopped = False + self._pingtask = None + self._runtask = asyncio.create_task(self._run()) + # ... aaand the connection is started. + async def send(self, ptype, data=None, await_response=True): if not self._ws: - raise asyncio.CancelledError - + raise exceptions.ConnectionClosed + #raise asyncio.CancelledError + pid = str(self._new_pid()) packet = { "type": ptype, @@ -125,62 +43,157 @@ class Connection: } if data: packet["data"] = data - + if await_response: wait_for = self._wait_for_response(pid) - + logging.debug(f"Currently used websocket at self._ws: {self._ws}") await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size - + if await_response: await wait_for return wait_for.result() - + + async def stop(self): + """ + Close websocket connection and wait for running task to stop. + + No connection function are to be called after calling stop(). + This means that stop() can only be called once. + """ + + self._stopped = True + if self._ws: + await self._ws.close() # _run() does the cleaning up now. + await self._runtask + + async def _connect(self, tries): + """ + Attempt to connect to a room. + If the Connection is already connected, it attempts to reconnect. + + Returns True on success, False on failure. + + If tries is None, connect retries infinitely. + The delay between connection attempts doubles every attempt (starts with 1s). + """ + + # Assumes _disconnect() has already been called in _run() + + delay = 1 # seconds + while True: + try: + if self._cookiejar: + cookies = [("Cookie", cookie) for cookie in self._cookiejar.sniff()] + self._ws = await websockets.connect(self.url, max_size=None, extra_headers=cookies) + else: + self._ws = await websockets.connect(self.url, max_size=None) + except (websockets.InvalidHandshake, socket.gaierror): # not websockets.InvalidURI + self._ws = None + + if tries is not None: + tries -= 1 + if tries <= 0: + return False + + await asyncio.sleep(delay) + delay *= 2 + else: + if self._cookiejar: + for set_cookie in self._ws.response_headers.get_all("Set-Cookie"): + self._cookiejar.bake(set_cookie) + + self._pingtask = asyncio.create_task(self._ping()) + + return True + + async def _disconnect(self): + """ + Disconnect and clean up all "residue", such as: + - close existing websocket connection + - cancel all pending response futures with a ConnectionClosed exception + - reset package ID counter + - make sure the ping task has finished + """ + + # stop ping task + if self._pingtask: + self._pingtask.cancel() + await self._pingtask + self._pingtask = None + + if self._ws: + await self._ws.close() + self._ws = None + + self._pid = 0 + + # clean up pending response futures + for _, future in self._pending_responses.items(): + logger.debug(f"Cancelling future with ConnectionClosed: {future}") + future.set_exception(exceptions.ConnectionClosed("No server response")) + self._pending_responses = {} + + async def _run(self): + """ + Listen for packets and deal with them accordingly. + """ + + while not self._stopped: + self._connect(self.reconnect_attempts) + + try: + while True: + await self._handle_next_message() + except websockets.ConnectionClosed: + pass + finally: + await self._disconnect() # disconnect and clean up + + async def _ping(self): + """ + Periodically ping the server to detect a timeout. + """ + + try: + while True: + logger.debug("Pinging...") + wait_for_reply = await self._ws.ping() + await asyncio.wait_for(wait_for_reply, self.ping_timeout) + logger.debug("Pinged!") + await asyncio.sleep(self.ping_delay) + except asyncio.TimeoutError: + logger.warning("Ping timed out.") + await self._ws.close() # trigger a reconnect attempt + except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): + pass + def _new_pid(self): self._pid += 1 return self._pid - + async def _handle_next_message(self): response = await self._ws.recv() - task = asyncio.ensure_future(self._handle_json(response)) - self._track_task(task) # will be cancelled when the connection is closed - - def _clean_up_futures(self): - for pid, future in self._pending_responses.items(): - logger.debug(f"Cancelling future: {future}") - future.cancel() - self._pending_responses = {} - - def _clean_up_tasks(self): - for task in self._spawned_tasks: - if not task.done(): - logger.debug(f"Cancelling task: {task}") - task.cancel() - else: - logger.debug(f"Task already done: {task}") - logger.debug(f"Exception: {task.exception()}") - self._spawned_tasks = set() - - async def _handle_json(self, text): packet = json.loads(text) - + # Deal with pending responses pid = packet.get("id", None) future = self._pending_responses.pop(pid, None) if future: future.set_result(packet) - + + ptype = packet.get("type") + data = packet.get("data", None) + error = packet.get("error", None) + if packet.get("throttled", False): + throttled = packet.get("throttled_reason") + else: + throttled = None + # Pass packet onto room - await self.packet_hook(packet) - - def _track_task(self, task): - self._spawned_tasks.add(task) - - # only keep running tasks - self._spawned_tasks = {task for task in self._spawned_tasks if not task.done()} - + asyncio.create_task(self.packet_callback(ptype, data, error, throttled)) + def _wait_for_response(self, pid): future = asyncio.Future() self._pending_responses[pid] = future - return future diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py new file mode 100644 index 0000000..5f6c922 --- /dev/null +++ b/yaboli/cookiejar.py @@ -0,0 +1,65 @@ +import contextlib +import http.cookies as cookies +import logging + + +logger = logging.getLogger(__name__) +__all__ = ["CookieJar"] + + +class CookieJar: + """ + Keeps your cookies in a file. + """ + + def __init__(self, filename): + self._filename = filename + self._cookies = cookies.SimpleCookie() + + with contextlib.suppress(FileNotFoundError): + with open(self._filename, "r") as f: + for line in f: + self._cookies.load(line) + + def sniff(self): + """ + Returns a list of Cookie headers containing all current cookies. + """ + + return [morsel.OutputString(attrs=[]) for morsel in self._cookies.values()] + + def bake(self, cookie_string): + """ + Parse cookie and add it to the jar. + Does not automatically save to the cookie file. + + Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; HttpOnly; Secure" + """ + + logger.debug(f"Baking cookie: {cookie_string!r}") + + self._cookies.load(cookie_string) + + def save(self): + """ + Saves all current cookies to the cookie jar file. + """ + + logger.debug(f"Saving cookies to {self._filename!r}") + + with open(self._filename, "w") as f: + for morsel in self._cookies.values(): + cookie_string = morsel.OutputString() + #f.write(f"{cookie_string}\n") + f.write(cookie_string) + f.write("\n") + + def monster(self): + """ + Removes all cookies from the cookie jar. + Does not automatically save to the cookie file. + """ + + logger.debug("OMNOMNOM, cookies are all gone!") + + self._cookies = cookies.SimpleCookie() diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py new file mode 100644 index 0000000..4aaa8e0 --- /dev/null +++ b/yaboli/exceptions.py @@ -0,0 +1,4 @@ +__all__ = ["ConnectionClosed"] + +class ConnectionClosed(Exception): + pass diff --git a/yaboli/room.py b/yaboli/room.py index 873dcd5..cfc6ad7 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,637 +1,142 @@ -import asyncio -import logging -from .callbacks import * -from .connection import * -from .utils import * - -logger = logging.getLogger(__name__) -__all__ = ["Room"] - +__all__ == ["Room", "Inhabitant"] class Room: """ - This class represents a connection to a room. This basically means that one - room instance means one nick on the nick list. - - It's purpose is to provide a higher-level way of interacting with a room to - a controller. This includes converting packets received from the server to - utility classes where possible, or keeping track of current room state like - the client's nick. - It does not keep track of the room's messages, as keeping (or not keeping) - messages is highly application-dependent. If needed, messages can be kept - using the utils.Log class. - - Room implements all commands necessary for creating bots. For now, the - human flag should always be False, and the cookie None. - It also "attaches" to a controller and calls the corresponding functions - when it receives events from the server - - When connection is lost while the room is running, it will attempt to - reconnect a few times. Loss of connection is determined by self._conn. + TODO """ - - ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" - HUMAN_FORMAT = f"{ROOM_FORMAT}?h=1" - - def __init__(self, roomname, controller, human=False, cookie=None): - """ - Create a room. To connect to the room and start a run task that listens - to packets on the connection, use connect(). - - roomname - name of the room to connect to, without a "&" in front - controller - the controller which should be notified of events - human - currently not implemented, should be False - cookie - currently not implemented, should be None - """ - + + def __init__(self, roomname, inhabitant, password=None, human=False, cookiejar=None): + # TODO: Connect to room etc. + # TODO: Deal with room/connection states of: + # disconnected connecting, fast-forwarding, connected + + self._inhabitant = inhabitant + + # Room info (all fields readonly!) self.roomname = roomname - self.controller = controller - self.human = human - self.cookie = cookie - - # Keeps track of sessions, but not messages, since they might be dealt - # with differently by different controllers. - # If you need to keep track of messages, use utils.Log. self.session = None self.account = None - self.listing = Listing() - - # Various room information + self.listing = None # TODO self.account_has_access = None self.account_email_verified = None self.room_is_private = None self.version = None # the version of the code being run and served by the server self.pm_with_nick = None self.pm_with_user_id = None - - self._callbacks = Callbacks() - self._add_callbacks() - - self._stopping = False - self._runtask = None - - if human: - url = self.HUMAN_FORMAT.format(self.roomname) + + #asyncio.create_task(self._run()) + + async def exit(self): + pass + +# ROOM COMMANDS +# These always return a response from the server. +# If the connection is lost while one of these commands is called, +# the command will retry once the bot has reconnected. + + async def get_message(self, mid): + pass + + async def log(self, n, before_mid=None): + pass + + async def nick(self, nick): + pass + + async def pm(self, uid): + pass + + async def send(self, content, parent_mid=None): + """ + Send a message to the room. + See http://api.euphoria.io/#send + """ + + if parent_mid: + data = await self._send_while_connected( + "send", + content=content, + parent=parent_mid + ) else: - url = self.ROOM_FORMAT.format(self.roomname) - self._conn = Connection(url, self._handle_packet, self.cookie) - - async def connect(self, max_tries=10, delay=60): - """ - runtask = await connect(max_tries, delay) - - Attempts to connect to the room once and returns a task running - self._run, if successful, otherwise None. This can be used to detect if - a room exists. - - The max_tries and delay parameters are passed on to self._run: - max_tries - maximum number of reconnect attempts before stopping - delay - time (in seconds) between reconnect attempts - """ - - task = await self._conn.connect(max_tries=1) - if task: - self._runtask = asyncio.ensure_future(self._run(task, max_tries=max_tries, delay=delay)) - return self._runtask - - async def _run(self, task, max_tries=10, delay=60): - """ - await _run(max_tries, delay) - - Run and reconnect when the connection is lost or closed, unless - self._stopping is set to True. - For an explanation of the parameters, see self.connect. - """ - - while not self._stopping: - if task.done(): - task = await self._conn.connect(max_tries=max_tries, delay=delay) - if not task: - return - - await task - await self.controller.on_disconnected() - - self.stopping = False - - async def stop(self): - """ - await stop() - - Close the connection to the room without reconnecting. - """ - - self._stopping = True - await self._conn.stop() - - if self._runtask: - await self._runtask - - - - # CATEGORY: SESSION COMMANDS - - async def auth(self, atype, passcode=None): - """ - success, reason=None = await auth(atype, passcode=None) - - From api.euphoria.io: - The auth command attempts to join a private room. It should be sent in - response to a bounce-event at the beginning of a session. - - The auth-reply packet reports whether the auth command succeeded. - """ - - data = {"type": atype} - if passcode: - data["passcode"] = passcode - - response = await self._send_packet("auth", data) - rdata = response.get("data") - - success = rdata.get("success") - reason = rdata.get("reason", None) - return success, reason - - async def ping_reply(self, time): - """ - await ping_reply(time) - - From api.euphoria.io: - The ping command initiates a client-to-server ping. The server will - send back a ping-reply with the same timestamp as soon as possible. - - ping-reply is a response to a ping command or ping-event. - """ - - data = {"time": time} - await self._conn.send("ping-reply", data, await_response=False) - - # CATEGORY: CHAT ROOM COMMANDS - - async def get_message(self, message_id): - """ - message = await get_message(message_id) - - From api.euphoria.io: - The get-message command retrieves the full content of a single message - in the room. - - get-message-reply returns the message retrieved by get-message. - """ - - data = {"id": message_id} - - response = await self._send_packet("get-message", data) - rdata = response.get("data") - - message = Message.from_dict(rdata) - return message - - async def log(self, n, before=None): - """ - log, before=None = await log(n, before=None) - - From api.euphoria.io: - The log command requests messages from the room’s message log. This can - be used to supplement the log provided by snapshot-event (for example, - when scrolling back further in history). - - The log-reply packet returns a list of messages from the room’s message - """ - - data = {"n": n} - if before: - data["before"] = before - - response = await self._send_packet("log", data) - rdata = response.get("data") - - messages = [Message.from_dict(d) for d in rdata.get("log")] - before = rdata.get("before", None) - return messages, before - - async def nick(self, name): - """ - session_id, user_id, from_nick, to_nick = await nick(name) - - From api.euphoria.io: - The nick command sets the name you present to the room. This name - applies to all messages sent during this session, until the nick - command is called again. - - nick-reply confirms the nick command. It returns the session’s former - and new names (the server may modify the requested nick). - """ - - data = {"name": name} - - response = await self._send_packet("nick", data) - rdata = response.get("data") - - session_id = rdata.get("session_id") - user_id = rdata.get("id") - from_nick = rdata.get("from") - to_nick = rdata.get("to") - - # update self.session - self.session.nick = to_nick - - return session_id, user_id, from_nick, to_nick - - async def pm_initiate(self, user_id): - """ - pm_id, to_nick = await pm_initiate(user_id) - - From api.euphoria.io: - The pm-initiate command constructs a virtual room for private messaging - between the client and the given UserID. - - The pm-initiate-reply provides the PMID for the requested private - messaging room. - """ - - data = {"user_id": user_id} - - response = await self._send_packet("pm-initiate", data) - rdata = response.get("data") - - pm_id = rdata.get("pm_id") - to_nick = rdata.get("to_nick") - return pm_id, to_nick - - async def send(self, content, parent=None): - """ - message = await send(content, parent=None) - - From api.euphoria.io: - The send command sends a message to a room. The session must be - successfully joined with the room. This message will be broadcast to - all sessions joined with the room. - - If the room is private, then the message content will be encrypted - before it is stored and broadcast to the rest of the room. - - The caller of this command will not receive the corresponding - send-event, but will receive the same information in the send-reply. - """ - - data = {"content": content} - if parent: - data["parent"] = parent - - response = await self._send_packet("send", data) - rdata = response.get("data") - - message = Message.from_dict(rdata) - return message - + data = await self._send_while_connected( + "send", + content=content + ) + + return Message.from_dict(data) + async def who(self): - """ - sessions = await who() - - From api.euphoria.io: - The who command requests a list of sessions currently joined in the - room. - - The who-reply packet lists the sessions currently joined in the room. - """ - - response = await self._send_packet("who") - rdata = response.get("data") - - sessions = [Session.from_dict(d) for d in rdata.get("listing")] - - # update self.listing - self.listing = Listing() - for session in sessions: - if not session.sid == self.session.sid: - self.listing.add(session) - - return sessions - - # CATEGORY: ACCOUNT COMMANDS - # NYI, and probably never will - - # CATEGORY: ROOM HOST COMMANDS - # NYI, and probably never will - - # CATEGORY: STAFF COMMANDS - # NYI, and probably never will - - - - # All the private functions for dealing with stuff - - def _add_callbacks(self): - """ - _add_callbacks() - - Adds the functions that handle server events to the callbacks for that - event. - """ - - self._callbacks.add("bounce-event", self._handle_bounce) - self._callbacks.add("disconnect-event", self._handle_disconnect) - self._callbacks.add("hello-event", self._handle_hello) - self._callbacks.add("join-event", self._handle_join) - self._callbacks.add("login-event", self._handle_login) - self._callbacks.add("logout-event", self._handle_logout) - self._callbacks.add("network-event", self._handle_network) - self._callbacks.add("nick-event", self._handle_nick) - self._callbacks.add("edit-message-event", self._handle_edit_message) - self._callbacks.add("part-event", self._handle_part) - self._callbacks.add("ping-event", self._handle_ping) - self._callbacks.add("pm-initiate-event", self._handle_pm_initiate) - self._callbacks.add("send-event", self._handle_send) - self._callbacks.add("snapshot-event", self._handle_snapshot) - - async def _send_packet(self, *args, **kwargs): - """ - reply_packet = await _send_packet(*args, **kwargs) - - Like self._conn.send, but checks for an error on the packet and raises - the corresponding exception. - """ - - response = await self._conn.send(*args, **kwargs) - self._check_for_errors(response) - - return response - - async def _handle_packet(self, packet): - """ - await _handle_packet(packet) - - Call the correct callbacks to deal with packet. - - This function catches CancelledErrors and instead displays an info so - the console doesn't show stack traces when a bot loses connection. - """ - - self._check_for_errors(packet) - - ptype = packet.get("type") - try: - await self._callbacks.call(ptype, packet) - except asyncio.CancelledError as e: - logger.info(f"&{self.roomname}: Callback of type {ptype!r} cancelled.") - #raise # not necessary? - - def _check_for_errors(self, packet): - """ - _check_for_errors(packet) - - Checks for an error on the packet and raises the corresponding - exception. - """ - - if packet.get("throttled", False): - logger.warn(f"&{self.roomname}: Throttled for reason: {packet.get('throttled_reason', 'no reason')!r}") - - if "error" in packet: - raise ResponseError(packet.get("error")) - - async def _handle_bounce(self, packet): - """ - From api.euphoria.io: - A bounce-event indicates that access to a room is denied. - """ - - data = packet.get("data") - - await self.controller.on_bounce( - reason=data.get("reason", None), - auth_options=data.get("auth_options", None), - agent_id=data.get("agent_id", None), - ip=data.get("ip", None) - ) - - async def _handle_disconnect(self, packet): - """ - From api.euphoria.io: - A disconnect-event indicates that the session is being closed. The - client will subsequently be disconnected. - - If the disconnect reason is “authentication changed”, the client should - immediately reconnect. - """ - - data = packet.get("data") - - await self.controller.on_disconnect(data.get("reason")) - - async def _handle_hello(self, packet): - """ - From api.euphoria.io: - A hello-event is sent by the server to the client when a session is - started. It includes information about the client’s authentication and - associated identity. - """ - - data = packet.get("data") - self.session = Session.from_dict(data.get("session")) - self.room_is_private = data.get("room_is_private") - self.version = data.get("version") - self.account = data.get("account", None) - self.account_has_access = data.get("account_has_access", None) - self.account_email_verified = data.get("account_email_verified", None) - - await self.controller.on_hello( - data.get("id"), - self.session, - self.room_is_private, - self.version, - account=self.account, - account_has_access=self.account_has_access, - account_email_verified=self.account_email_verified - ) - - async def _handle_join(self, packet): - """ - From api.euphoria.io: - A join-event indicates a session just joined the room. - """ - - data = packet.get("data") - session = Session.from_dict(data) - - # update self.listing - self.listing.add(session) - - await self.controller.on_join(session) - - async def _handle_login(self, packet): - """ - From api.euphoria.io: - The login-event packet is sent to all sessions of an agent when that - agent is logged in (except for the session that issued the login - command). - """ - - data = packet.get("data") - - await self.controller.on_login(data.get("account_id")) - - async def _handle_logout(self, packet): - """ - From api.euphoria.io: - The logout-event packet is sent to all sessions of an agent when that - agent is logged out (except for the session that issued the logout - command). - """ - - await self.controller.on_logout() - - async def _handle_network(self, packet): - """ - From api.euphoria.io: - A network-event indicates some server-side event that impacts the - presence of sessions in a room. - - If the network event type is partition, then this should be treated as - a part-event for all sessions connected to the same server id/era - combo. - """ - - data = packet.get("data") - server_id = data.get("server_id") - server_era = data.get("server_era") - - # update self.listing - self.listing.remove_combo(server_id, server_era) - - await self.controller.on_network(server_id, server_era) - - async def _handle_nick(self, packet): - """ - From api.euphoria.io: - nick-event announces a nick change by another session in the room. - """ - - data = packet.get("data") - session_id = data.get("session_id") - to_nick = data.get("to") - - # update self.listing - session = self.listing.by_sid(session_id) - if session: - session.nick = to_nick - - await self.controller.on_nick( - session_id, - data.get("id"), - data.get("from"), - to_nick - ) - - async def _handle_edit_message(self, packet): - """ - From api.euphoria.io: - An edit-message-event indicates that a message in the room has been - modified or deleted. If the client offers a user interface and the - indicated message is currently displayed, it should update its display - accordingly. - - The event packet includes a snapshot of the message post-edit. - """ - - data = packet.get("data") - message = Message.from_dict(data) - - await self.controller.on_edit_message( - data.get("edit_id"), - message - ) - - async def _handle_part(self, packet): - """ - From api.euphoria.io: - A part-event indicates a session just disconnected from the room. - """ - - data = packet.get("data") - session = Session.from_dict(data) - - # update self.listing - self.listing.remove(session.session_id) - - await self.controller.on_part(session) - - async def _handle_ping(self, packet): - """ - From api.euphoria.io: - A ping-event represents a server-to-client ping. The client should send - back a ping-reply with the same value for the time field as soon as - possible (or risk disconnection). - """ - - data = packet.get("data") - - await self.controller.on_ping( - data.get("time"), - data.get("next") - ) - - async def _handle_pm_initiate(self, packet): - """ - From api.euphoria.io: - The pm-initiate-event informs the client that another user wants to - chat with them privately. - """ - - data = packet.get("data") - - await self.controller.on_pm_initiate( - data.get("from"), - data.get("from_nick"), - data.get("from_room"), - data.get("pm_id") - ) - - async def _handle_send(self, packet): - """ - From api.euphoria.io: - A send-event indicates a message received by the room from another - session. - """ - - data = packet.get("data") - message = Message.from_dict(data) - - await self.controller.on_send(message) - - async def _handle_snapshot(self, packet): - """ - From api.euphoria.io: - A snapshot-event indicates that a session has successfully joined a - room. It also offers a snapshot of the room’s state and recent history. - """ - - data = packet.get("data") - - sessions = [Session.from_dict(d) for d in data.get("listing")] - messages = [Message.from_dict(d) for d in data.get("log")] - - # update self.listing - for session in sessions: - self.listing.add(session) - - self.session.nick = data.get("nick", None) - - self.pm_with_nick = data.get("pm_with_nick", None), - self.pm_with_user_id = data.get("pm_with_user_id", None) - - await self.controller.on_connected() - - await self.controller.on_snapshot( - data.get("identity"), - data.get("session_id"), - self.version, - sessions, # listing - messages, # log - nick=self.session.nick, - pm_with_nick=self.pm_with_nick, - pm_with_user_id=self.pm_with_user_id - ) + pass + +# COMMUNICATION WITH CONNECTION + + async def _receive_packet(self, ptype, data, error, throttled): + pass # TODO + + async def _disconnected(self): + pass # TODO + +# SOME USEFUL PUBLIC METHODS + + @staticmethod + def format_room_url(roomname, private=False, human=False): + if private: + roomname = f"pm:{roomname}" + + url = f"wss://euphoria.io/room/{roomname}/ws" + + if human: + url = f"{url}?h=1" + + return url + + async def connected(self): + pass + +# REST OF THE IMPLEMENTATION + + async def _run(self): + pass + + async def _send_while_connected(*args, **kwargs): + while True: + try: + await self.connected() + if not self._status != Room._CONNECTED: continue # TODO: Figure out a good solution + return await self._connection.send(*args, **kwargs) + except RoomDisconnected: + pass # Just try again + + +class Inhabitant: + """ + TODO + """ + +# ROOM EVENTS +# These functions are called by the room when something happens. +# They're launched via asyncio.create_task(), so they don't block execution of the room. +# Just overwrite the events you need (make sure to keep the arguments the same though). + + async def disconnected(self, room): + pass + + async def connected(self, room, log): + pass + + async def join(self, room, session): + pass + + async def part(self, room, session): + pass + + async def nick(self, room, sid, uid, from_nick, to_nick): + pass + + async def send(self, room, message): + pass + + async def pm(self, room, from_uid, from_nick, from_room, pm_id): + pass From 1f5fc58e06e351df5f15db00b128bd587e199ee8 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 25 Jul 2018 20:54:44 +0000 Subject: [PATCH 028/145] Continue rewrite --- yaboli/__init__.py | 6 +- yaboli/connection.py | 21 +++-- yaboli/exceptions.py | 9 +++ yaboli/room.py | 181 +++++++++++++++++++++++++++++++++++++++---- yaboli/utils.py | 90 +++++++++++---------- 5 files changed, 238 insertions(+), 69 deletions(-) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 681e168..97b805a 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -13,9 +13,13 @@ logging.getLogger(__name__).setLevel(logging.DEBUG) from .cookiejar import * from .connection import * from .exceptions import * +from .room import * +from utils import * __all__ = ( connection.__all__ + cookiejar.__all__ + - exceptions.__all__ + exceptions.__all__ + + room.__all__ + + utils.__all__ ) diff --git a/yaboli/connection.py b/yaboli/connection.py index 94cfc57..679425c 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -4,7 +4,7 @@ import logging import socket import websockets -from .exceptions import ConnectionClosed +from .exceptions import * logger = logging.getLogger(__name__) @@ -33,7 +33,7 @@ class Connection: async def send(self, ptype, data=None, await_response=True): if not self._ws: - raise exceptions.ConnectionClosed + raise ConnectionClosed #raise asyncio.CancelledError pid = str(self._new_pid()) @@ -63,9 +63,16 @@ class Connection: """ self._stopped = True - if self._ws: - await self._ws.close() # _run() does the cleaning up now. + await self.reconnect() # _run() does the cleaning up now. await self._runtask + + async def reconnect(self): + """ + Reconnect to the url. + """ + + if self._ws: + await self._ws.close() async def _connect(self, tries): """ @@ -116,6 +123,8 @@ class Connection: - make sure the ping task has finished """ + asyncio.create_task(self.disconnect_callback()) + # stop ping task if self._pingtask: self._pingtask.cancel() @@ -131,7 +140,7 @@ class Connection: # clean up pending response futures for _, future in self._pending_responses.items(): logger.debug(f"Cancelling future with ConnectionClosed: {future}") - future.set_exception(exceptions.ConnectionClosed("No server response")) + future.set_exception(ConnectionClosed("No server response")) self._pending_responses = {} async def _run(self): @@ -164,7 +173,7 @@ class Connection: await asyncio.sleep(self.ping_delay) except asyncio.TimeoutError: logger.warning("Ping timed out.") - await self._ws.close() # trigger a reconnect attempt + await self.reconnect() except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): pass diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py index 4aaa8e0..f9cce45 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -2,3 +2,12 @@ __all__ = ["ConnectionClosed"] class ConnectionClosed(Exception): pass + +class RoomException(Exception): + pass + +class AuthenticationRequired(RoomException): + pass + +class RoomClosed(RoomException): + pass diff --git a/yaboli/room.py b/yaboli/room.py index cfc6ad7..0e78eaa 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,3 +1,8 @@ +from .connection import * +from .exceptions import * +from .utils import * + + __all__ == ["Room", "Inhabitant"] @@ -6,18 +11,24 @@ class Room: TODO """ + CONNECTED = 1 + DISCONNECTED = 2 + EXITED = 3 + def __init__(self, roomname, inhabitant, password=None, human=False, cookiejar=None): # TODO: Connect to room etc. # TODO: Deal with room/connection states of: # disconnected connecting, fast-forwarding, connected - self._inhabitant = inhabitant - # Room info (all fields readonly!) self.roomname = roomname + self.password = password + self.human = human + self.session = None self.account = None - self.listing = None # TODO + self.listing = Listing() + self.account_has_access = None self.account_email_verified = None self.room_is_private = None @@ -25,10 +36,20 @@ class Room: self.pm_with_nick = None self.pm_with_user_id = None - #asyncio.create_task(self._run()) + self._inhabitant = inhabitant + self._status = Room.DISCONNECTED + + # TODO: Allow for all parameters of Connection() to be specified in Room(). + self._connection = Connection( + self.format_room_url(self.roomname, human=self.human), + self._receive_packet, + self._disconnected, + cookiejar + ) async def exit(self): - pass + self._status = Room.EXITED + await self._connection.stop() # ROOM COMMANDS # These always return a response from the server. @@ -72,11 +93,137 @@ class Room: # COMMUNICATION WITH CONNECTION - async def _receive_packet(self, ptype, data, error, throttled): - pass # TODO - async def _disconnected(self): - pass # TODO + # While disconnected, keep the last known session info, listing etc. + # All of this is instead reset when the hello/snapshot events are received. + self.status = Room.DISCONNECTED + self._connected_future = asyncio.Future() + + await self._inhabitant.disconnected(self) + + async def _receive_packet(self, ptype, data, error, throttled): + # Ignoring errors and throttling for now + functions = { + "bounce-event": self._event_bounce, + #"disconnect-event": self._event_disconnect, # Not important, can ignore + "hello-event": self._event_hello, + "join-event": self._event_join, + #"login-event": self._event_login, + #"logout-event": self._event_logout, + "network-event": self._event_network, + "nick-event": self._event_nick, + #"edit-message-event": self._event_edit_message, + "part-event": self._event_part, + "ping-event": self._event_ping, + "pm-initiate-event": self._event_pm_initiate, + "send-event": self._event_send, + "snapshot-event": self._event_snapshot, + } + + function = functions.get(ptype) + if function: + await function(data) + + async def _event_bounce(self, data): + if self.password is not None: + try: + response = await self._connection.send("auth", type=passcode, passcode=self.password) + rdata = response.get("data") + success = rdata.get("success") + if not success: + reason = rdata.get("reason") + raise AuthenticationRequired(f"Could not join &{self.roomname}: {reason}") + except ConnectionClosed: + pass + else: + raise AuthenticationRequired(f"&{self.roomname} is password locked but no password was given") + + async def _event_hello(self, data): + self.session = Session.from_dict(data.get("session")) + self.room_is_private = = data.get("room_is_private") + self.version = data.get("version") + self.account = data.get("account", None) + self.account_has_access = data.get("account_has_access", None) + self.account_email_verified = data.get("account_email_verified", None) + + async def _event_join(self, data): + session = Session.from_dict(data) + self.listing.add(session) + await self._inhabitant.join(self, session) + + async def _event_network(self, data): + server_id = data.get("server_id") + server_era = data.get("server_era") + + sessions = self.listing.remove_combo(server_id, server_era) + for session in sessions: + await self._inhabitant.part(self, session) + + async def _event_nick(self, data): + sid = data.get("session_id") + uid = data.get("user_id") + from_nick = data.get("from") + to_nick = data.get("to") + + session = self.listing.by_sid(sid) + if session: + session.nick = to_nick + + await self._inhabitant.nick(self, sid, uid, from_nick, to_nick) + + async def _event_part(self, data): + session = Session.from_dict(data) + self.listing.remove(session.sid) + await self._inhabitant.part(self, session) + + async def _event_ping(self, data): + try: + self._connection.send() + except exceptions.ConnectionClosed: + pass + + async def _event_pm_initiate(self, data): + from_uid = data.get("from") + from_nick = data.get("from_nick") + from_room = data.get("from_room") + pm_id = data.get("pm_id") + + await self._inhabitant.pm(self, from_uid, from_nick, from_room, pm_id) + + async def _event_send(self, data): + pass # TODO X + # TODO: Figure out a way to bring fast-forwarding into this + + async def _event_snapshot(self, data): + # update listing + self.listing = Listing() + sessions = [Session.from_dict(d) for d in data.get("listing")] + for session in sessions: + self.listing.add(session) + + # update (and possibly set) nick + new_nick = data.get("nick", None) + if self.session: + prev_nick = self.session.nick + if new_nick != prev_nick: + self.nick(prev_nick) + self.session.nick = new_nick + + # update more room info + self.pm_with_nick = data.get("pm_with_nick", None), + self.pm_with_user_id = data.get("pm_with_user_id", None) + + # Now, we're finally connected again! + self.status = Room.CONNECTED + if not self._connected_future.done(): # should always be True, I think + self._connected_future.set_result(None) + + # Let's let the inhabitant know. + log = [Message.from_dict(m) for m in data.get("log")] + await self._inhabitant.connected(self, log) + + # TODO: Figure out a way to bring fast-forwarding into this + # Should probably happen where this comment is # SOME USEFUL PUBLIC METHODS @@ -93,21 +240,20 @@ class Room: return url async def connected(self): - pass + await self._connected_future # REST OF THE IMPLEMENTATION - async def _run(self): - pass - async def _send_while_connected(*args, **kwargs): while True: + if self._status == Room.CLOSED: + raise RoomClosed() + try: await self.connected() - if not self._status != Room._CONNECTED: continue # TODO: Figure out a good solution return await self._connection.send(*args, **kwargs) - except RoomDisconnected: - pass # Just try again + except ConnectionClosed: + pass # just try again class Inhabitant: @@ -138,5 +284,8 @@ class Inhabitant: async def send(self, room, message): pass + async def fast_forward(self, room, message): + pass + async def pm(self, room, from_uid, from_nick, from_room, pm_id): pass diff --git a/yaboli/utils.py b/yaboli/utils.py index 0f81076..d6ded92 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,55 +1,54 @@ import asyncio -import logging +#import logging import time -logger = logging.getLogger(__name__) +#logger = logging.getLogger(__name__) __all__ = [ - "run_controller", "run_bot", + #"run_controller", "run_bot", "mention", "mention_reduced", "similar", "format_time", "format_time_delta", "Session", "Listing", "Message", "Log", - "ResponseError" ] -def run_controller(controller, room): - """ - Helper function to run a singular controller. - """ - - async def run(): - task, reason = await controller.connect(room) - if task: - await task - else: - logger.warn(f"Could not connect to &{room}: {reason!r}") - - asyncio.get_event_loop().run_until_complete(run()) - -def run_bot(bot_class, room, *args, **kwargs): - """ - Helper function to run a bot. To run Multibots, use the MultibotKeeper. - This restarts the bot when it is explicitly restarted through Bot.restart(). - """ - - async def run(): - while True: - logger.info(f"Creating new instance and connecting to &{room}") - bot = bot_class(*args, **kwargs) - task, reason = await bot.connect(room) - if task: - await task - else: - logger.warn(f"Could not connect to &{room}: {reason!r}") - - if bot.restarting: - logger.info(f"Restarting in &{room}") - else: - break - - asyncio.get_event_loop().run_until_complete(run()) +#def run_controller(controller, room): +# """ +# Helper function to run a singular controller. +# """ +# +# async def run(): +# task, reason = await controller.connect(room) +# if task: +# await task +# else: +# logger.warn(f"Could not connect to &{room}: {reason!r}") +# +# asyncio.get_event_loop().run_until_complete(run()) +# +#def run_bot(bot_class, room, *args, **kwargs): +# """ +# Helper function to run a bot. To run Multibots, use the MultibotKeeper. +# This restarts the bot when it is explicitly restarted through Bot.restart(). +# """ +# +# async def run(): +# while True: +# logger.info(f"Creating new instance and connecting to &{room}") +# bot = bot_class(*args, **kwargs) +# task, reason = await bot.connect(room) +# if task: +# await task +# else: +# logger.warn(f"Could not connect to &{room}: {reason!r}") +# +# if bot.restarting: +# logger.info(f"Restarting in &{room}") +# else: +# break +# +# asyncio.get_event_loop().run_until_complete(run()) def mention(nick): return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace()) @@ -157,8 +156,13 @@ class Listing: self._sessions.pop(session_id) def remove_combo(self, server_id, server_era): + removed = [ses for ses in self._sessions.items() + if ses.server_id == server_id and ses.server_era == server_era] + self._sessions = {i: ses for i, ses in self._sessions.items() if ses.server_id != server_id and ses.server_era != server_era} + + return removed def by_sid(self, session_id): return self._sessions.get(session_id); @@ -226,9 +230,3 @@ class Message(): d.get("deleted", None), d.get("truncated", None) ) - -class Log: - pass # TODO - -class ResponseError(Exception): - pass From a971b7e0642d2235de36a840426c89a8dca6dbca Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 25 Jul 2018 20:56:03 +0000 Subject: [PATCH 029/145] Clean up --- yaboli/callbacks.py | 65 ------------------------------------------ yaboli/connection.py | 2 +- yaboli/utils.py | 68 ++++++++++++++++++++++---------------------- 3 files changed, 35 insertions(+), 100 deletions(-) delete mode 100644 yaboli/callbacks.py diff --git a/yaboli/callbacks.py b/yaboli/callbacks.py deleted file mode 100644 index 9f88a49..0000000 --- a/yaboli/callbacks.py +++ /dev/null @@ -1,65 +0,0 @@ -import asyncio - -__all__ = ["Callbacks"] - - - -class Callbacks(): - """ - Manage callbacks asynchronously - """ - - def __init__(self): - self._callbacks = {} - - def add(self, event, callback): - """ - add(event, callback) -> None - - Add a function to be called on event. - """ - - if not event in self._callbacks: - self._callbacks[event] = [] - self._callbacks[event].append(callback) - - def remove(self, event): - """ - remove(event) -> None - - Remove all callbacks attached to that event. - """ - - if event in self._callbacks: - del self._callbacks[event] - - async def call(self, event, *args, **kwargs): - """ - await call(event) -> None - - Call all callbacks subscribed to the event with *args and **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): - """ - exists(event) -> bool - - Are any functions subscribed to this event? - """ - - return event in self._callbacks - - def list(self): - """ - list() -> callbacks - - Returns a list of current callbacks - """ - - return list(self._callbacks.keys()) diff --git a/yaboli/connection.py b/yaboli/connection.py index 679425c..3be4a0c 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -65,7 +65,7 @@ class Connection: self._stopped = True await self.reconnect() # _run() does the cleaning up now. await self._runtask - + async def reconnect(self): """ Reconnect to the url. diff --git a/yaboli/utils.py b/yaboli/utils.py index d6ded92..1fb438b 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -17,14 +17,14 @@ __all__ = [ # """ # Helper function to run a singular controller. # """ -# +# # async def run(): # task, reason = await controller.connect(room) # if task: # await task # else: # logger.warn(f"Could not connect to &{room}: {reason!r}") -# +# # asyncio.get_event_loop().run_until_complete(run()) # #def run_bot(bot_class, room, *args, **kwargs): @@ -32,7 +32,7 @@ __all__ = [ # Helper function to run a bot. To run Multibots, use the MultibotKeeper. # This restarts the bot when it is explicitly restarted through Bot.restart(). # """ -# +# # async def run(): # while True: # logger.info(f"Creating new instance and connecting to &{room}") @@ -42,12 +42,12 @@ __all__ = [ # await task # else: # logger.warn(f"Could not connect to &{room}: {reason!r}") -# +# # if bot.restarting: # logger.info(f"Restarting in &{room}") # else: # break -# +# # asyncio.get_event_loop().run_until_complete(run()) def mention(nick): @@ -70,28 +70,28 @@ def format_time_delta(delta): 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%hour - + if delta >= minute: result += f"{delta//minute}m " delta = delta%minute - + result += f"{delta}s" - + return result class Session: @@ -106,23 +106,23 @@ class Session: self.is_manager = is_manager self.client_address = client_address self.real_address = real_address - + @property def uid(self): return self.user_id - + @uid.setter def uid(self, new_uid): self.user_id = new_uid - + @property def sid(self): return self.session_id - + @sid.setter def sid(self, new_sid): self.session_id = new_sid - + @classmethod def from_dict(cls, d): return cls( @@ -136,7 +136,7 @@ class Session: d.get("client_address", None), d.get("real_address", None) ) - + @property def client_type(self): # account, agent or bot @@ -145,16 +145,16 @@ class Session: 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 remove_combo(self, server_id, server_era): removed = [ses for ses in self._sessions.items() if ses.server_id == server_id and ses.server_era == server_era] @@ -163,34 +163,34 @@ class Listing: if ses.server_id != server_id and ses.server_era != server_era} return removed - + def by_sid(self, session_id): return self._sessions.get(session_id); - + def by_uid(self, user_id): return [ses for ses in self._sessions if ses.user_id == user_id] - + def get(self, types=["agent", "account", "bot"], lurker=None): sessions = [] for uid, ses in self._sessions.items(): if ses.client_type not in types: continue - + is_lurker = not ses.nick # "" or None if lurker is None or lurker == is_lurker: sessions.append(ses) - + return sessions - + #def get_people(self): #return self.get(types=["agent", "account"]) - + #def get_accounts(self): #return self.get(types=["account"]) - + #def get_agents(self): #return self.get(types=["agent"]) - + #def get_bots(self): #return self.get(types=["bot"]) @@ -207,15 +207,15 @@ class Message(): self.edited = edited self.deleted = deleted self.truncated = truncated - + @property def mid(self): return self.message_id - + @mid.setter def mid(self, new_mid): self.message_id = new_mid - + @classmethod def from_dict(cls, d): return cls( From 3051b15095fbea732c1a9ff22303e57ebaf0d708 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 26 Jul 2018 14:32:14 +0000 Subject: [PATCH 030/145] Make room work --- yaboli/__init__.py | 2 +- yaboli/connection.py | 32 ++++++------ yaboli/room.py | 118 +++++++++++++++++++++++++++++++++---------- yaboli/utils.py | 2 +- 4 files changed, 108 insertions(+), 46 deletions(-) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 97b805a..d258678 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -14,7 +14,7 @@ from .cookiejar import * from .connection import * from .exceptions import * from .room import * -from utils import * +from .utils import * __all__ = ( connection.__all__ + diff --git a/yaboli/connection.py b/yaboli/connection.py index 3be4a0c..d56a527 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -28,7 +28,7 @@ class Connection: self._stopped = False self._pingtask = None - self._runtask = asyncio.create_task(self._run()) + self._runtask = asyncio.ensure_future(self._run()) # ... aaand the connection is started. async def send(self, ptype, data=None, await_response=True): @@ -90,8 +90,8 @@ class Connection: delay = 1 # seconds while True: try: - if self._cookiejar: - cookies = [("Cookie", cookie) for cookie in self._cookiejar.sniff()] + if self.cookiejar: + cookies = [("Cookie", cookie) for cookie in self.cookiejar.sniff()] self._ws = await websockets.connect(self.url, max_size=None, extra_headers=cookies) else: self._ws = await websockets.connect(self.url, max_size=None) @@ -106,11 +106,11 @@ class Connection: await asyncio.sleep(delay) delay *= 2 else: - if self._cookiejar: + if self.cookiejar: for set_cookie in self._ws.response_headers.get_all("Set-Cookie"): - self._cookiejar.bake(set_cookie) + self.cookiejar.bake(set_cookie) - self._pingtask = asyncio.create_task(self._ping()) + self._pingtask = asyncio.ensure_future(self._ping()) return True @@ -123,7 +123,7 @@ class Connection: - make sure the ping task has finished """ - asyncio.create_task(self.disconnect_callback()) + asyncio.ensure_future(self.disconnect_callback()) # stop ping task if self._pingtask: @@ -149,7 +149,7 @@ class Connection: """ while not self._stopped: - self._connect(self.reconnect_attempts) + await self._connect(self.reconnect_attempts) try: while True: @@ -183,13 +183,7 @@ class Connection: async def _handle_next_message(self): response = await self._ws.recv() - packet = json.loads(text) - - # Deal with pending responses - pid = packet.get("id", None) - future = self._pending_responses.pop(pid, None) - if future: - future.set_result(packet) + packet = json.loads(response) ptype = packet.get("type") data = packet.get("data", None) @@ -199,8 +193,14 @@ class Connection: else: throttled = None + # Deal with pending responses + pid = packet.get("id", None) + future = self._pending_responses.pop(pid, None) + if future: + future.set_result((ptype, data, error, throttled)) + # Pass packet onto room - asyncio.create_task(self.packet_callback(ptype, data, error, throttled)) + asyncio.ensure_future(self.packet_callback(ptype, data, error, throttled)) def _wait_for_response(self, pid): future = asyncio.Future() diff --git a/yaboli/room.py b/yaboli/room.py index 0e78eaa..efafd49 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,9 +1,14 @@ +import asyncio +import logging + +logger = logging.getLogger(__name__) + from .connection import * from .exceptions import * from .utils import * -__all__ == ["Room", "Inhabitant"] +__all__ = ["Room", "Inhabitant"] class Room: @@ -13,7 +18,7 @@ class Room: CONNECTED = 1 DISCONNECTED = 2 - EXITED = 3 + CLOSED = 3 def __init__(self, roomname, inhabitant, password=None, human=False, cookiejar=None): # TODO: Connect to room etc. @@ -38,6 +43,7 @@ class Room: self._inhabitant = inhabitant self._status = Room.DISCONNECTED + self._connected_future = asyncio.Future() # TODO: Allow for all parameters of Connection() to be specified in Room(). self._connection = Connection( @@ -48,7 +54,7 @@ class Room: ) async def exit(self): - self._status = Room.EXITED + self._status = Room.CLOSED await self._connection.stop() # ROOM COMMANDS @@ -57,16 +63,62 @@ class Room: # the command will retry once the bot has reconnected. async def get_message(self, mid): - pass + if self._status == Room.CLOSED: + raise RoomClosed() + + ptype, data, error, throttled = await self._send_while_connected( + "get-message", + id=mid + ) + + return Message.from_dict(data) async def log(self, n, before_mid=None): - pass + if self._status == Room.CLOSED: + raise RoomClosed() + + if before_mid: + ptype, data, error, throttled = await self._send_while_connected( + "log", + n=n, + before=before_mid + ) + else: + ptype, data, error, throttled = await self._send_while_connected( + "log", + n=n + ) + + return [Message.from_dict(d) for d in data.get("log")] async def nick(self, nick): - pass + if self._status == Room.CLOSED: + raise RoomClosed() + + ptype, data, error, throttled = await self._send_while_connected( + "nick", + name=nick + ) + + sid = data.get("session_id") + uid = data.get("id") + from_nick = data.get("from") + to_nick = data.get("to") + return sid, uid, from_nick, to_nick async def pm(self, uid): - pass + if self._status == Room.CLOSED: + raise RoomClosed() + + ptype, data, error, throttled = await self._send_while_connected( + "pm-initiate", + user_id=uid + ) + + # Just ignoring non-authenticated errors + pm_id = data.get("pm_id") + to_nick = data.get("to_nick") + return pm_id, to_nick async def send(self, content, parent_mid=None): """ @@ -75,13 +127,13 @@ class Room: """ if parent_mid: - data = await self._send_while_connected( + ptype, data, error, throttled = await self._send_while_connected( "send", content=content, parent=parent_mid ) else: - data = await self._send_while_connected( + ptype, data, error, throttled = await self._send_while_connected( "send", content=content ) @@ -103,6 +155,7 @@ class Room: async def _receive_packet(self, ptype, data, error, throttled): # Ignoring errors and throttling for now + logger.debug(f"Received packet of type: {ptype}") functions = { "bounce-event": self._event_bounce, #"disconnect-event": self._event_disconnect, # Not important, can ignore @@ -127,7 +180,8 @@ class Room: async def _event_bounce(self, data): if self.password is not None: try: - response = await self._connection.send("auth", type=passcode, passcode=self.password) + data = {"type": passcode, "passcode": self.password} + response = await self._connection.send("auth", data=data) rdata = response.get("data") success = rdata.get("success") if not success: @@ -140,7 +194,7 @@ class Room: async def _event_hello(self, data): self.session = Session.from_dict(data.get("session")) - self.room_is_private = = data.get("room_is_private") + 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) @@ -178,8 +232,9 @@ class Room: async def _event_ping(self, data): try: - self._connection.send() - except exceptions.ConnectionClosed: + new_data = {"time": data.get("time")} + await self._connection.send( "ping-reply", data=new_data, await_response=False) + except ConnectionClosed: pass async def _event_pm_initiate(self, data): @@ -191,34 +246,41 @@ class Room: await self._inhabitant.pm(self, from_uid, from_nick, from_room, pm_id) async def _event_send(self, data): - pass # TODO X + message = Message.from_dict(data) + + await self._inhabitant.send(self, message) + # TODO: Figure out a way to bring fast-forwarding into this async def _event_snapshot(self, data): - # update listing + # Update listing self.listing = Listing() sessions = [Session.from_dict(d) for d in data.get("listing")] for session in sessions: self.listing.add(session) - # update (and possibly set) nick - new_nick = data.get("nick", None) - if self.session: - prev_nick = self.session.nick - if new_nick != prev_nick: - self.nick(prev_nick) - self.session.nick = new_nick - - # update more room info + # Update room info self.pm_with_nick = data.get("pm_with_nick", None), self.pm_with_user_id = data.get("pm_with_user_id", None) + # Remember old nick, because we're going to try to get it back + old_nick = self.session.nick if self.session else None + new_nick = data.get("nick", None) + self.session.nick = new_nick + + if old_nick and old_nick != new_nick: + try: + await self._connection.send("nick", data={"name": old_nick}) + except ConnectionClosed: + return # Aww, we've lost connection again + # Now, we're finally connected again! self.status = Room.CONNECTED - if not self._connected_future.done(): # should always be True, I think + if not self._connected_future.done(): # Should never be done already, I think self._connected_future.set_result(None) # Let's let the inhabitant know. + logger.debug("Letting inhabitant know") log = [Message.from_dict(m) for m in data.get("log")] await self._inhabitant.connected(self, log) @@ -244,14 +306,14 @@ class Room: # REST OF THE IMPLEMENTATION - async def _send_while_connected(*args, **kwargs): + async def _send_while_connected(self, *args, **kwargs): while True: if self._status == Room.CLOSED: raise RoomClosed() try: await self.connected() - return await self._connection.send(*args, **kwargs) + return await self._connection.send(*args, data=kwargs) except ConnectionClosed: pass # just try again @@ -263,7 +325,7 @@ class Inhabitant: # ROOM EVENTS # These functions are called by the room when something happens. -# They're launched via asyncio.create_task(), so they don't block execution of the room. +# They're launched via asyncio.ensure_future(), so they don't block execution of the room. # Just overwrite the events you need (make sure to keep the arguments the same though). async def disconnected(self, room): diff --git a/yaboli/utils.py b/yaboli/utils.py index 1fb438b..b67f48c 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -8,7 +8,7 @@ __all__ = [ "mention", "mention_reduced", "similar", "format_time", "format_time_delta", "Session", "Listing", - "Message", "Log", + "Message", ] From 3eade77cf1055dffd755fb51817b4f26a33f7565 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 26 Jul 2018 14:36:58 +0000 Subject: [PATCH 031/145] Clean up --- yaboli/room.py | 4 +--- yaboli/utils.py | 44 +------------------------------------------- 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/yaboli/room.py b/yaboli/room.py index efafd49..947e0ad 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,13 +1,12 @@ import asyncio import logging -logger = logging.getLogger(__name__) - from .connection import * from .exceptions import * from .utils import * +logger = logging.getLogger(__name__) __all__ = ["Room", "Inhabitant"] @@ -155,7 +154,6 @@ class Room: async def _receive_packet(self, ptype, data, error, throttled): # Ignoring errors and throttling for now - logger.debug(f"Received packet of type: {ptype}") functions = { "bounce-event": self._event_bounce, #"disconnect-event": self._event_disconnect, # Not important, can ignore diff --git a/yaboli/utils.py b/yaboli/utils.py index b67f48c..3d5df55 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,55 +1,13 @@ import asyncio -#import logging import time -#logger = logging.getLogger(__name__) __all__ = [ - #"run_controller", "run_bot", "mention", "mention_reduced", "similar", "format_time", "format_time_delta", - "Session", "Listing", - "Message", + "Session", "Listing", "Message", ] - -#def run_controller(controller, room): -# """ -# Helper function to run a singular controller. -# """ -# -# async def run(): -# task, reason = await controller.connect(room) -# if task: -# await task -# else: -# logger.warn(f"Could not connect to &{room}: {reason!r}") -# -# asyncio.get_event_loop().run_until_complete(run()) -# -#def run_bot(bot_class, room, *args, **kwargs): -# """ -# Helper function to run a bot. To run Multibots, use the MultibotKeeper. -# This restarts the bot when it is explicitly restarted through Bot.restart(). -# """ -# -# async def run(): -# while True: -# logger.info(f"Creating new instance and connecting to &{room}") -# bot = bot_class(*args, **kwargs) -# task, reason = await bot.connect(room) -# if task: -# await task -# else: -# logger.warn(f"Could not connect to &{room}: {reason!r}") -# -# if bot.restarting: -# logger.info(f"Restarting in &{room}") -# else: -# break -# -# asyncio.get_event_loop().run_until_complete(run()) - def mention(nick): return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace()) From 1bb38fc836a47f1a3d7d87fb724afeca3a964852 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 26 Jul 2018 19:54:44 +0000 Subject: [PATCH 032/145] Make bot library functional --- ExampleBot.py | 21 +++ setup.py | 10 -- yaboli/__init__.py | 2 + yaboli/bot.py | 384 +++++++++++++------------------------------ yaboli/connection.py | 1 + yaboli/controller.py | 213 ------------------------ yaboli/cookiejar.py | 10 +- yaboli/room.py | 20 ++- yaboli/utils.py | 4 + 9 files changed, 167 insertions(+), 498 deletions(-) create mode 100644 ExampleBot.py delete mode 100644 setup.py delete mode 100644 yaboli/controller.py diff --git a/ExampleBot.py b/ExampleBot.py new file mode 100644 index 0000000..0a60d0b --- /dev/null +++ b/ExampleBot.py @@ -0,0 +1,21 @@ +import asyncio +import yaboli + +class ExampleBot(yaboli.Bot): + async def send(self, room, message): + ping = "ExamplePong!" + short_help = "Example bot for the yaboli bot library" + long_help = "I'm an example bot for the yaboli bot library, which can be found at https://github.com/Garmelon/yaboli" + + await self.botrulez_ping_general(room, message, ping_text=ping) + await self.botrulez_ping_specific(room, message, ping_text=ping) + await self.botrulez_help_general(room, message, help_text=short_help) + await self.botrulez_help_specific(room, message, help_text=long_help) + await self.botrulez_uptime(room, message) + await self.botrulez_kill(room, message) + await self.botrulez_restart(room, message) + + forward = send # should work without modifications for most bots + +bot = ExampleBot("ExampleBot", "examplebot_cookies", rooms=["test", "welcome"]) +asyncio.get_event_loop().run_forever() diff --git a/setup.py b/setup.py deleted file mode 100644 index c46f97b..0000000 --- a/setup.py +++ /dev/null @@ -1,10 +0,0 @@ -from setuptools import setup - -setup(name='yaboli', - version='1.0', - description='Yet Another BOt LIbrary for euphoria.io', - author='Garmelon', - url='https://github.com/Garmelon/yaboli', - packages=['yaboli'], - install_requires=['websockets'] -) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index d258678..7f6b6ba 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -10,6 +10,7 @@ logging.getLogger("asyncio").setLevel(logging.DEBUG) logging.getLogger(__name__).setLevel(logging.DEBUG) # ----------- END DEV SECTION ----------- +from .bot import * from .cookiejar import * from .connection import * from .exceptions import * @@ -17,6 +18,7 @@ from .room import * from .utils import * __all__ = ( + bot.__all__ + connection.__all__ + cookiejar.__all__ + exceptions.__all__ + diff --git a/yaboli/bot.py b/yaboli/bot.py index 0672e70..ddac48e 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,178 +1,122 @@ -import asyncio -from collections import namedtuple import logging import re import time -from .callbacks import * -from .controller import * + +from .cookiejar import * +from .room import * from .utils import * + logger = logging.getLogger(__name__) __all__ = ["Bot"] +# Some command stuff -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]*)" - - ParsedMessage = namedtuple("ParsedMessage", ["command", "argstr"]) - TopicHelp = namedtuple("TopicHelp", ["text", "visible"]) - - def __init__(self, nick): - super().__init__(nick) - - self.restarting = False # whoever runs the bot can check if a restart is necessary - self.start_time = time.time() - - self._commands = Callbacks() - self._triggers = Callbacks() - self.register_default_commands() - - self._help_topics = {} - self.add_default_help_topics() - - # settings (modify in your bot's __init__) - self.help_general = None # None -> does not respond to general help - self.help_specific = "No help available" - self.killable = True - self.kill_message = "/me *poof*" # how to respond to !kill, whether killable or not - self.restartable = True - self.restart_message = "/me temporary *poof*" # how to respond to !restart, whether restartable or not - self.ping_message = "Pong!" # as specified by the botrulez - - def register_command(self, command, callback, specific=True): - self._commands.add((command, specific), callback) - - def register_trigger(self, regex, callback): - self._triggers.add(re.compile(regex), callback) - - def register_trigger_compiled(self, comp_regex, callback): - self._triggers.add(comp_regex, callback) - - def add_help(self, topic, text, visible=True): - info = self.TopicHelp(text, visible) - self._help_topics[topic] = info - - def get_help(self, topic): - info = self._help_topics.get(topic, None) - if info: - return self.format_help(info.text) - - def format_help(self, helptext): - return helptext.format( - nick=mention(self.nick) - ) - - def list_help_topics(self, max_characters=100): - # Magic happens here to ensure that the resulting lines are always - # max_characters or less characters long. - - lines = [] - curline = "" - wrapper = None - - for topic, info in sorted(self._help_topics.items()): - if not info.visible: - continue - - if wrapper: - curline += "," - lines.append(curline) - curline = wrapper - wrapper = None - - if not curline: - curline = topic - elif len(curline) + len(f", {topic},") <= max_characters: - curline += f", {topic}" - elif len(curline) + len(f", {topic}") <= max_characters: - wrapper = topic +SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)") +GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)") + +def command(commandname, specific=True, noargs=False): + def decorator(func): + async def wrapper(self, room, message, *args, **kwargs): + print(f"New message: {message.content!r}, current name: {room.session!r}") + if specific: + print(f"Trying specific: {message.content!r}") + result = self._parse_command(message.content, specific=room.session.nick) else: - curline += "," - lines.append(curline) - curline = topic - - if wrapper: - curline += "," - lines.append(curline) - lines.append(wrapper) - elif curline: - lines.append(curline) - - return "\n".join(lines) - - async def restart(self): - # After calling this, the bot is stopped, not yet restarted. - self.restarting = True - await self.stop() - - def noargs(func): - async def wrapper(self, message, argstr): - if not argstr: - return await func(self, message) + print(f"Trying general: {message.content!r}") + result = self._parse_command(message.content) + if result is None: return + cmd, argstr = result + if cmd != commandname: return + if noargs: + if argstr: return + return await func(self, room, message, *args, **kwargs) + else: + return await func(self, room, message, args*args, **kwargs) return wrapper - - async def on_send(self, message): - wait = [] - - # get specific command to call (if any) - specific = self.parse_message(message.content, specific=True) - if specific: - wait.append(self._commands.call( - (specific.command, True), - message, specific.argstr - )) - - # get generic command to call (if any) - general = self.parse_message(message.content, specific=False) - if general: - wait.append(self._commands.call( - (general.command, False), - message, general.argstr - )) - - # find triggers to call (if any) - for trigger in self._triggers.list(): - match = trigger.fullmatch(message.content) - if match: - wait.append(self._triggers.call(trigger, message, match)) - - if wait: - await asyncio.wait(wait) - - def parse_message(self, content, specific=True): + return decorator + + +# And now comes the real bot... + +class Bot(Inhabitant): + def __init__(self, nick, cookiefile=None, rooms=["test"]): + self.target_nick = nick + self.rooms = {} + self.cookiejar = CookieJar(cookiefile) + + for roomname in rooms: + self.join_room(roomname) + + # ROOM MANAGEMENT + + def join_room(self, roomname, password=None): + if roomname in self.rooms: + return + + self.rooms[roomname] = Room(self, roomname, self.target_nick, password=password, cookiejar=self.cookiejar) + + async def part_room(self, roomname): + room = self.rooms.pop(roomname, None) + if room: + await room.exit() + + # BOTRULEZ + + @command("ping", specific=False, noargs=True) + async def botrulez_ping_general(self, room, message, ping_text="Pong!"): + await room.send(ping_text, message.mid) + + @command("ping", specific=True, noargs=True) + async def botrulez_ping_specific(self, room, message, ping_text="Pong!"): + await room.send(ping_text, message.mid) + + @command("help", specific=False, noargs=True) + async def botrulez_help_general(self, room, message, help_text="Placeholder help text"): + await room.send(help_text, message.mid) + + @command("help", specific=True, noargs=True) + async def botrulez_help_specific(self, room, message, help_text="Placeholder help text"): + await room.send(help_text, message.mid) + + @command("uptime", specific=True, noargs=True) + async def botrulez_uptime(self, room, message): + now = time.time() + startformat = format_time(room.start_time) + deltaformat = format_time_delta(now - room.start_time) + text = f"/me has been up since {startformat} ({deltaformat})" + await room.send(text, message.mid) + + @command("kill", specific=True, noargs=True) + async def botrulez_kill(self, room, message, kill_text="/me dies"): + await room.send(kill_text, message.mid) + await self.part_room(room.roomname) + + @command("restart", specific=True, noargs=True) + async def botrulez_restart(self, room, message, restart_text="/me restarts"): + await room.send(restart_text, message.mid) + await self.part_room(room.roomname) + self.join_room(room.roomname, password=room.password) + + # COMMAND PARSING + + @staticmethod + def parse_args(text): """ - ParsedMessage = parse_message(content) - - Returns None, not a (None, None) tuple, when message could not be parsed - """ - - if specific: - match = re.fullmatch(self.SPECIFIC_RE, content) - if match and similar(match.group(2), self.nick): - return self.ParsedMessage(match.group(1), match.group(3)) - else: - match = re.fullmatch(self.GENERIC_RE, content) - if match: - return self.ParsedMessage(match.group(1), match.group(2)) - - def parse_args(self, text): - """ - Use single- and double-quotes bash-style to include whitespace in arguments. + Use bash-style single- and double-quotes 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 @@ -192,20 +136,21 @@ class Bot(Controller): 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): + + @staticmethod + def parse_flags(arglist): flags = "" args = [] kwargs = {} - + for arg in arglist: # kwargs (--abc, --foo=bar) if arg[:2] == "--": @@ -222,112 +167,19 @@ class Bot(Controller): # args (normal arguments) else: args.append(arg) - + return flags, args, kwargs - - - - # BOTRULEZ AND YABOLI-SPECIFIC COMMANDS - - def register_default_commands(self): - self.register_command("ping", self.command_ping) - self.register_command("ping", self.command_ping, specific=False) - self.register_command("help", self.command_help) - self.register_command("help", self.command_help_general, specific=False) - self.register_command("uptime", self.command_uptime) - self.register_command("kill", self.command_kill) - self.register_command("restart", self.command_restart) - - def add_default_help_topics(self): - self.add_help("botrulez", ( - "This bot complies with the botrulez at https://github.com/jedevc/botrulez.\n" - "It implements the standard commands, and additionally !kill and !restart.\n\n" - "Standard commands:\n" - " !ping, !ping @{nick} - reply with a short pong message\n" - " !help, !help @{nick} - reply with help about the bot\n" - " !uptime @{nick} - reply with the bot's uptime\n\n" - "Non-standard commands:\n" - " !kill @{nick} - terminate this bot instance\n" - " !restart @{nick} - restart this bot instance\n\n" - "Command extensions:\n" - " !help @{nick} [ ...] - provide help on the topics listed" - )) - - self.add_help("yaboli", ( - "Yaboli is \"Yet Another BOt LIbrary for euphoria\", written by @Garmy in Python.\n" - "It relies heavily on the asyncio module from the standard library and uses f-strings.\n" - "Because of this, Python version >= 3.6 is required.\n\n" - "Github: https://github.com/Garmelon/yaboli" - )) - - @noargs - async def command_ping(self, message): - if self.ping_message: - await self.room.send(self.ping_message, message.mid) - - async def command_help(self, message, argstr): - args = self.parse_args(argstr.lower()) - if not args: - if self.help_specific: - await self.room.send( - self.format_help(self.help_specific), - message.mid - ) + + @staticmethod + def _parse_command(content, specific=None): + print(repr(specific)) + if specific is not None: + print("SPECIFIC") + match = SPECIFIC_RE.fullmatch(content) + if match and similar(match.group(2), specific): + return match.group(1), match.group(3) else: - # collect all valid topics - messages = [] - for topic in sorted(set(args)): - text = self.get_help(topic) - if text: - messages.append(f"Topic: {topic}\n{text}") - - # print result in separate messages - if messages: - for text in messages: - await self.room.send(text, message.mid) - else: - await self.room.send("None of those topics found.", message.mid) - - @noargs - async def command_help_general(self, message): - if self.help_general is not None: - await self.room.send(self.help_general, message.mid) - - @noargs - async def command_uptime(self, message): - 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.mid) - - async def command_kill(self, message, args): - logging.warn(f"Kill attempt by @{mention(message.sender.nick)} in &{self.room.roomname}: {message.content!r}") - - if self.kill_message is not None: - await self.room.send(self.kill_message, message.mid) - - if self.killable: - await self.stop() - - async def command_restart(self, message, args): - logging.warn(f"Restart attempt by @{mention(message.sender.nick)} in &{self.room.roomname}: {message.content!r}") - - if self.restart_message is not None: - await self.room.send(self.restart_message, message.mid) - - if self.restartable: - await self.restart() - -class Multibot(Bot): - def __init__(self, nick, keeper): - super().__init__(nick) - - self.keeper = keeper - -class MultibotKeeper(): - def __init__(self, configfile): - # TODO: load configfile - - # TODO: namedtuple botinfo (bot, task) - self._bots = {} # self._bots[roomname] = botinfo + print("GENERAL") + match = GENERAL_RE.fullmatch(content) + if match: + return match.group(1), match.group(2) diff --git a/yaboli/connection.py b/yaboli/connection.py index d56a527..0258ae3 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -109,6 +109,7 @@ class Connection: if self.cookiejar: for set_cookie in self._ws.response_headers.get_all("Set-Cookie"): self.cookiejar.bake(set_cookie) + self.cookiejar.save() self._pingtask = asyncio.ensure_future(self._ping()) diff --git a/yaboli/controller.py b/yaboli/controller.py deleted file mode 100644 index 14faec3..0000000 --- a/yaboli/controller.py +++ /dev/null @@ -1,213 +0,0 @@ -import asyncio -import logging -from .room import Room - -logger = logging.getLogger(__name__) -__all__ = ["Controller"] - - - -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, nick, human=False, cookie=None, connect_timeout=10): - """ - 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 - connect_timeout - time for authentication to complete - """ - self.nick = nick - self.human = human - self.cookie = cookie - - self.roomname = "test" - self.password = None - - self.room = None - self.connect_timeout = connect_timeout # in seconds - self._connect_result = None - - 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) - - 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 - "timeout" = timed out while waiting for server - "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" - - # connection succeeded, now we need to know whether we can log in - # wait for success/authentication/disconnect - try: - await asyncio.wait_for(self._connect_result, self.connect_timeout) - except asyncio.TimeoutError: - result = "timeout" - else: - 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.room: - logger.info(f"@{self.nick}: Stopping") - await self.room.stop() - logger.debug(f"@{self.nick}: Stopped. Deleting room") - self.room = None - - async def set_nick(self, nick): - if nick != self.nick: - _, _, _, to_nick = await self.room.nick(nick) - self.nick = to_nick - - 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): - """ - 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. - """ - - self._set_connect_result("success") - - 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? - """ - - logger.debug(f"on_disconnected: self.room is {self.room}") - self._set_connect_result("disconnected") - - 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 - - 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_nick, to_nick): - 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 - """ - - 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): - pass - - async def on_send(self, message): - pass - - async def on_snapshot(self, user_id, session_id, version, sessions, messages, nick=None, - pm_with_nick=None, pm_with_user_id=None): - if nick != self.nick: - await self.room.nick(self.nick) diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py index 5f6c922..ac4f3bf 100644 --- a/yaboli/cookiejar.py +++ b/yaboli/cookiejar.py @@ -12,10 +12,14 @@ class CookieJar: Keeps your cookies in a file. """ - def __init__(self, filename): + def __init__(self, filename=None): self._filename = filename self._cookies = cookies.SimpleCookie() + if not self._filename: + logger.info("Could not load cookies, no filename given.") + return + with contextlib.suppress(FileNotFoundError): with open(self._filename, "r") as f: for line in f: @@ -45,6 +49,10 @@ class CookieJar: Saves all current cookies to the cookie jar file. """ + if not self._filename: + logger.info("Could not save cookies, no filename given.") + return + logger.debug(f"Saving cookies to {self._filename!r}") with open(self._filename, "w") as f: diff --git a/yaboli/room.py b/yaboli/room.py index 947e0ad..ff3337b 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,5 +1,6 @@ import asyncio import logging +import time from .connection import * from .exceptions import * @@ -19,12 +20,13 @@ class Room: DISCONNECTED = 2 CLOSED = 3 - def __init__(self, roomname, inhabitant, password=None, human=False, cookiejar=None): + def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None): # TODO: Connect to room etc. # TODO: Deal with room/connection states of: # disconnected connecting, fast-forwarding, connected # Room info (all fields readonly!) + self.target_nick = nick self.roomname = roomname self.password = password self.human = human @@ -33,6 +35,8 @@ class Room: self.account = None self.listing = Listing() + self.start_time = time.time() + self.account_has_access = None self.account_email_verified = None self.room_is_private = None @@ -103,6 +107,8 @@ class Room: uid = data.get("id") from_nick = data.get("from") to_nick = data.get("to") + + self.session.nick = to_nick return sid, uid, from_nick, to_nick async def pm(self, uid): @@ -260,15 +266,13 @@ class Room: # Update room info self.pm_with_nick = data.get("pm_with_nick", None), self.pm_with_user_id = data.get("pm_with_user_id", None) + self.session.nick = data.get("nick", None) - # Remember old nick, because we're going to try to get it back - old_nick = self.session.nick if self.session else None - new_nick = data.get("nick", None) - self.session.nick = new_nick - - if old_nick and old_nick != new_nick: + # Make sure a room is not CONNECTED without a nick + if self.target_nick and self.target_nick != self.session.nick: try: - await self._connection.send("nick", data={"name": old_nick}) + _, nick_data, _, _ = await self._connection.send("nick", data={"name": self.target_nick}) + self.session.nick = nick_data.get("to") except ConnectionClosed: return # Aww, we've lost connection again diff --git a/yaboli/utils.py b/yaboli/utils.py index 3d5df55..48b901f 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -2,12 +2,16 @@ import asyncio import time __all__ = [ + "parallel", "mention", "mention_reduced", "similar", "format_time", "format_time_delta", "Session", "Listing", "Message", ] +# alias for parallel message sending +parallel = asyncio.ensure_future + def mention(nick): return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace()) From fb7079a7a9ca3fc05a5e2caae8d9ced0612e5bc4 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 26 Jul 2018 20:27:38 +0000 Subject: [PATCH 033/145] Clean up --- TemplateBot.py | 92 -------------------------------------------------- 1 file changed, 92 deletions(-) delete mode 100644 TemplateBot.py diff --git a/TemplateBot.py b/TemplateBot.py deleted file mode 100644 index a4615c8..0000000 --- a/TemplateBot.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Copy this template script and modify it to create a new bot. -""" - -import yaboli -from yaboli.utils import * -import sys - - - -class YourBot(yaboli.Bot): - """ - Your bot's docstring - """ - - def __init__(self): - super().__init__("Your bot's name") - - # set help and other settings here - #self.help_general = None - #self.help_specific = "No help available" - #self.killable = True - #self.kill_message = "/me *poof*" - #self.restartable = True - #self.restart_message = "/me temporary *poof*" - - # Event callbacks - just fill in your code. - # If the function contains a super(), DON'T remove it unless you know what you're doing! - # (You can remove the function itself though.) - # When you're done, remove all unneeded functions. - - async def on_connected(self): - await super().on_connected() - - async def on_disconnected(self): - await super().on_disconnected() - - async def on_bounce(self, reason=None, auth_options=[], agent_id=None, ip=None): - await super().on_bounce(reason, auth_options, agent_id, ip) - - 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_nick, to_nick): - 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): - await super().on_ping(ptime, pnext) - - async def on_pm_initiate(self, from_id, from_nick, from_room, pm_id): - pass - - async def on_send(self, message): - await super().on_send(message) # This is where yaboli.bot reacts to commands - - async def on_snapshot(self, user_id, session_id, version, listing, log, nick=None, - pm_with_nick=None, pm_with_user_id=None): - await super().on_snapshot(user_id, session_id, version, listing, log, nick, pm_with_nick, - pm_with_user_id) - -def main(): - if len(sys.argv) == 2: - run_bot(YourBot, sys.argv[1]) - else: - print("USAGE:") - print(f" {sys.argv[0]} ") - return - -if __name__ == "__main__": - main() From b5827df2f15035451aa5d39190e9325b375adc00 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 26 Jul 2018 20:34:02 +0000 Subject: [PATCH 034/145] Clean up some debugging prints --- yaboli/bot.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index ddac48e..64732c5 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -19,12 +19,9 @@ GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)") def command(commandname, specific=True, noargs=False): def decorator(func): async def wrapper(self, room, message, *args, **kwargs): - print(f"New message: {message.content!r}, current name: {room.session!r}") if specific: - print(f"Trying specific: {message.content!r}") result = self._parse_command(message.content, specific=room.session.nick) else: - print(f"Trying general: {message.content!r}") result = self._parse_command(message.content) if result is None: return cmd, argstr = result @@ -172,14 +169,11 @@ class Bot(Inhabitant): @staticmethod def _parse_command(content, specific=None): - print(repr(specific)) if specific is not None: - print("SPECIFIC") match = SPECIFIC_RE.fullmatch(content) if match and similar(match.group(2), specific): return match.group(1), match.group(3) else: - print("GENERAL") match = GENERAL_RE.fullmatch(content) if match: return match.group(1), match.group(2) From 5ee578258e64c27786965c7774976cc2f15b40e8 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 26 Jul 2018 22:28:23 +0000 Subject: [PATCH 035/145] Fix some bugs --- yaboli/bot.py | 9 +++------ yaboli/room.py | 9 +++------ yaboli/utils.py | 9 +++++++++ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index 64732c5..d91ce01 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -8,7 +8,7 @@ from .utils import * logger = logging.getLogger(__name__) -__all__ = ["Bot"] +__all__ = ["Bot", "command"] # Some command stuff @@ -30,7 +30,7 @@ def command(commandname, specific=True, noargs=False): if argstr: return return await func(self, room, message, *args, **kwargs) else: - return await func(self, room, message, args*args, **kwargs) + return await func(self, room, message, argstr, *args, **kwargs) return wrapper return decorator @@ -38,14 +38,11 @@ def command(commandname, specific=True, noargs=False): # And now comes the real bot... class Bot(Inhabitant): - def __init__(self, nick, cookiefile=None, rooms=["test"]): + def __init__(self, nick, cookiefile=None): self.target_nick = nick self.rooms = {} self.cookiejar = CookieJar(cookiefile) - for roomname in rooms: - self.join_room(roomname) - # ROOM MANAGEMENT def join_room(self, roomname, password=None): diff --git a/yaboli/room.py b/yaboli/room.py index ff3337b..cf83e00 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -98,6 +98,7 @@ class Room: if self._status == Room.CLOSED: raise RoomClosed() + self.target_nick = nick ptype, data, error, throttled = await self._send_while_connected( "nick", name=nick @@ -126,11 +127,6 @@ class Room: return pm_id, to_nick async def send(self, content, parent_mid=None): - """ - Send a message to the room. - See http://api.euphoria.io/#send - """ - if parent_mid: ptype, data, error, throttled = await self._send_while_connected( "send", @@ -146,7 +142,8 @@ class Room: return Message.from_dict(data) async def who(self): - pass + ptype, data, error, throttled = await self._send_while_connected("who") + self.listing = Listing.from_dict(data.get("listing")) # COMMUNICATION WITH CONNECTION diff --git a/yaboli/utils.py b/yaboli/utils.py index 48b901f..e810998 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,6 +1,8 @@ import asyncio +import logging import time +logger = logging.getLogger(__name__) __all__ = [ "parallel", "mention", "mention_reduced", "similar", @@ -144,6 +146,13 @@ class Listing: return sessions + @classmethod + def from_dict(cls, d): + listing = cls() + for session in d: + listing.add(Session.from_dict(session)) + return listing + #def get_people(self): #return self.get(types=["agent", "account"]) From e39f41e1836d9b34a46b23aff6f8c4d5f8b58f7e Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 26 Jul 2018 22:31:50 +0000 Subject: [PATCH 036/145] Remove debugging stuff --- yaboli/__init__.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 7f6b6ba..30fe578 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,15 +1,3 @@ -# ---------- BEGIN DEV SECTION ---------- -import asyncio -import logging - -# asyncio debugging -asyncio.get_event_loop().set_debug(True) # uncomment for asycio debugging mode -logging.getLogger("asyncio").setLevel(logging.DEBUG) - -# yaboli logger level -logging.getLogger(__name__).setLevel(logging.DEBUG) -# ----------- END DEV SECTION ----------- - from .bot import * from .cookiejar import * from .connection import * From 6174fa6ff147ad33fed5379f22a96e09da9f1e28 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 27 Jul 2018 10:29:55 +0000 Subject: [PATCH 037/145] Add yaboli.trigger decorator --- yaboli/bot.py | 53 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index d91ce01..2f0dedf 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -8,7 +8,7 @@ from .utils import * logger = logging.getLogger(__name__) -__all__ = ["Bot", "command"] +__all__ = ["Bot", "command", "trigger"] # Some command stuff @@ -16,21 +16,42 @@ __all__ = ["Bot", "command"] SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)") GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)") -def command(commandname, specific=True, noargs=False): +# Decorator magic for commands and triggers. +# I think commands could probably be implemented as some kind of triggers, +# but I'm not gonna do that now because commands are working fine this way. +def command(commandname, specific=True, args=True): def decorator(func): - async def wrapper(self, room, message, *args, **kwargs): + async def wrapper(self, room, message, *args_, **kwargs_): if specific: result = self._parse_command(message.content, specific=room.session.nick) else: result = self._parse_command(message.content) - if result is None: return + if result is None: return False cmd, argstr = result - if cmd != commandname: return - if noargs: - if argstr: return - return await func(self, room, message, *args, **kwargs) + if cmd != commandname: return False + if args: + await func(self, room, message, argstr, *args_, **kwargs_) + return True else: - return await func(self, room, message, argstr, *args, **kwargs) + if argstr: return + await func(self, room, message, *args_, **kwargs_) + return True + return wrapper + return decorator + +def trigger(regex, fullmatch=True, flags=0): + def decorator(func): + compiled_regex = re.compile(regex, flags=flags) + async def wrapper(self, room, message, *args, **kwargs): + if fullmatch: + match = compiled_regex.fullmatch(message.content) + else: + match = compiled_regex.match(message.content) + if match is not None: + await func(self, room, message, match, *args, **kwargs) + return True + else: + return False return wrapper return decorator @@ -58,23 +79,23 @@ class Bot(Inhabitant): # BOTRULEZ - @command("ping", specific=False, noargs=True) + @command("ping", specific=False, args=False) async def botrulez_ping_general(self, room, message, ping_text="Pong!"): await room.send(ping_text, message.mid) - @command("ping", specific=True, noargs=True) + @command("ping", specific=True, args=False) async def botrulez_ping_specific(self, room, message, ping_text="Pong!"): await room.send(ping_text, message.mid) - @command("help", specific=False, noargs=True) + @command("help", specific=False, args=False) async def botrulez_help_general(self, room, message, help_text="Placeholder help text"): await room.send(help_text, message.mid) - @command("help", specific=True, noargs=True) + @command("help", specific=True, args=False) async def botrulez_help_specific(self, room, message, help_text="Placeholder help text"): await room.send(help_text, message.mid) - @command("uptime", specific=True, noargs=True) + @command("uptime", specific=True, args=False) async def botrulez_uptime(self, room, message): now = time.time() startformat = format_time(room.start_time) @@ -82,12 +103,12 @@ class Bot(Inhabitant): text = f"/me has been up since {startformat} ({deltaformat})" await room.send(text, message.mid) - @command("kill", specific=True, noargs=True) + @command("kill", specific=True, args=False) async def botrulez_kill(self, room, message, kill_text="/me dies"): await room.send(kill_text, message.mid) await self.part_room(room.roomname) - @command("restart", specific=True, noargs=True) + @command("restart", specific=True, args=False) async def botrulez_restart(self, room, message, restart_text="/me restarts"): await room.send(restart_text, message.mid) await self.part_room(room.roomname) From bd75d0ebba7965c4b3d592f74b9f31806c7e30bf Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 27 Jul 2018 12:21:33 +0000 Subject: [PATCH 038/145] Add new inhabitant callback --- yaboli/room.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/yaboli/room.py b/yaboli/room.py index cf83e00..b8d9292 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -56,6 +56,8 @@ class Room: cookiejar ) + asyncio.ensure_future(self._inhabitant.created(self)) + async def exit(self): self._status = Room.CLOSED await self._connection.stop() @@ -327,12 +329,15 @@ class Inhabitant: # They're launched via asyncio.ensure_future(), so they don't block execution of the room. # Just overwrite the events you need (make sure to keep the arguments the same though). - async def disconnected(self, room): + async def created(self, room): pass async def connected(self, room, log): pass + async def disconnected(self, room): + pass + async def join(self, room, session): pass From f8d3f68ed95d98398881b31eeba14ad3f6804455 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 27 Jul 2018 12:21:57 +0000 Subject: [PATCH 039/145] Clean up --- yaboli/database.py | 87 ---------------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 yaboli/database.py diff --git a/yaboli/database.py b/yaboli/database.py deleted file mode 100644 index 1d6e359..0000000 --- a/yaboli/database.py +++ /dev/null @@ -1,87 +0,0 @@ -import asyncio -from functools import wraps -import sqlite3 -import threading - -__all__ = ["Database"] - - - -def shielded(afunc): - #@wraps(afunc) - async def wrapper(*args, **kwargs): - return await asyncio.shield(afunc(*args, **kwargs)) - return wrapper - -class PooledConnection: - def __init__(self, pool): - self._pool = pool - - self.connection = None - - async def open(self): - self.connection = await self._pool._request() - - async def close(self): - conn = self.connection - self.connection = None - await self._pool._return(conn) - - async def __aenter__(self): - await self.open() - return self - - async def __aexit__(self, exc_type, exc, tb): - await self.close() - -class Pool: - def __init__(self, filename, size=10): - self.filename = filename - self.size = size - - self._available_connections = asyncio.Queue() - - for i in range(size): - conn = sqlite3.connect(self.filename, check_same_thread=False) - self._available_connections.put_nowait(conn) - - def connection(self): - return PooledConnection(self) - - async def _request(self): - return await self._available_connections.get() - - async def _return(self, conn): - await self._available_connections.put(conn) - -class Database: - def __init__(self, filename, pool_size=10, event_loop=None): - self._filename = filename - self._pool = Pool(filename, size=pool_size) - self._loop = event_loop or asyncio.get_event_loop() - - def operation(func): - @wraps(func) - @shielded - async def wrapper(self, *args, **kwargs): - async with self._pool.connection() as conn: - return await self._run_in_thread(func, conn.connection, *args, **kwargs) - return wrapper - - @staticmethod - def _target_function(loop, future, func, *args, **kwargs): - result = None - try: - result = func(*args, **kwargs) - finally: - loop.call_soon_threadsafe(future.set_result, result) - - async def _run_in_thread(self, func, *args, **kwargs): - finished = asyncio.Future() - target_args = (self._loop, finished, func, *args) - - thread = threading.Thread(target=self._target_function, args=target_args, kwargs=kwargs) - thread.start() - - await finished - return finished.result() From 943537b57a6b57917db2415a81b0e72b638aa65c Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 27 Jul 2018 15:12:23 +0000 Subject: [PATCH 040/145] Add bot's own session to listing --- yaboli/room.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/yaboli/room.py b/yaboli/room.py index b8d9292..4f5b75a 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -146,6 +146,7 @@ class Room: async def who(self): ptype, data, error, throttled = await self._send_while_connected("who") self.listing = Listing.from_dict(data.get("listing")) + self.listing.add(self.session) # COMMUNICATION WITH CONNECTION @@ -203,6 +204,8 @@ class Room: self.account_has_access = data.get("account_has_access", None) self.account_email_verified = data.get("account_email_verified", None) + self.listing.add(self.session) + async def _event_join(self, data): session = Session.from_dict(data) self.listing.add(session) @@ -261,6 +264,7 @@ class Room: sessions = [Session.from_dict(d) for d in data.get("listing")] for session in sessions: self.listing.add(session) + self.listing.add(self.session) # Update room info self.pm_with_nick = data.get("pm_with_nick", None), From d9761008f6806d9c6cc218872b72f91d707617d9 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 27 Jul 2018 19:09:43 +0000 Subject: [PATCH 041/145] Change botrulez arguments and update examplebot --- .gitignore | 3 ++- ExampleBot.py | 21 --------------------- examplebot.py | 33 +++++++++++++++++++++++++++++++++ join_rooms.py | 2 ++ yaboli/bot.py | 24 ++++++++++++------------ 5 files changed, 49 insertions(+), 34 deletions(-) delete mode 100644 ExampleBot.py create mode 100644 examplebot.py create mode 100644 join_rooms.py diff --git a/.gitignore b/.gitignore index 66427ba..0fc3ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -yaboli/__pycache__/ +**/__pycache__/ *.db +*.cookie diff --git a/ExampleBot.py b/ExampleBot.py deleted file mode 100644 index 0a60d0b..0000000 --- a/ExampleBot.py +++ /dev/null @@ -1,21 +0,0 @@ -import asyncio -import yaboli - -class ExampleBot(yaboli.Bot): - async def send(self, room, message): - ping = "ExamplePong!" - short_help = "Example bot for the yaboli bot library" - long_help = "I'm an example bot for the yaboli bot library, which can be found at https://github.com/Garmelon/yaboli" - - await self.botrulez_ping_general(room, message, ping_text=ping) - await self.botrulez_ping_specific(room, message, ping_text=ping) - await self.botrulez_help_general(room, message, help_text=short_help) - await self.botrulez_help_specific(room, message, help_text=long_help) - await self.botrulez_uptime(room, message) - await self.botrulez_kill(room, message) - await self.botrulez_restart(room, message) - - forward = send # should work without modifications for most bots - -bot = ExampleBot("ExampleBot", "examplebot_cookies", rooms=["test", "welcome"]) -asyncio.get_event_loop().run_forever() diff --git a/examplebot.py b/examplebot.py new file mode 100644 index 0000000..01cf376 --- /dev/null +++ b/examplebot.py @@ -0,0 +1,33 @@ +import asyncio + +import yaboli +from yaboli.utils import * +from join_rooms import join_rooms # List of rooms kept in separate file, which is .gitignore'd + + +class ExampleBot(yaboli.Bot): + async def send(self, room, message): + ping = "ExamplePong!" + short_help = "Example bot for the yaboli bot library" + long_help = ( + "I'm an example bot for the yaboli bot library," + " which can be found at https://github.com/Garmelon/yaboli" + ) + + await self.botrulez_ping_general(room, message, text=ping) + await self.botrulez_ping_specific(room, message, text=ping) + await self.botrulez_help_general(room, message, text=short_help) + await self.botrulez_help_specific(room, message, text=long_help) + await self.botrulez_uptime(room, message) + await self.botrulez_kill(room, message, text="/me dies spectacularly") + await self.botrulez_restart(room, message, text="/me restarts spectacularly") + + forward = send # should work without modifications for most bots + +def main(): + bot = ExampleBot("ExampleBot", "examplebot.cookie") + join_rooms(bot) + asyncio.get_event_loop().run_forever() + +if __name__ == "__main__": + main() diff --git a/join_rooms.py b/join_rooms.py new file mode 100644 index 0000000..cd3f3c4 --- /dev/null +++ b/join_rooms.py @@ -0,0 +1,2 @@ +def join_rooms(bot): + bot.join_room("test") diff --git a/yaboli/bot.py b/yaboli/bot.py index 2f0dedf..005706c 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -80,20 +80,20 @@ class Bot(Inhabitant): # BOTRULEZ @command("ping", specific=False, args=False) - async def botrulez_ping_general(self, room, message, ping_text="Pong!"): - await room.send(ping_text, message.mid) + async def botrulez_ping_general(self, room, message, text="Pong!"): + await room.send(text, message.mid) @command("ping", specific=True, args=False) - async def botrulez_ping_specific(self, room, message, ping_text="Pong!"): - await room.send(ping_text, message.mid) + async def botrulez_ping_specific(self, room, message, text="Pong!"): + await room.send(text, message.mid) @command("help", specific=False, args=False) - async def botrulez_help_general(self, room, message, help_text="Placeholder help text"): - await room.send(help_text, message.mid) + async def botrulez_help_general(self, room, message, text="Placeholder help text"): + await room.send(text, message.mid) @command("help", specific=True, args=False) - async def botrulez_help_specific(self, room, message, help_text="Placeholder help text"): - await room.send(help_text, message.mid) + async def botrulez_help_specific(self, room, message, text="Placeholder help text"): + await room.send(text, message.mid) @command("uptime", specific=True, args=False) async def botrulez_uptime(self, room, message): @@ -104,13 +104,13 @@ class Bot(Inhabitant): await room.send(text, message.mid) @command("kill", specific=True, args=False) - async def botrulez_kill(self, room, message, kill_text="/me dies"): - await room.send(kill_text, message.mid) + async def botrulez_kill(self, room, message, text="/me dies"): + await room.send(text, message.mid) await self.part_room(room.roomname) @command("restart", specific=True, args=False) - async def botrulez_restart(self, room, message, restart_text="/me restarts"): - await room.send(restart_text, message.mid) + async def botrulez_restart(self, room, message, text="/me restarts"): + await room.send(text, message.mid) await self.part_room(room.roomname) self.join_room(room.roomname, password=room.password) From 7e28c6e3dd9ea27babdb10cb1246f3556b3b90aa Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 27 Jul 2018 19:22:04 +0000 Subject: [PATCH 042/145] Deal with closed ws connection while sending --- yaboli/connection.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 0258ae3..97a6bae 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -48,7 +48,10 @@ class Connection: wait_for = self._wait_for_response(pid) logging.debug(f"Currently used websocket at self._ws: {self._ws}") - await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size + try: + await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size + except websockets.ConnectionClosed: + raise ConnectionClosed() if await_response: await wait_for From 55798a5b8817efcb395c45600f216aa3b1566abd Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 27 Jul 2018 22:49:13 +0000 Subject: [PATCH 043/145] Implement rewind/fast-forward system --- yaboli/room.py | 75 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/yaboli/room.py b/yaboli/room.py index 4f5b75a..d193e8f 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -19,6 +19,7 @@ class Room: CONNECTED = 1 DISCONNECTED = 2 CLOSED = 3 + FORWARDING = 4 def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None): # TODO: Connect to room etc. @@ -48,6 +49,10 @@ class Room: self._status = Room.DISCONNECTED self._connected_future = asyncio.Future() + self._last_known_mid = None + self._forwarding = None # task that downloads messages and fowards + self._forward_new = [] # new messages received while downloading old messages + # TODO: Allow for all parameters of Connection() to be specified in Room(). self._connection = Connection( self.format_room_url(self.roomname, human=self.human), @@ -78,15 +83,16 @@ class Room: return Message.from_dict(data) - async def log(self, n, before_mid=None): + # The log returned is sorted from old to new + async def log(self, n, before=None): if self._status == Room.CLOSED: raise RoomClosed() - if before_mid: + if before: ptype, data, error, throttled = await self._send_while_connected( "log", n=n, - before=before_mid + before=before ) else: ptype, data, error, throttled = await self._send_while_connected( @@ -128,12 +134,12 @@ class Room: to_nick = data.get("to_nick") return pm_id, to_nick - async def send(self, content, parent_mid=None): - if parent_mid: + async def send(self, content, parent=None): + if parent: ptype, data, error, throttled = await self._send_while_connected( "send", content=content, - parent=parent_mid + parent=parent ) else: ptype, data, error, throttled = await self._send_while_connected( @@ -141,7 +147,9 @@ class Room: content=content ) - return Message.from_dict(data) + message = Message.from_dict(data) + self._last_known_mid = message.mid + return message async def who(self): ptype, data, error, throttled = await self._send_while_connected("who") @@ -156,6 +164,9 @@ class Room: self.status = Room.DISCONNECTED self._connected_future = asyncio.Future() + if self._forwarding is not None: + self._forwarding.cancel() + await self._inhabitant.disconnected(self) async def _receive_packet(self, ptype, data, error, throttled): @@ -254,14 +265,20 @@ class Room: async def _event_send(self, data): message = Message.from_dict(data) - await self._inhabitant.send(self, message) + if self._status == Room.FORWARDING: + self._forward_new.append(message) + else: + self._last_known_mid = message.mid + await self._inhabitant.send(self, message) # TODO: Figure out a way to bring fast-forwarding into this async def _event_snapshot(self, data): + log = [Message.from_dict(m) for m in data.get("log")] + sessions = [Session.from_dict(d) for d in data.get("listing")] + # Update listing self.listing = Listing() - sessions = [Session.from_dict(d) for d in data.get("listing")] for session in sessions: self.listing.add(session) self.listing.add(self.session) @@ -280,13 +297,23 @@ class Room: return # Aww, we've lost connection again # Now, we're finally connected again! - self.status = Room.CONNECTED + if self._last_known_mid is None: + self._status = Room.CONNECTED + if log: # log goes from old to new + self._last_known_mid = log[-1].mid + else: + self._status = Room.FORWARDING + self._forward_new = [] + + if self._forwarding is not None: + self._forwarding.cancel() + self._forwarding = asyncio.ensure_future(self._forward(log)) + if not self._connected_future.done(): # Should never be done already, I think self._connected_future.set_result(None) # Let's let the inhabitant know. logger.debug("Letting inhabitant know") - log = [Message.from_dict(m) for m in data.get("log")] await self._inhabitant.connected(self, log) # TODO: Figure out a way to bring fast-forwarding into this @@ -311,6 +338,32 @@ class Room: # REST OF THE IMPLEMENTATION + async def _forward(self, log): + old_messages = [] + while True: + found_last_known = True + for message in reversed(log): + if message.mid <= self._last_known_mid: + break + old_messages.append(message) + else: + found_last_known = False + + if found_last_known: + break + + log = await self.log(100, before=log[0].mid) + + for message in reversed(old_messages): + self._last_known_mid = message.mid + asyncio.ensure_future(self._inhabitant.forward(self, message)) + for message in self._forward_new: + self._last_known_mid = message.mid + asyncio.ensure_future(self._inhabitant.forward(self, message)) + + self._forward_new = [] + self._status = Room.CONNECTED + async def _send_while_connected(self, *args, **kwargs): while True: if self._status == Room.CLOSED: From 31f4e23abab8ac11f4f0299462477123820ba8cc Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 29 Jul 2018 11:43:26 +0000 Subject: [PATCH 044/145] Enable debugging in examplebot --- examplebot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examplebot.py b/examplebot.py index 01cf376..7d355f5 100644 --- a/examplebot.py +++ b/examplebot.py @@ -1,4 +1,9 @@ import asyncio +import logging + +# Turn all debugging on +asyncio.get_event_loop().set_debug(True) +logging.basicConfig(level=logging.DEBUG) import yaboli from yaboli.utils import * From 46cd20ac746d0e2bf085429f37c7f409a10312f6 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 29 Jul 2018 12:20:40 +0000 Subject: [PATCH 045/145] Log more stuff --- examplebot.py | 12 ++++++++---- yaboli/connection.py | 7 ++++--- yaboli/cookiejar.py | 7 ++++--- yaboli/room.py | 17 +++++++++++++++-- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/examplebot.py b/examplebot.py index 7d355f5..83f3b7a 100644 --- a/examplebot.py +++ b/examplebot.py @@ -1,14 +1,15 @@ import asyncio import logging -# Turn all debugging on -asyncio.get_event_loop().set_debug(True) -logging.basicConfig(level=logging.DEBUG) - import yaboli from yaboli.utils import * from join_rooms import join_rooms # List of rooms kept in separate file, which is .gitignore'd +# Turn all debugging on +asyncio.get_event_loop().set_debug(True) +logging.getLogger("asyncio").setLevel(logging.INFO) +logging.getLogger("yaboli").setLevel(logging.DEBUG) + class ExampleBot(yaboli.Bot): async def send(self, room, message): @@ -26,6 +27,9 @@ class ExampleBot(yaboli.Bot): await self.botrulez_uptime(room, message) await self.botrulez_kill(room, message, text="/me dies spectacularly") await self.botrulez_restart(room, message, text="/me restarts spectacularly") + + if message.content == "!!!": + await room._connection._ws.close() forward = send # should work without modifications for most bots diff --git a/yaboli/connection.py b/yaboli/connection.py index 97a6bae..c0bb174 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -154,6 +154,7 @@ class Connection: while not self._stopped: await self._connect(self.reconnect_attempts) + logger.debug(f"{self.url}:Connected") try: while True: @@ -170,13 +171,13 @@ class Connection: try: while True: - logger.debug("Pinging...") + logger.debug(f"{self.url}:Pinging...") wait_for_reply = await self._ws.ping() await asyncio.wait_for(wait_for_reply, self.ping_timeout) - logger.debug("Pinged!") + logger.debug(f"{self.url}:Pinged!") await asyncio.sleep(self.ping_delay) except asyncio.TimeoutError: - logger.warning("Ping timed out.") + logger.warning(f"{self.url}:Ping timed out") await self.reconnect() except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): pass diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py index ac4f3bf..8b3a073 100644 --- a/yaboli/cookiejar.py +++ b/yaboli/cookiejar.py @@ -17,10 +17,11 @@ class CookieJar: self._cookies = cookies.SimpleCookie() if not self._filename: - logger.info("Could not load cookies, no filename given.") + logger.warning("Could not load cookies, no filename given.") return with contextlib.suppress(FileNotFoundError): + logger.info(f"Loading cookies from {self._filename!r}") with open(self._filename, "r") as f: for line in f: self._cookies.load(line) @@ -50,10 +51,10 @@ class CookieJar: """ if not self._filename: - logger.info("Could not save cookies, no filename given.") + logger.warning("Could not save cookies, no filename given.") return - logger.debug(f"Saving cookies to {self._filename!r}") + logger.info(f"Saving cookies to {self._filename!r}") with open(self._filename, "w") as f: for morsel in self._cookies.values(): diff --git a/yaboli/room.py b/yaboli/room.py index d193e8f..7dde973 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -161,6 +161,7 @@ class Room: async def _disconnected(self): # While disconnected, keep the last known session info, listing etc. # All of this is instead reset when the hello/snapshot events are received. + logger.warn(f"&{self.roomname}:Lost connection.") self.status = Room.DISCONNECTED self._connected_future = asyncio.Future() @@ -193,6 +194,7 @@ class Room: await function(data) async def _event_bounce(self, data): + logger.info(f"&{self.roomname}:Received bounce-event") if self.password is not None: try: data = {"type": passcode, "passcode": self.password} @@ -201,10 +203,14 @@ class Room: success = rdata.get("success") if not success: reason = rdata.get("reason") - raise AuthenticationRequired(f"Could not join &{self.roomname}: {reason}") + logger.warn(f"&{self.roomname}:Authentication failed: {reason}") + raise AuthenticationRequired(f"Could not join &{self.roomname}:{reason}") + else: + logger.info(f"&{self.roomname}:Authentication successful") except ConnectionClosed: pass else: + logger.warn(f"&{self.roomname}:Could not authenticate: Password unknown") raise AuthenticationRequired(f"&{self.roomname} is password locked but no password was given") async def _event_hello(self, data): @@ -225,6 +231,7 @@ class Room: async def _event_network(self, data): server_id = data.get("server_id") server_era = data.get("server_era") + logger.debug(f"&{self.roomname}:Received network-event: server_id: {server_id!r}, server_era: {server_era!r}") sessions = self.listing.remove_combo(server_id, server_era) for session in sessions: @@ -266,6 +273,7 @@ class Room: message = Message.from_dict(data) if self._status == Room.FORWARDING: + logger.info(f"&{self.roomname}:Received new message while forwarding, adding to queue") self._forward_new.append(message) else: self._last_known_mid = message.mid @@ -274,6 +282,7 @@ class Room: # TODO: Figure out a way to bring fast-forwarding into this async def _event_snapshot(self, data): + logger.debug(f"&{self.roomname}:Received snapshot-event, gained access to the room") log = [Message.from_dict(m) for m in data.get("log")] sessions = [Session.from_dict(d) for d in data.get("listing")] @@ -290,6 +299,7 @@ class Room: # Make sure a room is not CONNECTED without a nick if self.target_nick and self.target_nick != self.session.nick: + logger.info(f"&{self.roomname}:Current nick doesn't match target nick {self.target_nick!r}, changing nick") try: _, nick_data, _, _ = await self._connection.send("nick", data={"name": self.target_nick}) self.session.nick = nick_data.get("to") @@ -298,10 +308,12 @@ class Room: # Now, we're finally connected again! if self._last_known_mid is None: + logger.info(f"&{self.roomname}:Fully connected") self._status = Room.CONNECTED if log: # log goes from old to new self._last_known_mid = log[-1].mid else: + logger.info(f"&{self.roomname}:Not fully connected yet, starting message rewinding") self._status = Room.FORWARDING self._forward_new = [] @@ -313,7 +325,6 @@ class Room: self._connected_future.set_result(None) # Let's let the inhabitant know. - logger.debug("Letting inhabitant know") await self._inhabitant.connected(self, log) # TODO: Figure out a way to bring fast-forwarding into this @@ -354,6 +365,7 @@ class Room: log = await self.log(100, before=log[0].mid) + logger.info(f"&{self.roomname}:Reached last known message, forwarding through messages") for message in reversed(old_messages): self._last_known_mid = message.mid asyncio.ensure_future(self._inhabitant.forward(self, message)) @@ -361,6 +373,7 @@ class Room: self._last_known_mid = message.mid asyncio.ensure_future(self._inhabitant.forward(self, message)) + logger.info(f"&{self.roomname}:Forwarding complete, fully connected") self._forward_new = [] self._status = Room.CONNECTED From 339d3ca51692bedbe81d4085d28ccc1839bd9f7e Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 29 Jul 2018 15:26:45 +0000 Subject: [PATCH 046/145] Rename event functions --- examplebot.py | 7 +------ yaboli/room.py | 42 +++++++++++++++++++++--------------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/examplebot.py b/examplebot.py index 83f3b7a..22536e3 100644 --- a/examplebot.py +++ b/examplebot.py @@ -12,7 +12,7 @@ logging.getLogger("yaboli").setLevel(logging.DEBUG) class ExampleBot(yaboli.Bot): - async def send(self, room, message): + async def on_send(self, room, message): ping = "ExamplePong!" short_help = "Example bot for the yaboli bot library" long_help = ( @@ -28,11 +28,6 @@ class ExampleBot(yaboli.Bot): await self.botrulez_kill(room, message, text="/me dies spectacularly") await self.botrulez_restart(room, message, text="/me restarts spectacularly") - if message.content == "!!!": - await room._connection._ws.close() - - forward = send # should work without modifications for most bots - def main(): bot = ExampleBot("ExampleBot", "examplebot.cookie") join_rooms(bot) diff --git a/yaboli/room.py b/yaboli/room.py index 7dde973..4456b9c 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -61,7 +61,7 @@ class Room: cookiejar ) - asyncio.ensure_future(self._inhabitant.created(self)) + asyncio.ensure_future(self._inhabitant.on_created(self)) async def exit(self): self._status = Room.CLOSED @@ -168,7 +168,7 @@ class Room: if self._forwarding is not None: self._forwarding.cancel() - await self._inhabitant.disconnected(self) + await self._inhabitant.on_disconnected(self) async def _receive_packet(self, ptype, data, error, throttled): # Ignoring errors and throttling for now @@ -226,7 +226,7 @@ class Room: async def _event_join(self, data): session = Session.from_dict(data) self.listing.add(session) - await self._inhabitant.join(self, session) + await self._inhabitant.on_join(self, session) async def _event_network(self, data): server_id = data.get("server_id") @@ -235,7 +235,7 @@ class Room: sessions = self.listing.remove_combo(server_id, server_era) for session in sessions: - await self._inhabitant.part(self, session) + asyncio.ensure_future(self._inhabitant.on_part(self, session)) async def _event_nick(self, data): sid = data.get("session_id") @@ -247,12 +247,12 @@ class Room: if session: session.nick = to_nick - await self._inhabitant.nick(self, sid, uid, from_nick, to_nick) + await self._inhabitant.on_nick(self, sid, uid, from_nick, to_nick) async def _event_part(self, data): session = Session.from_dict(data) self.listing.remove(session.sid) - await self._inhabitant.part(self, session) + await self._inhabitant.on_part(self, session) async def _event_ping(self, data): try: @@ -267,7 +267,7 @@ class Room: from_room = data.get("from_room") pm_id = data.get("pm_id") - await self._inhabitant.pm(self, from_uid, from_nick, from_room, pm_id) + await self._inhabitant.on_pm(self, from_uid, from_nick, from_room, pm_id) async def _event_send(self, data): message = Message.from_dict(data) @@ -277,7 +277,7 @@ class Room: self._forward_new.append(message) else: self._last_known_mid = message.mid - await self._inhabitant.send(self, message) + await self._inhabitant.on_send(self, message) # TODO: Figure out a way to bring fast-forwarding into this @@ -325,7 +325,7 @@ class Room: self._connected_future.set_result(None) # Let's let the inhabitant know. - await self._inhabitant.connected(self, log) + await self._inhabitant.on_connected(self, log) # TODO: Figure out a way to bring fast-forwarding into this # Should probably happen where this comment is @@ -368,10 +368,10 @@ class Room: logger.info(f"&{self.roomname}:Reached last known message, forwarding through messages") for message in reversed(old_messages): self._last_known_mid = message.mid - asyncio.ensure_future(self._inhabitant.forward(self, message)) + asyncio.ensure_future(self._inhabitant.on_forward(self, message)) for message in self._forward_new: self._last_known_mid = message.mid - asyncio.ensure_future(self._inhabitant.forward(self, message)) + asyncio.ensure_future(self._inhabitant.on_forward(self, message)) logger.info(f"&{self.roomname}:Forwarding complete, fully connected") self._forward_new = [] @@ -399,29 +399,29 @@ class Inhabitant: # They're launched via asyncio.ensure_future(), so they don't block execution of the room. # Just overwrite the events you need (make sure to keep the arguments the same though). - async def created(self, room): + async def on_created(self, room): pass - async def connected(self, room, log): + async def on_connected(self, room, log): pass - async def disconnected(self, room): + async def on_disconnected(self, room): pass - async def join(self, room, session): + async def on_join(self, room, session): pass - async def part(self, room, session): + async def on_part(self, room, session): pass - async def nick(self, room, sid, uid, from_nick, to_nick): + async def on_nick(self, room, sid, uid, from_nick, to_nick): pass - async def send(self, room, message): + async def on_send(self, room, message): pass - async def fast_forward(self, room, message): - pass + async def on_forward(self, room, message): + await self.on_send(room, message) - async def pm(self, room, from_uid, from_nick, from_room, pm_id): + async def on_pm(self, room, from_uid, from_nick, from_room, pm_id): pass From bb2dadb8629a3064c1d2bc3fc637acf694b52720 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 31 Jul 2018 15:18:57 +0000 Subject: [PATCH 047/145] Add database module --- yaboli/__init__.py | 2 ++ yaboli/database.py | 31 +++++++++++++++++++++++++++++++ yaboli/utils.py | 7 ++++++- 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 yaboli/database.py diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 30fe578..89eccb2 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,6 +1,7 @@ from .bot import * from .cookiejar import * from .connection import * +from .database import * from .exceptions import * from .room import * from .utils import * @@ -9,6 +10,7 @@ __all__ = ( bot.__all__ + connection.__all__ + cookiejar.__all__ + + database.__all__ + exceptions.__all__ + room.__all__ + utils.__all__ diff --git a/yaboli/database.py b/yaboli/database.py new file mode 100644 index 0000000..36e72d4 --- /dev/null +++ b/yaboli/database.py @@ -0,0 +1,31 @@ +import asyncio +import sqlite3 + +from .utils import * + + +__all__ = ["Database", "operation"] + + +def operation(func): + async def wrapper(self, *args, **kwargs): + async with self as db: + return await asyncify(func, db, *args, **kwargs) + return wrapper + +class Database: + def __init__(self, database): + self._connection = sqlite3.connect(database, check_same_thread=False) + self._lock = asyncio.Lock() + + self.initialize(self._connection) + + def initialize(self, db): + pass + + async def __aenter__(self, *args, **kwargs): + await self._lock.__aenter__(*args, **kwargs) + return self._connection + + async def __aexit__(self, *args, **kwargs): + return await self._lock.__aexit__(*args, **kwargs) diff --git a/yaboli/utils.py b/yaboli/utils.py index e810998..d1f9ccb 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,10 +1,11 @@ import asyncio import logging import time +import functools logger = logging.getLogger(__name__) __all__ = [ - "parallel", + "parallel", "asyncify", "mention", "mention_reduced", "similar", "format_time", "format_time_delta", "Session", "Listing", "Message", @@ -14,6 +15,10 @@ __all__ = [ # alias for parallel message sending parallel = asyncio.ensure_future +async def asyncify(func, *args, **kwargs): + func_with_args = functools.partial(func, *args, **kwargs) + return await asyncio.get_event_loop().run_in_executor(None, func_with_args) + def mention(nick): return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace()) From df72a3d9cf72bdd4dc8e0de0d4ae2ac8c4fd8e31 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 31 Jul 2018 15:19:11 +0000 Subject: [PATCH 048/145] get_message returns None when no message was found --- yaboli/room.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/yaboli/room.py b/yaboli/room.py index 4456b9c..840ebda 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -81,7 +81,9 @@ class Room: id=mid ) - return Message.from_dict(data) + if data: + return Message.from_dict(data) + # else: message does not exist # The log returned is sorted from old to new async def log(self, n, before=None): From d55d05826f11d14080535e2fded2a8dc64082838 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 1 Aug 2018 21:41:08 +0000 Subject: [PATCH 049/145] Notice when connection runs out of retries --- yaboli/bot.py | 5 +++++ yaboli/connection.py | 34 +++++++++++++++++++++------------- yaboli/room.py | 7 +++++++ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index 005706c..4a0772b 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -77,6 +77,11 @@ class Bot(Inhabitant): if room: await room.exit() + # INHABITED FUNCTIONS + + async def on_stopped(self, room): + await self.part_room(room.roomname) + # BOTRULEZ @command("ping", specific=False, args=False) diff --git a/yaboli/connection.py b/yaboli/connection.py index c0bb174..0a83e9e 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -12,10 +12,11 @@ __all__ = ["Connection"] class Connection: - def __init__(self, url, packet_callback, disconnect_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10): + def __init__(self, url, packet_callback, disconnect_callback, stop_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10): self.url = url self.packet_callback = packet_callback self.disconnect_callback = disconnect_callback + self.stop_callback = stop_callback # is called when the connection stops on its own self.cookiejar = cookiejar self.ping_timeout = ping_timeout # how long to wait for websocket ping reply self.ping_delay = ping_delay # how long to wait between pings @@ -65,9 +66,10 @@ class Connection: This means that stop() can only be called once. """ - self._stopped = True - await self.reconnect() # _run() does the cleaning up now. - await self._runtask + if not self._stopped: + self._stopped = True + await self.reconnect() # _run() does the cleaning up now. + await self._runtask async def reconnect(self): """ @@ -153,16 +155,22 @@ class Connection: """ while not self._stopped: - await self._connect(self.reconnect_attempts) - logger.debug(f"{self.url}:Connected") + connected = await self._connect(self.reconnect_attempts) + if connected: + logger.debug(f"{self.url}:Connected") + try: + while True: + await self._handle_next_message() + except websockets.ConnectionClosed: + pass + finally: + await self._disconnect() # disconnect and clean up + else: + logger.debug(f"{self.url}:Stopping") + asyncio.ensure_future(self.stop_callback) + self._stopped = True + await self._disconnect() - try: - while True: - await self._handle_next_message() - except websockets.ConnectionClosed: - pass - finally: - await self._disconnect() # disconnect and clean up async def _ping(self): """ diff --git a/yaboli/room.py b/yaboli/room.py index 840ebda..76650cb 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -58,6 +58,7 @@ class Room: self.format_room_url(self.roomname, human=self.human), self._receive_packet, self._disconnected, + self._stopped, cookiejar ) @@ -172,6 +173,9 @@ class Room: await self._inhabitant.on_disconnected(self) + async def _stopped(self): + await self._inhabitant.on_stopped(self) + async def _receive_packet(self, ptype, data, error, throttled): # Ignoring errors and throttling for now functions = { @@ -410,6 +414,9 @@ class Inhabitant: async def on_disconnected(self, room): pass + async def on_stopped(self, room): + pass + async def on_join(self, room, session): pass From 78bb6b935fe26586d7b0605ef2dc93a99307c9f0 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 1 Aug 2018 21:44:46 +0000 Subject: [PATCH 050/145] Set some Connection parameters in join_room --- yaboli/bot.py | 4 ++-- yaboli/room.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index 4a0772b..7eec704 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -66,11 +66,11 @@ class Bot(Inhabitant): # ROOM MANAGEMENT - def join_room(self, roomname, password=None): + def join_room(self, roomname, **kwargs): if roomname in self.rooms: return - self.rooms[roomname] = Room(self, roomname, self.target_nick, password=password, cookiejar=self.cookiejar) + self.rooms[roomname] = Room(self, roomname, self.target_nick, cookiejar=self.cookiejar, **kwargs) async def part_room(self, roomname): room = self.rooms.pop(roomname, None) diff --git a/yaboli/room.py b/yaboli/room.py index 76650cb..b14b86a 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -21,7 +21,7 @@ class Room: CLOSED = 3 FORWARDING = 4 - def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None): + def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None, **kwargs): # TODO: Connect to room etc. # TODO: Deal with room/connection states of: # disconnected connecting, fast-forwarding, connected @@ -59,7 +59,8 @@ class Room: self._receive_packet, self._disconnected, self._stopped, - cookiejar + cookiejar, + **kwargs ) asyncio.ensure_future(self._inhabitant.on_created(self)) From 5c254f4e708a0f695bb508a4c79d866e57f109c3 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 2 Aug 2018 20:18:21 +0000 Subject: [PATCH 051/145] Use configparser in examplebot --- .gitignore | 1 - examplebot.conf | 9 +++++++++ examplebot.py | 26 +++++++++++++++++++------- join_rooms.py | 2 -- 4 files changed, 28 insertions(+), 10 deletions(-) create mode 100644 examplebot.conf delete mode 100644 join_rooms.py diff --git a/.gitignore b/.gitignore index 0fc3ef1..ce41371 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ **/__pycache__/ -*.db *.cookie diff --git a/examplebot.conf b/examplebot.conf new file mode 100644 index 0000000..a2386ee --- /dev/null +++ b/examplebot.conf @@ -0,0 +1,9 @@ +[general] +nick = ExampleBot +cookiefile = examplebot.cookie + +[rooms] +# Format: +# room +# room=password +test diff --git a/examplebot.py b/examplebot.py index 22536e3..1aad8eb 100644 --- a/examplebot.py +++ b/examplebot.py @@ -1,14 +1,15 @@ import asyncio +import configparser import logging import yaboli from yaboli.utils import * -from join_rooms import join_rooms # List of rooms kept in separate file, which is .gitignore'd # Turn all debugging on asyncio.get_event_loop().set_debug(True) -logging.getLogger("asyncio").setLevel(logging.INFO) -logging.getLogger("yaboli").setLevel(logging.DEBUG) +#logging.getLogger("asyncio").setLevel(logging.INFO) +#logging.getLogger("yaboli").setLevel(logging.DEBUG) +logging.basicConfig(level=logging.DEBUG) class ExampleBot(yaboli.Bot): @@ -28,10 +29,21 @@ class ExampleBot(yaboli.Bot): await self.botrulez_kill(room, message, text="/me dies spectacularly") await self.botrulez_restart(room, message, text="/me restarts spectacularly") -def main(): - bot = ExampleBot("ExampleBot", "examplebot.cookie") - join_rooms(bot) +def main(configfile): + config = configparser.ConfigParser(allow_no_value=True) + config.read(configfile) + + nick = config.get("general", "nick") + cookiefile = config.get("general", "cookiefile", fallback=None) + print(cookiefile) + bot = ExampleBot(nick, cookiefile=cookiefile) + + for room, password in config.items("rooms"): + if not password: + password = None + bot.join_room(room, password=password) + asyncio.get_event_loop().run_forever() if __name__ == "__main__": - main() + main("examplebot.conf") diff --git a/join_rooms.py b/join_rooms.py deleted file mode 100644 index cd3f3c4..0000000 --- a/join_rooms.py +++ /dev/null @@ -1,2 +0,0 @@ -def join_rooms(bot): - bot.join_room("test") From 1fee49d0e406f2e05d9896bf832b81cffaa46d25 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 3 Aug 2018 18:13:32 +0000 Subject: [PATCH 052/145] Fix various things - Fix some typos in Message and SessionView - Database operations can now access self - Fix mention (missing comma) and rename mention_reduce to normalize --- yaboli/database.py | 2 +- yaboli/utils.py | 27 +++++++++++++++------------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/yaboli/database.py b/yaboli/database.py index 36e72d4..e80d8cd 100644 --- a/yaboli/database.py +++ b/yaboli/database.py @@ -10,7 +10,7 @@ __all__ = ["Database", "operation"] def operation(func): async def wrapper(self, *args, **kwargs): async with self as db: - return await asyncify(func, db, *args, **kwargs) + return await asyncify(func, self, db, *args, **kwargs) return wrapper class Database: diff --git a/yaboli/utils.py b/yaboli/utils.py index d1f9ccb..ffdeea2 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -1,12 +1,14 @@ import asyncio import logging +import re import time import functools + logger = logging.getLogger(__name__) __all__ = [ "parallel", "asyncify", - "mention", "mention_reduced", "similar", + "mention", "normalize", "similar", "format_time", "format_time_delta", "Session", "Listing", "Message", ] @@ -19,14 +21,15 @@ async def asyncify(func, *args, **kwargs): func_with_args = functools.partial(func, *args, **kwargs) return await asyncio.get_event_loop().run_in_executor(None, func_with_args) -def mention(nick): - return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace()) +def mention(nick, ping=True): + nick = re.sub(r"""[,.!?;&<'"\s]""", "", nick) + return "@" + nick if ping else nick -def mention_reduced(nick): - return mention(nick).lower() +def normalize(nick): + return mention(nick, ping=False).lower() def similar(nick1, nick2): - return mention_reduced(nick1) == mention_reduced(nick2) + return normalize(nick1) == normalize(nick2) def format_time(timestamp): return time.strftime( @@ -65,7 +68,7 @@ def format_time_delta(delta): 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): + is_manager=None, client_address=None, real_client_address=None): self.user_id = user_id self.nick = nick self.server_id = server_id @@ -74,7 +77,7 @@ class Session: self.is_staff = is_staff self.is_manager = is_manager self.client_address = client_address - self.real_address = real_address + self.real_client_address = real_client_address @property def uid(self): @@ -103,7 +106,7 @@ class Session: d.get("is_staff", None), d.get("is_manager", None), d.get("client_address", None), - d.get("real_address", None) + d.get("real_client_address", None) ) @property @@ -172,14 +175,14 @@ class Listing: 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): + encryption_key_id=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.encryption_key_id = encryption_key_id self.edited = edited self.deleted = deleted self.truncated = truncated @@ -201,7 +204,7 @@ class Message(): d.get("content"), d.get("parent", None), d.get("previous_edit_id", None), - d.get("encryption_key", None), + d.get("encryption_key_id", None), d.get("edited", None), d.get("deleted", None), d.get("truncated", None) From 6b1348236de409cfc632a3d14984bbf41e620279 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 3 Aug 2018 19:47:11 +0000 Subject: [PATCH 053/145] Retry transaction until it works --- yaboli/database.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/yaboli/database.py b/yaboli/database.py index e80d8cd..7428be9 100644 --- a/yaboli/database.py +++ b/yaboli/database.py @@ -1,16 +1,23 @@ import asyncio +import logging import sqlite3 from .utils import * +logger = logging.getLogger(__name__) __all__ = ["Database", "operation"] def operation(func): async def wrapper(self, *args, **kwargs): async with self as db: - return await asyncify(func, self, db, *args, **kwargs) + while True: + try: + return await asyncify(func, self, db, *args, **kwargs) + except sqlite3.OperationalError as e: + logger.warn(f"Operational error encountered: {e}") + await asyncio.sleep(5) return wrapper class Database: From ac04e7fd30706b6d95ba475b4df59fc45a96afb3 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 4 Aug 2018 09:12:55 +0000 Subject: [PATCH 054/145] Notify inhabitant of edits --- yaboli/room.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/yaboli/room.py b/yaboli/room.py index b14b86a..a09c507 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -188,7 +188,7 @@ class Room: #"logout-event": self._event_logout, "network-event": self._event_network, "nick-event": self._event_nick, - #"edit-message-event": self._event_edit_message, + "edit-message-event": self._event_edit_message, "part-event": self._event_part, "ping-event": self._event_ping, "pm-initiate-event": self._event_pm_initiate, @@ -256,6 +256,10 @@ class Room: await self._inhabitant.on_nick(self, sid, uid, from_nick, to_nick) + async def _event_edit_message(self, data): + message = Message.from_dict(data) + await self._inhabitant.on_edit(self, message) + async def _event_part(self, data): session = Session.from_dict(data) self.listing.remove(session.sid) @@ -433,5 +437,8 @@ class Inhabitant: async def on_forward(self, room, message): await self.on_send(room, message) + async def on_edit(self, room, message): + pass + async def on_pm(self, room, from_uid, from_nick, from_room, pm_id): pass From aee21f359c549453f1cfc6503e01ea226385a222 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 4 Aug 2018 10:36:45 +0000 Subject: [PATCH 055/145] Restructure command parsing --- examplebot.py | 25 +++++++++++--------- yaboli/bot.py | 64 ++++++++++++++++++++++++++------------------------- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/examplebot.py b/examplebot.py index 1aad8eb..482a5ad 100644 --- a/examplebot.py +++ b/examplebot.py @@ -13,21 +13,25 @@ logging.basicConfig(level=logging.DEBUG) class ExampleBot(yaboli.Bot): - async def on_send(self, room, message): - ping = "ExamplePong!" - short_help = "Example bot for the yaboli bot library" + async def on_command_specific(self, room, message, command, nick, argstr): long_help = ( "I'm an example bot for the yaboli bot library," " which can be found at https://github.com/Garmelon/yaboli" ) - await self.botrulez_ping_general(room, message, text=ping) - await self.botrulez_ping_specific(room, message, text=ping) - await self.botrulez_help_general(room, message, text=short_help) - await self.botrulez_help_specific(room, message, text=long_help) - await self.botrulez_uptime(room, message) - await self.botrulez_kill(room, message, text="/me dies spectacularly") - await self.botrulez_restart(room, message, text="/me restarts spectacularly") + if similar(nick, room.session.nick) and not argstr: + await self.botrulez_ping(room, message, command, text="ExamplePong!") + await self.botrulez_help(room, message, command, text=long_help) + await self.botrulez_uptime(room, message, command) + await self.botrulez_kill(room, message, command) + await self.botrulez_restart(room, message, command) + + async def on_command_general(self, room, message, command, argstr): + short_help = "Example bot for the yaboli bot library" + + if not argstr: + await self.botrulez_ping(room, message, command, text="ExamplePong!") + await self.botrulez_help(room, message, command, text=short_help) def main(configfile): config = configparser.ConfigParser(allow_no_value=True) @@ -35,7 +39,6 @@ def main(configfile): nick = config.get("general", "nick") cookiefile = config.get("general", "cookiefile", fallback=None) - print(cookiefile) bot = ExampleBot(nick, cookiefile=cookiefile) for room, password in config.items("rooms"): diff --git a/yaboli/bot.py b/yaboli/bot.py index 7eec704..04cbb33 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -19,23 +19,14 @@ GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)") # Decorator magic for commands and triggers. # I think commands could probably be implemented as some kind of triggers, # but I'm not gonna do that now because commands are working fine this way. -def command(commandname, specific=True, args=True): +def command(*commands): def decorator(func): - async def wrapper(self, room, message, *args_, **kwargs_): - if specific: - result = self._parse_command(message.content, specific=room.session.nick) - else: - result = self._parse_command(message.content) - if result is None: return False - cmd, argstr = result - if cmd != commandname: return False - if args: - await func(self, room, message, argstr, *args_, **kwargs_) + async def wrapper(self, room, message, command, *args, **kwargs): + if command in commands: + await func(self, room, message, *args, **kwargs) return True else: - if argstr: return - await func(self, room, message, *args_, **kwargs_) - return True + return False return wrapper return decorator @@ -77,30 +68,41 @@ class Bot(Inhabitant): if room: await room.exit() + # COMMANDS + + async def on_command_specific(self, room, message, command, nick, argstr): + pass + + async def on_command_general(self, room, message, command, argstr): + pass + # INHABITED FUNCTIONS + async def on_send(self, room, message): + match = SPECIFIC_RE.fullmatch(message.content) + if match: + command, nick, argstr = match.groups() + await self.on_command_specific(room, message, command, nick, argstr) + + match = GENERAL_RE.fullmatch(message.content) + if match: + command, argstr = match.groups() + await self.on_command_general(room, message, command, argstr) + async def on_stopped(self, room): await self.part_room(room.roomname) # BOTRULEZ - @command("ping", specific=False, args=False) - async def botrulez_ping_general(self, room, message, text="Pong!"): + @command("ping") + async def botrulez_ping(self, room, message, text="Pong!"): await room.send(text, message.mid) - @command("ping", specific=True, args=False) - async def botrulez_ping_specific(self, room, message, text="Pong!"): + @command("help") + async def botrulez_help(self, room, message, text="Placeholder help text"): await room.send(text, message.mid) - @command("help", specific=False, args=False) - async def botrulez_help_general(self, room, message, text="Placeholder help text"): - await room.send(text, message.mid) - - @command("help", specific=True, args=False) - async def botrulez_help_specific(self, room, message, text="Placeholder help text"): - await room.send(text, message.mid) - - @command("uptime", specific=True, args=False) + @command("uptime") async def botrulez_uptime(self, room, message): now = time.time() startformat = format_time(room.start_time) @@ -108,12 +110,12 @@ class Bot(Inhabitant): text = f"/me has been up since {startformat} ({deltaformat})" await room.send(text, message.mid) - @command("kill", specific=True, args=False) + @command("kill") async def botrulez_kill(self, room, message, text="/me dies"): await room.send(text, message.mid) await self.part_room(room.roomname) - @command("restart", specific=True, args=False) + @command("restart") async def botrulez_restart(self, room, message, text="/me restarts"): await room.send(text, message.mid) await self.part_room(room.roomname) @@ -192,9 +194,9 @@ class Bot(Inhabitant): @staticmethod def _parse_command(content, specific=None): - if specific is not None: + if specific: match = SPECIFIC_RE.fullmatch(content) - if match and similar(match.group(2), specific): + if match: return match.group(1), match.group(3) else: match = GENERAL_RE.fullmatch(content) From 0881d25103b4ab225dd0fc3855f63a663c98aa06 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 5 Aug 2018 11:54:25 +0000 Subject: [PATCH 056/145] Fix joining rooms with passwords --- yaboli/room.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/yaboli/room.py b/yaboli/room.py index a09c507..9d2a1c5 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -204,9 +204,8 @@ class Room: logger.info(f"&{self.roomname}:Received bounce-event") if self.password is not None: try: - data = {"type": passcode, "passcode": self.password} - response = await self._connection.send("auth", data=data) - rdata = response.get("data") + data = {"type": "passcode", "passcode": self.password} + ptype, rdata, error, throttled = await self._connection.send("auth", data=data) success = rdata.get("success") if not success: reason = rdata.get("reason") From fa579ec2317df99f3aebeced4d920121f2a928bb Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 6 Aug 2018 14:05:28 +0000 Subject: [PATCH 057/145] Add license --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8c068df --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Garmelon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From ceaf8748e94a833eb5e928e818a487e973d68808 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 8 Aug 2018 16:55:59 +0000 Subject: [PATCH 058/145] Fix bots vanishing after trying to reconnect --- yaboli/connection.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 0a83e9e..eeae05e 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -79,7 +79,7 @@ class Connection: if self._ws: await self._ws.close() - async def _connect(self, tries): + async def _connect(self, tries, timeout=10): """ Attempt to connect to a room. If the Connection is already connected, it attempts to reconnect. @@ -97,10 +97,15 @@ class Connection: try: if self.cookiejar: cookies = [("Cookie", cookie) for cookie in self.cookiejar.sniff()] - self._ws = await websockets.connect(self.url, max_size=None, extra_headers=cookies) + ws = asyncio.ensure_future( + websockets.connect(self.url, max_size=None, extra_headers=cookies) + ) else: - self._ws = await websockets.connect(self.url, max_size=None) - except (websockets.InvalidHandshake, socket.gaierror): # not websockets.InvalidURI + ws = asyncio.ensure_future( + websockets.connect(self.url, max_size=None) + ) + self._ws = await asyncio.wait_for(ws, timeout) + except (websockets.InvalidHandshake, socket.gaierror, asyncio.TimeoutError): # not websockets.InvalidURI self._ws = None if tries is not None: From 6ed513efd936438973aaa187e4b7e420c227cb66 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 8 Aug 2018 17:01:27 +0000 Subject: [PATCH 059/145] Log failure to connect --- yaboli/connection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/yaboli/connection.py b/yaboli/connection.py index eeae05e..ebfe3cd 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -106,6 +106,7 @@ class Connection: ) self._ws = await asyncio.wait_for(ws, timeout) except (websockets.InvalidHandshake, socket.gaierror, asyncio.TimeoutError): # not websockets.InvalidURI + logger.warn(f"Connection attempt failed, {tries} tries left.") self._ws = None if tries is not None: From e60d0ba81efff6344d296e039a0e5a0ac772eb6b Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 13 Aug 2018 13:27:56 +0000 Subject: [PATCH 060/145] Add PersonalAccountView --- yaboli/utils.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/yaboli/utils.py b/yaboli/utils.py index ffdeea2..ba45af7 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -10,7 +10,7 @@ __all__ = [ "parallel", "asyncify", "mention", "normalize", "similar", "format_time", "format_time_delta", - "Session", "Listing", "Message", + "Session", "PersonalAccountView", "Listing", "Message", ] @@ -114,6 +114,20 @@ class Session: # account, agent or bot return self.user_id.split(":")[0] +class PersonalAccountView: + def __init__(self, account_id, name, email): + self.account_id = account_id + self.name = name + self.email = email + + @property + def aid(self): + return self.account_id + + @aid.setter + def aid(self, new_aid): + self.account_id = new_aid + class Listing: def __init__(self): self._sessions = {} From 85eb820fa6ff513afe6670475a0e068b12f9181a Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 13 Aug 2018 16:34:17 +0000 Subject: [PATCH 061/145] Keep trailing backslash in arg parsing --- yaboli/bot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/yaboli/bot.py b/yaboli/bot.py index 04cbb33..a57cb07 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -162,6 +162,9 @@ class Bot(Inhabitant): #if escape or quote: #return None # syntax error + if escape: + arg += "\\" + if len(arg) > 0: args.append(arg) From 2c1a1996a1c08f8b22a93a5b74c67a30505f5e2a Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 19 Aug 2018 20:24:54 +0000 Subject: [PATCH 062/145] Remove debugging logging --- examplebot.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/examplebot.py b/examplebot.py index 482a5ad..ba9bdb8 100644 --- a/examplebot.py +++ b/examplebot.py @@ -5,12 +5,6 @@ import logging import yaboli from yaboli.utils import * -# Turn all debugging on -asyncio.get_event_loop().set_debug(True) -#logging.getLogger("asyncio").setLevel(logging.INFO) -#logging.getLogger("yaboli").setLevel(logging.DEBUG) -logging.basicConfig(level=logging.DEBUG) - class ExampleBot(yaboli.Bot): async def on_command_specific(self, room, message, command, nick, argstr): @@ -34,6 +28,8 @@ class ExampleBot(yaboli.Bot): await self.botrulez_help(room, message, command, text=short_help) def main(configfile): + logging.basicConfig(level=logging.INFO) + config = configparser.ConfigParser(allow_no_value=True) config.read(configfile) From 5e108fd31b4883f159878c2a5d49965d82b14701 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 12 Sep 2018 21:17:36 +0000 Subject: [PATCH 063/145] Add Module and ModuleBot --- yaboli/bot.py | 66 +++++++++++++++++++++++++++++++++++++++++++- yaboli/connection.py | 2 ++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index a57cb07..666bb9e 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -8,7 +8,7 @@ from .utils import * logger = logging.getLogger(__name__) -__all__ = ["Bot", "command", "trigger"] +__all__ = ["Bot", "command", "trigger", "Module", "ModuleBot"] # Some command stuff @@ -205,3 +205,67 @@ class Bot(Inhabitant): match = GENERAL_RE.fullmatch(content) if match: return match.group(1), match.group(2) + +class Module(Inhabitant): + SHORT_DESCRIPTION = "short module description" + LONG_DESCRIPTION = "long module description" + SHORT_HELP = "short !help" + LONG_HELP = "long !help" + + async def on_command_specific(self, room, message, command, nick, argstr, mentioned): + pass + + async def on_command_general(self, room, message, command, argstr): + pass + +class ModuleBot(Bot): + def __init__(self, module, nick, *args, cookiefile=None, **kwargs): + super().__init__(nick, cookiefile=cookiefile) + self.module = module + + async def on_created(self, room): + await self.module.on_created(room) + + async def on_connected(self, room, log): + await self.module.on_connected(room, log) + + async def on_disconnected(self, room): + await self.module.on_disconnected(room) + + async def on_stopped(self, room): + await self.module.on_stopped(room) + + async def on_join(self, room, session): + await self.module.on_join(room, session) + + async def on_part(self, room, session): + await self.module.on_part(room, session) + + async def on_nick(self, room, sid, uid, from_nick, to_nick): + await self.module.on_nick(room, sid, uid, from_nick, to_nick) + + async def on_send(self, room, message): + await super().on_send(room, message) + + await self.module.on_send(room, message) + + async def on_command_specific(self, room, message, command, nick, argstr): + if similar(nick, room.session.nick): + await self.module.on_command_specific(room, message, command, nick, argstr, True) + + if not argstr: + await self.botrulez_ping(room, message, command) + await self.botrulez_help(room, message, command, text=self.module.LONG_HELP) + await self.botrulez_uptime(room, message, command) + await self.botrulez_kill(room, message, command) + await self.botrulez_restart(room, message, command) + + else: + await self.module.on_command_specific(room, message, command, nick, argstr, False) + + async def on_command_general(self, room, message, command, argstr): + await self.module.on_command_general(room, message, command, argstr) + + if not argstr: + await self.botrulez_ping(room, message, command) + await self.botrulez_help(room, message, command, text=self.module.SHORT_HELP) diff --git a/yaboli/connection.py b/yaboli/connection.py index ebfe3cd..070503f 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -112,6 +112,7 @@ class Connection: if tries is not None: tries -= 1 if tries <= 0: + logger.warn(f"{self.url}:Ran out of tries") return False await asyncio.sleep(delay) @@ -161,6 +162,7 @@ class Connection: """ while not self._stopped: + logger.debug(f"{self.url}:Connecting...") connected = await self._connect(self.reconnect_attempts) if connected: logger.debug(f"{self.url}:Connected") From a5af01f669ea627cf4998b69b0e53a59260edc70 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Apr 2019 09:02:09 +0000 Subject: [PATCH 064/145] Start rewrite (yet again) This will hopefully be the final rewrite. --- .gitignore | 14 +- example.py | 26 +++ examplebot.conf | 9 - examplebot.py | 48 ---- info.txt | 22 ++ mypy.ini | 3 + requirements.txt | 1 + yaboli/__init__.py | 36 +-- yaboli/bot.py | 271 ----------------------- yaboli/client.py | 23 ++ yaboli/connection.py | 229 ------------------- yaboli/cookiejar.py | 74 ------- yaboli/database.py | 38 ---- yaboli/exceptions.py | 56 ++++- yaboli/message.py | 108 +++++++++ yaboli/room.py | 510 ++++++++----------------------------------- yaboli/user.py | 91 ++++++++ yaboli/util.py | 15 ++ yaboli/utils.py | 225 ------------------- 19 files changed, 455 insertions(+), 1344 deletions(-) create mode 100644 example.py delete mode 100644 examplebot.conf delete mode 100644 examplebot.py create mode 100644 info.txt create mode 100644 mypy.ini create mode 100644 requirements.txt delete mode 100644 yaboli/bot.py create mode 100644 yaboli/client.py delete mode 100644 yaboli/connection.py delete mode 100644 yaboli/cookiejar.py delete mode 100644 yaboli/database.py create mode 100644 yaboli/message.py create mode 100644 yaboli/user.py create mode 100644 yaboli/util.py delete mode 100644 yaboli/utils.py diff --git a/.gitignore b/.gitignore index ce41371..bf7ff1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ -**/__pycache__/ -*.cookie +# python stuff +*/__pycache__/ + +# venv stuff +bin/ +include/ +lib/ +lib64 +pyvenv.cfg + +# mypy stuff +.mypy_cache/ diff --git a/example.py b/example.py new file mode 100644 index 0000000..97aff03 --- /dev/null +++ b/example.py @@ -0,0 +1,26 @@ +import yyb + +class MyClient(yyb.Client): + async def on_join(self, room): + await room.say("Hello!") + + async def on_message(self, message): + if message.content == "reply to me"): + reply = await message.reply("reply") + await reply.reply("reply to the reply") + await message.room.say("stuff going on") + + elif message.content == "hey, join &test!": + # returns room in phase 3, or throws JoinException + room = await self.join("test") + if room: + room.say("hey, I joined!") + else: + message.reply("didn't work :(") + + async def before_part(self, room): + await room.say("Goodbye!") + +# Something like this, I guess. It's still missing password fields though. +c = MyClient("my:bot:") +c.run("test", "bots") diff --git a/examplebot.conf b/examplebot.conf deleted file mode 100644 index a2386ee..0000000 --- a/examplebot.conf +++ /dev/null @@ -1,9 +0,0 @@ -[general] -nick = ExampleBot -cookiefile = examplebot.cookie - -[rooms] -# Format: -# room -# room=password -test diff --git a/examplebot.py b/examplebot.py deleted file mode 100644 index ba9bdb8..0000000 --- a/examplebot.py +++ /dev/null @@ -1,48 +0,0 @@ -import asyncio -import configparser -import logging - -import yaboli -from yaboli.utils import * - - -class ExampleBot(yaboli.Bot): - async def on_command_specific(self, room, message, command, nick, argstr): - long_help = ( - "I'm an example bot for the yaboli bot library," - " which can be found at https://github.com/Garmelon/yaboli" - ) - - if similar(nick, room.session.nick) and not argstr: - await self.botrulez_ping(room, message, command, text="ExamplePong!") - await self.botrulez_help(room, message, command, text=long_help) - await self.botrulez_uptime(room, message, command) - await self.botrulez_kill(room, message, command) - await self.botrulez_restart(room, message, command) - - async def on_command_general(self, room, message, command, argstr): - short_help = "Example bot for the yaboli bot library" - - if not argstr: - await self.botrulez_ping(room, message, command, text="ExamplePong!") - await self.botrulez_help(room, message, command, text=short_help) - -def main(configfile): - logging.basicConfig(level=logging.INFO) - - config = configparser.ConfigParser(allow_no_value=True) - config.read(configfile) - - nick = config.get("general", "nick") - cookiefile = config.get("general", "cookiefile", fallback=None) - bot = ExampleBot(nick, cookiefile=cookiefile) - - for room, password in config.items("rooms"): - if not password: - password = None - bot.join_room(room, password=password) - - asyncio.get_event_loop().run_forever() - -if __name__ == "__main__": - main("examplebot.conf") diff --git a/info.txt b/info.txt new file mode 100644 index 0000000..a17672c --- /dev/null +++ b/info.txt @@ -0,0 +1,22 @@ +Signature of a normal function: + +def a(b: int, c: str) -> bool: + pass + +a # type: Callable[[int, str], bool] + +Signature of an async function: + +async def a(b: int, c: str) -> bool: + pass + +a # type: Callable[[int, str], Awaitable[bool]] + + + +Enable logging (from the websockets docs): + +import logging +logger = logging.getLogger('websockets') +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..e91e90c --- /dev/null +++ b/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +disallow_untyped_defs = True +disallow_incomplete_defs = True diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4789da4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +websockets==7.0 diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 89eccb2..4f04690 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,17 +1,21 @@ -from .bot import * -from .cookiejar import * -from .connection import * -from .database import * -from .exceptions import * -from .room import * -from .utils import * +from typing import List -__all__ = ( - bot.__all__ + - connection.__all__ + - cookiejar.__all__ + - database.__all__ + - exceptions.__all__ + - room.__all__ + - utils.__all__ -) +__all__: List[str] = [] + +from .client import * +__all__ += client.__all__ + +from .exceptions import * +__all__ += client.__all__ + +from .message import * +__all__ += exceptions.__all__ + +from .room import * +__all__ += message.__all__ + +__all__ += room.__all__ +from .user import * + +__all__ += user.__all__ +from .util import * diff --git a/yaboli/bot.py b/yaboli/bot.py deleted file mode 100644 index 666bb9e..0000000 --- a/yaboli/bot.py +++ /dev/null @@ -1,271 +0,0 @@ -import logging -import re -import time - -from .cookiejar import * -from .room import * -from .utils import * - - -logger = logging.getLogger(__name__) -__all__ = ["Bot", "command", "trigger", "Module", "ModuleBot"] - - -# Some command stuff - -SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)") -GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)") - -# Decorator magic for commands and triggers. -# I think commands could probably be implemented as some kind of triggers, -# but I'm not gonna do that now because commands are working fine this way. -def command(*commands): - def decorator(func): - async def wrapper(self, room, message, command, *args, **kwargs): - if command in commands: - await func(self, room, message, *args, **kwargs) - return True - else: - return False - return wrapper - return decorator - -def trigger(regex, fullmatch=True, flags=0): - def decorator(func): - compiled_regex = re.compile(regex, flags=flags) - async def wrapper(self, room, message, *args, **kwargs): - if fullmatch: - match = compiled_regex.fullmatch(message.content) - else: - match = compiled_regex.match(message.content) - if match is not None: - await func(self, room, message, match, *args, **kwargs) - return True - else: - return False - return wrapper - return decorator - - -# And now comes the real bot... - -class Bot(Inhabitant): - def __init__(self, nick, cookiefile=None): - self.target_nick = nick - self.rooms = {} - self.cookiejar = CookieJar(cookiefile) - - # ROOM MANAGEMENT - - def join_room(self, roomname, **kwargs): - if roomname in self.rooms: - return - - self.rooms[roomname] = Room(self, roomname, self.target_nick, cookiejar=self.cookiejar, **kwargs) - - async def part_room(self, roomname): - room = self.rooms.pop(roomname, None) - if room: - await room.exit() - - # COMMANDS - - async def on_command_specific(self, room, message, command, nick, argstr): - pass - - async def on_command_general(self, room, message, command, argstr): - pass - - # INHABITED FUNCTIONS - - async def on_send(self, room, message): - match = SPECIFIC_RE.fullmatch(message.content) - if match: - command, nick, argstr = match.groups() - await self.on_command_specific(room, message, command, nick, argstr) - - match = GENERAL_RE.fullmatch(message.content) - if match: - command, argstr = match.groups() - await self.on_command_general(room, message, command, argstr) - - async def on_stopped(self, room): - await self.part_room(room.roomname) - - # BOTRULEZ - - @command("ping") - async def botrulez_ping(self, room, message, text="Pong!"): - await room.send(text, message.mid) - - @command("help") - async def botrulez_help(self, room, message, text="Placeholder help text"): - await room.send(text, message.mid) - - @command("uptime") - async def botrulez_uptime(self, room, message): - now = time.time() - startformat = format_time(room.start_time) - deltaformat = format_time_delta(now - room.start_time) - text = f"/me has been up since {startformat} ({deltaformat})" - await room.send(text, message.mid) - - @command("kill") - async def botrulez_kill(self, room, message, text="/me dies"): - await room.send(text, message.mid) - await self.part_room(room.roomname) - - @command("restart") - async def botrulez_restart(self, room, message, text="/me restarts"): - await room.send(text, message.mid) - await self.part_room(room.roomname) - self.join_room(room.roomname, password=room.password) - - # COMMAND PARSING - - @staticmethod - def parse_args(text): - """ - Use bash-style single- and double-quotes 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(): - if len(arg) > 0: - args.append(arg) - arg = "" - else: - arg += character - - #if escape or quote: - #return None # syntax error - - if escape: - arg += "\\" - - if len(arg) > 0: - args.append(arg) - - return args - - @staticmethod - def parse_flags(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 - - @staticmethod - def _parse_command(content, specific=None): - if specific: - match = SPECIFIC_RE.fullmatch(content) - if match: - return match.group(1), match.group(3) - else: - match = GENERAL_RE.fullmatch(content) - if match: - return match.group(1), match.group(2) - -class Module(Inhabitant): - SHORT_DESCRIPTION = "short module description" - LONG_DESCRIPTION = "long module description" - SHORT_HELP = "short !help" - LONG_HELP = "long !help" - - async def on_command_specific(self, room, message, command, nick, argstr, mentioned): - pass - - async def on_command_general(self, room, message, command, argstr): - pass - -class ModuleBot(Bot): - def __init__(self, module, nick, *args, cookiefile=None, **kwargs): - super().__init__(nick, cookiefile=cookiefile) - self.module = module - - async def on_created(self, room): - await self.module.on_created(room) - - async def on_connected(self, room, log): - await self.module.on_connected(room, log) - - async def on_disconnected(self, room): - await self.module.on_disconnected(room) - - async def on_stopped(self, room): - await self.module.on_stopped(room) - - async def on_join(self, room, session): - await self.module.on_join(room, session) - - async def on_part(self, room, session): - await self.module.on_part(room, session) - - async def on_nick(self, room, sid, uid, from_nick, to_nick): - await self.module.on_nick(room, sid, uid, from_nick, to_nick) - - async def on_send(self, room, message): - await super().on_send(room, message) - - await self.module.on_send(room, message) - - async def on_command_specific(self, room, message, command, nick, argstr): - if similar(nick, room.session.nick): - await self.module.on_command_specific(room, message, command, nick, argstr, True) - - if not argstr: - await self.botrulez_ping(room, message, command) - await self.botrulez_help(room, message, command, text=self.module.LONG_HELP) - await self.botrulez_uptime(room, message, command) - await self.botrulez_kill(room, message, command) - await self.botrulez_restart(room, message, command) - - else: - await self.module.on_command_specific(room, message, command, nick, argstr, False) - - async def on_command_general(self, room, message, command, argstr): - await self.module.on_command_general(room, message, command, argstr) - - if not argstr: - await self.botrulez_ping(room, message, command) - await self.botrulez_help(room, message, command, text=self.module.SHORT_HELP) diff --git a/yaboli/client.py b/yaboli/client.py new file mode 100644 index 0000000..ee868cf --- /dev/null +++ b/yaboli/client.py @@ -0,0 +1,23 @@ +from .message import Message +from .room import Room +from .user import User + +from typing import List, Optional + +__all__ = ["Client"] + +class Client: + + # Joining and leaving rooms + + async def join(self, + room_name: str, + password: str = None, + nick: str = None) -> Room: + pass + + async def get(self, room_name: str) -> Optional[Room]: + pass + + async def get_all(self, room_name: str) -> List[Room]: + pass diff --git a/yaboli/connection.py b/yaboli/connection.py deleted file mode 100644 index 070503f..0000000 --- a/yaboli/connection.py +++ /dev/null @@ -1,229 +0,0 @@ -import asyncio -import json -import logging -import socket -import websockets - -from .exceptions import * - - -logger = logging.getLogger(__name__) -__all__ = ["Connection"] - - -class Connection: - def __init__(self, url, packet_callback, disconnect_callback, stop_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10): - self.url = url - self.packet_callback = packet_callback - self.disconnect_callback = disconnect_callback - self.stop_callback = stop_callback # is called when the connection stops on its own - self.cookiejar = cookiejar - self.ping_timeout = ping_timeout # how long to wait for websocket ping reply - self.ping_delay = ping_delay # how long to wait between pings - self.reconnect_attempts = reconnect_attempts - - self._ws = None - self._pid = 0 # successive packet ids - #self._spawned_tasks = set() - self._pending_responses = {} - - self._stopped = False - self._pingtask = None - self._runtask = asyncio.ensure_future(self._run()) - # ... aaand the connection is started. - - async def send(self, ptype, data=None, await_response=True): - if not self._ws: - raise ConnectionClosed - #raise asyncio.CancelledError - - pid = str(self._new_pid()) - packet = { - "type": ptype, - "id": pid - } - if data: - packet["data"] = data - - if await_response: - wait_for = self._wait_for_response(pid) - - logging.debug(f"Currently used websocket at self._ws: {self._ws}") - try: - await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size - except websockets.ConnectionClosed: - raise ConnectionClosed() - - if await_response: - await wait_for - return wait_for.result() - - async def stop(self): - """ - Close websocket connection and wait for running task to stop. - - No connection function are to be called after calling stop(). - This means that stop() can only be called once. - """ - - if not self._stopped: - self._stopped = True - await self.reconnect() # _run() does the cleaning up now. - await self._runtask - - async def reconnect(self): - """ - Reconnect to the url. - """ - - if self._ws: - await self._ws.close() - - async def _connect(self, tries, timeout=10): - """ - Attempt to connect to a room. - If the Connection is already connected, it attempts to reconnect. - - Returns True on success, False on failure. - - If tries is None, connect retries infinitely. - The delay between connection attempts doubles every attempt (starts with 1s). - """ - - # Assumes _disconnect() has already been called in _run() - - delay = 1 # seconds - while True: - try: - if self.cookiejar: - cookies = [("Cookie", cookie) for cookie in self.cookiejar.sniff()] - ws = asyncio.ensure_future( - websockets.connect(self.url, max_size=None, extra_headers=cookies) - ) - else: - ws = asyncio.ensure_future( - websockets.connect(self.url, max_size=None) - ) - self._ws = await asyncio.wait_for(ws, timeout) - except (websockets.InvalidHandshake, socket.gaierror, asyncio.TimeoutError): # not websockets.InvalidURI - logger.warn(f"Connection attempt failed, {tries} tries left.") - self._ws = None - - if tries is not None: - tries -= 1 - if tries <= 0: - logger.warn(f"{self.url}:Ran out of tries") - return False - - await asyncio.sleep(delay) - delay *= 2 - else: - if self.cookiejar: - for set_cookie in self._ws.response_headers.get_all("Set-Cookie"): - self.cookiejar.bake(set_cookie) - self.cookiejar.save() - - self._pingtask = asyncio.ensure_future(self._ping()) - - return True - - async def _disconnect(self): - """ - Disconnect and clean up all "residue", such as: - - close existing websocket connection - - cancel all pending response futures with a ConnectionClosed exception - - reset package ID counter - - make sure the ping task has finished - """ - - asyncio.ensure_future(self.disconnect_callback()) - - # stop ping task - if self._pingtask: - self._pingtask.cancel() - await self._pingtask - self._pingtask = None - - if self._ws: - await self._ws.close() - self._ws = None - - self._pid = 0 - - # clean up pending response futures - for _, future in self._pending_responses.items(): - logger.debug(f"Cancelling future with ConnectionClosed: {future}") - future.set_exception(ConnectionClosed("No server response")) - self._pending_responses = {} - - async def _run(self): - """ - Listen for packets and deal with them accordingly. - """ - - while not self._stopped: - logger.debug(f"{self.url}:Connecting...") - connected = await self._connect(self.reconnect_attempts) - if connected: - logger.debug(f"{self.url}:Connected") - try: - while True: - await self._handle_next_message() - except websockets.ConnectionClosed: - pass - finally: - await self._disconnect() # disconnect and clean up - else: - logger.debug(f"{self.url}:Stopping") - asyncio.ensure_future(self.stop_callback) - self._stopped = True - await self._disconnect() - - - async def _ping(self): - """ - Periodically ping the server to detect a timeout. - """ - - try: - while True: - logger.debug(f"{self.url}:Pinging...") - wait_for_reply = await self._ws.ping() - await asyncio.wait_for(wait_for_reply, self.ping_timeout) - logger.debug(f"{self.url}:Pinged!") - await asyncio.sleep(self.ping_delay) - except asyncio.TimeoutError: - logger.warning(f"{self.url}:Ping timed out") - await self.reconnect() - except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): - pass - - def _new_pid(self): - self._pid += 1 - return self._pid - - async def _handle_next_message(self): - response = await self._ws.recv() - packet = json.loads(response) - - ptype = packet.get("type") - data = packet.get("data", None) - error = packet.get("error", None) - if packet.get("throttled", False): - throttled = packet.get("throttled_reason") - else: - throttled = None - - # Deal with pending responses - pid = packet.get("id", None) - future = self._pending_responses.pop(pid, None) - if future: - future.set_result((ptype, data, error, throttled)) - - # Pass packet onto room - asyncio.ensure_future(self.packet_callback(ptype, data, error, throttled)) - - def _wait_for_response(self, pid): - future = asyncio.Future() - self._pending_responses[pid] = future - return future diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py deleted file mode 100644 index 8b3a073..0000000 --- a/yaboli/cookiejar.py +++ /dev/null @@ -1,74 +0,0 @@ -import contextlib -import http.cookies as cookies -import logging - - -logger = logging.getLogger(__name__) -__all__ = ["CookieJar"] - - -class CookieJar: - """ - Keeps your cookies in a file. - """ - - def __init__(self, filename=None): - self._filename = filename - self._cookies = cookies.SimpleCookie() - - if not self._filename: - logger.warning("Could not load cookies, no filename given.") - return - - with contextlib.suppress(FileNotFoundError): - logger.info(f"Loading cookies from {self._filename!r}") - with open(self._filename, "r") as f: - for line in f: - self._cookies.load(line) - - def sniff(self): - """ - Returns a list of Cookie headers containing all current cookies. - """ - - return [morsel.OutputString(attrs=[]) for morsel in self._cookies.values()] - - def bake(self, cookie_string): - """ - Parse cookie and add it to the jar. - Does not automatically save to the cookie file. - - Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; HttpOnly; Secure" - """ - - logger.debug(f"Baking cookie: {cookie_string!r}") - - self._cookies.load(cookie_string) - - def save(self): - """ - Saves all current cookies to the cookie jar file. - """ - - if not self._filename: - logger.warning("Could not save cookies, no filename given.") - return - - logger.info(f"Saving cookies to {self._filename!r}") - - with open(self._filename, "w") as f: - for morsel in self._cookies.values(): - cookie_string = morsel.OutputString() - #f.write(f"{cookie_string}\n") - f.write(cookie_string) - f.write("\n") - - def monster(self): - """ - Removes all cookies from the cookie jar. - Does not automatically save to the cookie file. - """ - - logger.debug("OMNOMNOM, cookies are all gone!") - - self._cookies = cookies.SimpleCookie() diff --git a/yaboli/database.py b/yaboli/database.py deleted file mode 100644 index 7428be9..0000000 --- a/yaboli/database.py +++ /dev/null @@ -1,38 +0,0 @@ -import asyncio -import logging -import sqlite3 - -from .utils import * - - -logger = logging.getLogger(__name__) -__all__ = ["Database", "operation"] - - -def operation(func): - async def wrapper(self, *args, **kwargs): - async with self as db: - while True: - try: - return await asyncify(func, self, db, *args, **kwargs) - except sqlite3.OperationalError as e: - logger.warn(f"Operational error encountered: {e}") - await asyncio.sleep(5) - return wrapper - -class Database: - def __init__(self, database): - self._connection = sqlite3.connect(database, check_same_thread=False) - self._lock = asyncio.Lock() - - self.initialize(self._connection) - - def initialize(self, db): - pass - - async def __aenter__(self, *args, **kwargs): - await self._lock.__aenter__(*args, **kwargs) - return self._connection - - async def __aexit__(self, *args, **kwargs): - return await self._lock.__aexit__(*args, **kwargs) diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py index f9cce45..2c951a0 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -1,13 +1,51 @@ -__all__ = ["ConnectionClosed"] +__all__ = ["EuphException", "JoinException", "CouldNotConnectException", + "CouldNotAuthenticateException", "RoomClosedException", + "RateLimitException", "NotLoggedInException", "UnauthorizedException"] -class ConnectionClosed(Exception): - pass +class EuphException(Exception): + pass -class RoomException(Exception): - pass +# Joining a room -class AuthenticationRequired(RoomException): - pass +class JoinException(EuphException): + """ + An exception that happened while joining a room. + """ + pass -class RoomClosed(RoomException): - pass +class CouldNotConnectException(JoinException): + """ + Could not establish a websocket connection to euphoria. + """ + pass + +class CouldNotAuthenticateException(JoinException): + """ + The password is either incorrect or not set, even though authentication is + required. + """ + pass + +# Doing stuff in a room + +class RoomClosedException(EuphException): + """ + The room has been closed already. + + This means that phase 4 (see the docstring of Room) has been initiated or + completed. + """ + pass + +# exception for having no username? + +# Maybe these will become real exceptions one day? + +class RateLimitException(EuphException): + pass + +class NotLoggedInException(EuphException): + pass + +class UnauthorizedException(EuphException): + pass diff --git a/yaboli/message.py b/yaboli/message.py new file mode 100644 index 0000000..088cbe7 --- /dev/null +++ b/yaboli/message.py @@ -0,0 +1,108 @@ +from .user import User, LiveUser + +from typing import TYPE_CHECKING, Optional +import datetime + +if TYPE_CHECKING: + from .client import Client + from .room import Room + +__all__ = ["Message", "LiveMessage"] + +# "Offline" message +class Message: + def __init__(self, + room_name: str, + id_: str, + parent_id: Optional[str], + timestamp: int, + sender: User, + content: str, + deleted: bool, + truncated: bool): + self._room_name = room_name + self._id = id_ + self._parent_id = parent_id + self._timestamp = timestamp + self._sender = sender + self._content = content + self._deleted = deleted + self._truncated = truncated + + @property + def room_name(self) -> str: + return self._room_name + + @property + def id(self) -> str: + return self._id + + @property + def parent_id(self) -> Optional[str]: + return self._parent_id + + @property + def time(self) -> datetime.datetime: + return datetime.datetime.fromtimestamp(self.timestamp) + + @property + def timestamp(self) -> int: + return self._timestamp + + @property + def sender(self) -> User: + return self._sender + + @property + def content(self) -> str: + return self._content + + @property + def deleted(self) -> bool: + return self._deleted + + @property + def truncated(self) -> bool: + return self._truncated + +# "Online" message +# has a few nice functions +class LiveMessage(Message): + def __init__(self, + client: 'Client', + room: 'Room', + id_: str, + parent_id: Optional[str], + timestamp: int, + sender: LiveUser, + content: str, + deleted: bool, + truncated: bool): + self._client = client + super().__init__(room.name, id_, parent_id, timestamp, sender, content, + deleted, truncated) + self._room = room + # The typechecker can't use self._sender directly, because it has type + # User. + # + # TODO Find a way to satisfy the type checker without having this + # duplicate around, if possible? + self._livesender = sender + + @property + def room(self) -> 'Room': + return self._room + + @property + def sender(self) -> LiveUser: + return self._livesender + + async def reply(self, text: str) -> None: + pass + + # TODO add some sort of permission guard that checks the room + # UnauthorizedException + async def delete(self, + deleted: bool = True + ) -> None: + pass diff --git a/yaboli/room.py b/yaboli/room.py index 9d2a1c5..5d36c54 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,443 +1,107 @@ -import asyncio -import logging -import time - -from .connection import * from .exceptions import * -from .utils import * +from .message import LiveMessage +from .user import LiveUser +from typing import List, Optional -logger = logging.getLogger(__name__) -__all__ = ["Room", "Inhabitant"] - +__all__ = ["Room"] class Room: - """ - TODO - """ - - CONNECTED = 1 - DISCONNECTED = 2 - CLOSED = 3 - FORWARDING = 4 - - def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None, **kwargs): - # TODO: Connect to room etc. - # TODO: Deal with room/connection states of: - # disconnected connecting, fast-forwarding, connected - - # Room info (all fields readonly!) - self.target_nick = nick - self.roomname = roomname - self.password = password - self.human = human - - self.session = None - self.account = None - self.listing = Listing() - - self.start_time = time.time() - - self.account_has_access = None - self.account_email_verified = None - self.room_is_private = None - self.version = None # the version of the code being run and served by the server - self.pm_with_nick = None - self.pm_with_user_id = None - - self._inhabitant = inhabitant - self._status = Room.DISCONNECTED - self._connected_future = asyncio.Future() - - self._last_known_mid = None - self._forwarding = None # task that downloads messages and fowards - self._forward_new = [] # new messages received while downloading old messages - - # TODO: Allow for all parameters of Connection() to be specified in Room(). - self._connection = Connection( - self.format_room_url(self.roomname, human=self.human), - self._receive_packet, - self._disconnected, - self._stopped, - cookiejar, - **kwargs - ) - - asyncio.ensure_future(self._inhabitant.on_created(self)) - - async def exit(self): - self._status = Room.CLOSED - await self._connection.stop() - -# ROOM COMMANDS -# These always return a response from the server. -# If the connection is lost while one of these commands is called, -# the command will retry once the bot has reconnected. - - async def get_message(self, mid): - if self._status == Room.CLOSED: - raise RoomClosed() - - ptype, data, error, throttled = await self._send_while_connected( - "get-message", - id=mid - ) - - if data: - return Message.from_dict(data) - # else: message does not exist - - # The log returned is sorted from old to new - async def log(self, n, before=None): - if self._status == Room.CLOSED: - raise RoomClosed() - - if before: - ptype, data, error, throttled = await self._send_while_connected( - "log", - n=n, - before=before - ) - else: - ptype, data, error, throttled = await self._send_while_connected( - "log", - n=n - ) - - return [Message.from_dict(d) for d in data.get("log")] - - async def nick(self, nick): - if self._status == Room.CLOSED: - raise RoomClosed() - - self.target_nick = nick - ptype, data, error, throttled = await self._send_while_connected( - "nick", - name=nick - ) - - sid = data.get("session_id") - uid = data.get("id") - from_nick = data.get("from") - to_nick = data.get("to") - - self.session.nick = to_nick - return sid, uid, from_nick, to_nick - - async def pm(self, uid): - if self._status == Room.CLOSED: - raise RoomClosed() - - ptype, data, error, throttled = await self._send_while_connected( - "pm-initiate", - user_id=uid - ) - - # Just ignoring non-authenticated errors - pm_id = data.get("pm_id") - to_nick = data.get("to_nick") - return pm_id, to_nick - - async def send(self, content, parent=None): - if parent: - ptype, data, error, throttled = await self._send_while_connected( - "send", - content=content, - parent=parent - ) - else: - ptype, data, error, throttled = await self._send_while_connected( - "send", - content=content - ) - - message = Message.from_dict(data) - self._last_known_mid = message.mid - return message - - async def who(self): - ptype, data, error, throttled = await self._send_while_connected("who") - self.listing = Listing.from_dict(data.get("listing")) - self.listing.add(self.session) - -# COMMUNICATION WITH CONNECTION - - async def _disconnected(self): - # While disconnected, keep the last known session info, listing etc. - # All of this is instead reset when the hello/snapshot events are received. - logger.warn(f"&{self.roomname}:Lost connection.") - self.status = Room.DISCONNECTED - self._connected_future = asyncio.Future() - - if self._forwarding is not None: - self._forwarding.cancel() - - await self._inhabitant.on_disconnected(self) - - async def _stopped(self): - await self._inhabitant.on_stopped(self) - - async def _receive_packet(self, ptype, data, error, throttled): - # Ignoring errors and throttling for now - functions = { - "bounce-event": self._event_bounce, - #"disconnect-event": self._event_disconnect, # Not important, can ignore - "hello-event": self._event_hello, - "join-event": self._event_join, - #"login-event": self._event_login, - #"logout-event": self._event_logout, - "network-event": self._event_network, - "nick-event": self._event_nick, - "edit-message-event": self._event_edit_message, - "part-event": self._event_part, - "ping-event": self._event_ping, - "pm-initiate-event": self._event_pm_initiate, - "send-event": self._event_send, - "snapshot-event": self._event_snapshot, - } - - function = functions.get(ptype) - if function: - await function(data) - - async def _event_bounce(self, data): - logger.info(f"&{self.roomname}:Received bounce-event") - if self.password is not None: - try: - data = {"type": "passcode", "passcode": self.password} - ptype, rdata, error, throttled = await self._connection.send("auth", data=data) - success = rdata.get("success") - if not success: - reason = rdata.get("reason") - logger.warn(f"&{self.roomname}:Authentication failed: {reason}") - raise AuthenticationRequired(f"Could not join &{self.roomname}:{reason}") - else: - logger.info(f"&{self.roomname}:Authentication successful") - except ConnectionClosed: - pass - else: - logger.warn(f"&{self.roomname}:Could not authenticate: Password unknown") - raise AuthenticationRequired(f"&{self.roomname} is password locked but no password was given") - - async def _event_hello(self, data): - self.session = Session.from_dict(data.get("session")) - self.room_is_private = data.get("room_is_private") - self.version = data.get("version") - self.account = data.get("account", None) - self.account_has_access = data.get("account_has_access", None) - self.account_email_verified = data.get("account_email_verified", None) - - self.listing.add(self.session) - - async def _event_join(self, data): - session = Session.from_dict(data) - self.listing.add(session) - await self._inhabitant.on_join(self, session) - - async def _event_network(self, data): - server_id = data.get("server_id") - server_era = data.get("server_era") - logger.debug(f"&{self.roomname}:Received network-event: server_id: {server_id!r}, server_era: {server_era!r}") - - sessions = self.listing.remove_combo(server_id, server_era) - for session in sessions: - asyncio.ensure_future(self._inhabitant.on_part(self, session)) - - async def _event_nick(self, data): - sid = data.get("session_id") - uid = data.get("user_id") - from_nick = data.get("from") - to_nick = data.get("to") - - session = self.listing.by_sid(sid) - if session: - session.nick = to_nick - - await self._inhabitant.on_nick(self, sid, uid, from_nick, to_nick) - - async def _event_edit_message(self, data): - message = Message.from_dict(data) - await self._inhabitant.on_edit(self, message) - - async def _event_part(self, data): - session = Session.from_dict(data) - self.listing.remove(session.sid) - await self._inhabitant.on_part(self, session) - - async def _event_ping(self, data): - try: - new_data = {"time": data.get("time")} - await self._connection.send( "ping-reply", data=new_data, await_response=False) - except ConnectionClosed: - pass - - async def _event_pm_initiate(self, data): - from_uid = data.get("from") - from_nick = data.get("from_nick") - from_room = data.get("from_room") - pm_id = data.get("pm_id") - - await self._inhabitant.on_pm(self, from_uid, from_nick, from_room, pm_id) - - async def _event_send(self, data): - message = Message.from_dict(data) - - if self._status == Room.FORWARDING: - logger.info(f"&{self.roomname}:Received new message while forwarding, adding to queue") - self._forward_new.append(message) - else: - self._last_known_mid = message.mid - await self._inhabitant.on_send(self, message) - - # TODO: Figure out a way to bring fast-forwarding into this - - async def _event_snapshot(self, data): - logger.debug(f"&{self.roomname}:Received snapshot-event, gained access to the room") - log = [Message.from_dict(m) for m in data.get("log")] - sessions = [Session.from_dict(d) for d in data.get("listing")] - - # Update listing - self.listing = Listing() - for session in sessions: - self.listing.add(session) - self.listing.add(self.session) - - # Update room info - self.pm_with_nick = data.get("pm_with_nick", None), - self.pm_with_user_id = data.get("pm_with_user_id", None) - self.session.nick = data.get("nick", None) - - # Make sure a room is not CONNECTED without a nick - if self.target_nick and self.target_nick != self.session.nick: - logger.info(f"&{self.roomname}:Current nick doesn't match target nick {self.target_nick!r}, changing nick") - try: - _, nick_data, _, _ = await self._connection.send("nick", data={"name": self.target_nick}) - self.session.nick = nick_data.get("to") - except ConnectionClosed: - return # Aww, we've lost connection again - - # Now, we're finally connected again! - if self._last_known_mid is None: - logger.info(f"&{self.roomname}:Fully connected") - self._status = Room.CONNECTED - if log: # log goes from old to new - self._last_known_mid = log[-1].mid - else: - logger.info(f"&{self.roomname}:Not fully connected yet, starting message rewinding") - self._status = Room.FORWARDING - self._forward_new = [] - - if self._forwarding is not None: - self._forwarding.cancel() - self._forwarding = asyncio.ensure_future(self._forward(log)) - - if not self._connected_future.done(): # Should never be done already, I think - self._connected_future.set_result(None) - - # Let's let the inhabitant know. - await self._inhabitant.on_connected(self, log) - - # TODO: Figure out a way to bring fast-forwarding into this - # Should probably happen where this comment is - -# SOME USEFUL PUBLIC METHODS - - @staticmethod - def format_room_url(roomname, private=False, human=False): - if private: - roomname = f"pm:{roomname}" - - url = f"wss://euphoria.io/room/{roomname}/ws" - - if human: - url = f"{url}?h=1" - - return url - - async def connected(self): - await self._connected_future - -# REST OF THE IMPLEMENTATION - - async def _forward(self, log): - old_messages = [] - while True: - found_last_known = True - for message in reversed(log): - if message.mid <= self._last_known_mid: - break - old_messages.append(message) - else: - found_last_known = False - - if found_last_known: - break - - log = await self.log(100, before=log[0].mid) + """ + A Room represents one connection to a room on euphoria, i. e. what other + implementations might consider a "client". This means that each Room has + its own session (User) and nick. - logger.info(f"&{self.roomname}:Reached last known message, forwarding through messages") - for message in reversed(old_messages): - self._last_known_mid = message.mid - asyncio.ensure_future(self._inhabitant.on_forward(self, message)) - for message in self._forward_new: - self._last_known_mid = message.mid - asyncio.ensure_future(self._inhabitant.on_forward(self, message)) - - logger.info(f"&{self.roomname}:Forwarding complete, fully connected") - self._forward_new = [] - self._status = Room.CONNECTED - - async def _send_while_connected(self, *args, **kwargs): - while True: - if self._status == Room.CLOSED: - raise RoomClosed() - - try: - await self.connected() - return await self._connection.send(*args, data=kwargs) - except ConnectionClosed: - pass # just try again + A Room can only be used once in the sense that after it has been closed, + any further actions will result in a RoomClosedException. If you need to + manually reconnect, instead just create a new Room object. -class Inhabitant: - """ - TODO - """ -# ROOM EVENTS -# These functions are called by the room when something happens. -# They're launched via asyncio.ensure_future(), so they don't block execution of the room. -# Just overwrite the events you need (make sure to keep the arguments the same though). + Life cycle of a Room - async def on_created(self, room): - pass + 1. create a new Room and register callbacks + 2. await join() + 3. do room-related stuff + 4. await part() - async def on_connected(self, room, log): - pass - async def on_disconnected(self, room): - pass - async def on_stopped(self, room): - pass + IN PHASE 1, a password and a starting nick can be set. The password and + current nick are used when first connecting to the room, or when + reconnecting to the room after connection was lost. - async def on_join(self, room, session): - pass + Usually, event callbacks are also registered during this phase. - async def on_part(self, room, session): - pass - async def on_nick(self, room, sid, uid, from_nick, to_nick): - pass - async def on_send(self, room, message): - pass + IN PHASE 2, the Room creates the initial connection to euphoria and + performs initialisations (i. e. authentication or setting the nick) where + necessary. It also starts the Room's main event loop. The join() function + returns once one of the following cases has occurred: - async def on_forward(self, room, message): - await self.on_send(room, message) + 1. the room is now in phase 3, in which case join() returns None + 2. the room could not be joined, in which case one of the JoinExceptions is + returned - async def on_edit(self, room, message): - pass - async def on_pm(self, room, from_uid, from_nick, from_room, pm_id): - pass + + IN PHASE 3, the usual room-related functions like say() or nick() are + available. The Room's event loop is running. + + The room will automatically reconnect if it loses connection to euphoria. + The usual room-related functions will block until the room has successfully + reconnected. + + + + IN PHASE 4, the Room is disconnected and the event loop stopped. During and + after completion of this phase, the Room is considered closed. Any further + attempts to re-join or call room action functions will result in a + RoomClosedException. + """ + + # Phase 1 + + def __init__(self, + room_name: str, + nick: str = None, + password: str = None): + pass + + self.closed = False + + # Phase 2 + + # Phase 3 + + def _ensure_open(self) -> None: + if self.closed: + raise RoomClosedException() + + async def _ensure_joined(self) -> None: + pass + + async def _ensure(self) -> None: + self._ensure_open() + await self._ensure_joined() + + # Phase 4 + + # Other stuff + + @property + def name(self) -> str: + pass + + async def say(self, + text: str, + parent_id: Optional[str] = None + ) -> LiveMessage: + pass + + @property + def users(self) -> List[LiveUser]: + pass + + # retrieving messages diff --git a/yaboli/user.py b/yaboli/user.py new file mode 100644 index 0000000..053315c --- /dev/null +++ b/yaboli/user.py @@ -0,0 +1,91 @@ +from .util import mention, atmention + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .client import Client + from .room import Room + +__all__ = ["User", "LiveUser"] + +class User: + def __init__(self, + room_name: str, + id_: str, + name: str, + is_staff: bool, + is_manager: bool): + self._room_name = room_name + self._id = id_ + self._name = name + self._is_staff = is_staff + self._is_manager = is_manager + + @property + def room_name(self) -> str: + return self._room_name + + @property + def id(self) -> str: + return self._id + + @property + def name(self) -> str: + # no name = empty str + return self._name + + @property + def is_staff(self) -> bool: + return self._is_staff + + @property + def is_manager(self) -> bool: + return self._is_manager + + @property + def is_account(self) -> bool: + pass + + @property + def is_agent(self) -> bool: + # TODO should catch all old ids too + pass + + @property + def is_bot(self) -> bool: + pass + + # TODO possibly add other fields + + # Properties here? Yeah sure, why not? + + @property + def mention(self) -> str: + return mention(self.name) + + @property + def atmention(self) -> str: + return atmention(self.name) + +class LiveUser(User): + def __init__(self, + client: 'Client', + room: 'Room', + id_: str, + name: str, + is_staff: bool, + is_manager: bool): + super().__init__(room.name, id_, name, is_staff, is_manager) + self._room = room + + @property + def room(self) -> 'Room': + return self._room + + # NotLoggedInException + async def pm(self) -> 'Room': + pass + + # kick + # ban + # ip_ban diff --git a/yaboli/util.py b/yaboli/util.py new file mode 100644 index 0000000..7dff6aa --- /dev/null +++ b/yaboli/util.py @@ -0,0 +1,15 @@ +__all__ = ["mention", "atmention", "normalize", "compare"] + +# Name/nick related functions + +def mention(name: str) -> str: + pass + +def atmention(name: str) -> str: + pass + +def normalize(name: str) -> str: + pass + +def compare(name_a: str, name_b: str) -> bool: + pass diff --git a/yaboli/utils.py b/yaboli/utils.py deleted file mode 100644 index ba45af7..0000000 --- a/yaboli/utils.py +++ /dev/null @@ -1,225 +0,0 @@ -import asyncio -import logging -import re -import time -import functools - - -logger = logging.getLogger(__name__) -__all__ = [ - "parallel", "asyncify", - "mention", "normalize", "similar", - "format_time", "format_time_delta", - "Session", "PersonalAccountView", "Listing", "Message", -] - - -# alias for parallel message sending -parallel = asyncio.ensure_future - -async def asyncify(func, *args, **kwargs): - func_with_args = functools.partial(func, *args, **kwargs) - return await asyncio.get_event_loop().run_in_executor(None, func_with_args) - -def mention(nick, ping=True): - nick = re.sub(r"""[,.!?;&<'"\s]""", "", nick) - return "@" + nick if ping else nick - -def normalize(nick): - return mention(nick, ping=False).lower() - -def similar(nick1, nick2): - return normalize(nick1) == normalize(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%hour - - 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_client_address=None): - self.user_id = user_id - self.nick = nick - 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_client_address = real_client_address - - @property - def uid(self): - return self.user_id - - @uid.setter - def uid(self, new_uid): - self.user_id = new_uid - - @property - def sid(self): - return self.session_id - - @sid.setter - def sid(self, new_sid): - self.session_id = new_sid - - @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", None), - d.get("is_manager", None), - d.get("client_address", None), - d.get("real_client_address", None) - ) - - @property - def client_type(self): - # account, agent or bot - return self.user_id.split(":")[0] - -class PersonalAccountView: - def __init__(self, account_id, name, email): - self.account_id = account_id - self.name = name - self.email = email - - @property - def aid(self): - return self.account_id - - @aid.setter - def aid(self, new_aid): - self.account_id = new_aid - -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 remove_combo(self, server_id, server_era): - removed = [ses for ses in self._sessions.items() - if ses.server_id == server_id and ses.server_era == server_era] - - self._sessions = {i: ses for i, ses in self._sessions.items() - if ses.server_id != server_id and ses.server_era != server_era} - - return removed - - def by_sid(self, session_id): - return self._sessions.get(session_id); - - def by_uid(self, user_id): - return [ses for ses in self._sessions if ses.user_id == user_id] - - def get(self, types=["agent", "account", "bot"], lurker=None): - sessions = [] - for uid, ses in self._sessions.items(): - if ses.client_type not in types: - continue - - is_lurker = not ses.nick # "" or None - if lurker is None or lurker == is_lurker: - sessions.append(ses) - - return sessions - - @classmethod - def from_dict(cls, d): - listing = cls() - for session in d: - listing.add(Session.from_dict(session)) - return listing - - #def get_people(self): - #return self.get(types=["agent", "account"]) - - #def get_accounts(self): - #return self.get(types=["account"]) - - #def get_agents(self): - #return self.get(types=["agent"]) - - #def get_bots(self): - #return self.get(types=["bot"]) - -class Message(): - def __init__(self, message_id, time, sender, content, parent=None, previous_edit_id=None, - encryption_key_id=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_id = encryption_key_id - self.edited = edited - self.deleted = deleted - self.truncated = truncated - - @property - def mid(self): - return self.message_id - - @mid.setter - def mid(self, new_mid): - self.message_id = new_mid - - @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", None), - d.get("previous_edit_id", None), - d.get("encryption_key_id", None), - d.get("edited", None), - d.get("deleted", None), - d.get("truncated", None) - ) From 325af11feadfb245b4b222307a8bae765821b528 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Apr 2019 10:22:23 +0000 Subject: [PATCH 065/145] Add Connection concept --- yaboli/connection.py | 67 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 yaboli/connection.py diff --git a/yaboli/connection.py b/yaboli/connection.py new file mode 100644 index 0000000..a92d0b8 --- /dev/null +++ b/yaboli/connection.py @@ -0,0 +1,67 @@ +__all__ = ["Connection"] + +class Connection: + """ + The Connection handles the lower-level stuff required when connecting to + euphoria, such as: + + - Creating a websocket connection + - Encoding and decoding packets (json) + - Waiting for the server's asynchronous replies to packets + - Keeping the connection alive (ping, ping-reply packets) + - Reconnecting (timeout while connecting, no pings received in some time) + + + + Life cycle of a Connection: + + 1. create connection and register event callbacks + 2. call connect() + 3. send and receive packets, reconnecting automatically when connection is + lost + 4. call disconnect(), return to 2. + + + IN PHASE 1, parameters such as the url the Connection should connect to are + set. Usually, event callbacks are also registered in this phase. + + + IN PHASE 2, the Connection attempts to connect to the url set in phase 1. + If successfully connected, it fires a "connected" event. + + + IN PHASE 3, the Connection listenes for packets from the server and fires + the corresponding events. Packets can be sent using the Connection. + + If the Connection has to reconnect for some reason, it first fires a + "reconnecting" event. Then it tries to reconnect until it has established a + connection to euphoria again. After the connection is reestablished, it + fires a "reconnected" event. + + + IN PHASE 4, the Connection fires a "disconnecting" event and then closes + the connection to euphoria. This event is the last event that is fired + until connect() is called again. + + + + Events: + + - "connected" : No arguments + - "reconnecting" : No arguments + - "reconnected" : No arguments + - "disconnecting" : No arguments + - "on_": the packet, parsed as JSON + + Events ending with "-ing" ("reconnecting", "disconnecting") are fired at + the beginning of the process they represent. Events ending with "-ed" + ("connected", "reconnected") are fired after the process they represent has + completed. + + Examples for the last category of events include "on_message-event", + "on_part-event" and "on_ping". + """ + + def __init__(self, + url: str): + self._url = url From 23425090cc2f2ee8be02b70929b2dcb72d84f5ed Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Apr 2019 17:17:10 +0000 Subject: [PATCH 066/145] Implement most of Connection --- yaboli/connection.py | 208 ++++++++++++++++++++++++++++++++++++++++++- yaboli/events.py | 25 ++++++ yaboli/exceptions.py | 15 ++++ 3 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 yaboli/events.py diff --git a/yaboli/connection.py b/yaboli/connection.py index a92d0b8..be0589c 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -1,3 +1,14 @@ +import asyncio +import logging +from typing import Any, Awaitable, Callable + +import websockets + +from .events import Events +from .exceptions import * + +logger = logging.getLogger(__name__) + __all__ = ["Connection"] class Connection: @@ -19,7 +30,7 @@ class Connection: 2. call connect() 3. send and receive packets, reconnecting automatically when connection is lost - 4. call disconnect(), return to 2. + 4. call disconnect() IN PHASE 1, parameters such as the url the Connection should connect to are @@ -62,6 +73,197 @@ class Connection: "on_part-event" and "on_ping". """ - def __init__(self, - url: str): + PING_TIMEOUT = 60 # seconds + + _NOT_RUNNING = "not running" + _CONNECTING = "connecting" + _RUNNING = "running" + _RECONNECTING = "reconnecting" + _DISCONNECTING = "disconnecting" + + # Initialising + + def __init__(self, url: str) -> None: self._url = url + + self._events = Events() + + # This is the current status of the connection. It can be set to one of + # _NOT_RUNNING, _CONNECTING, _RUNNING, _RECONNECTING, or + # _DISCONNECTING. + # + # Always be careful to set any state-dependent variables. + self._status = _NOT_RUNNING + self._connected_event = asyncio.Event() + self._disconnected_event = asyncio.Event() + + self._event_loop = None + + # These must always be (re)set together. If one of them is None, all + # must be None. + self._ws = None + self._awaiting_replies = None + self._ping_check = None + + def register_event(self, + event: str, + callback: Callable[..., Awaitable[None]] + ) -> None: + self._events.register(event, callback) + + # Connecting and disconnecting + + async def _disconnect(self) -> None: + """ + Disconnect _ws and clean up _ws, _awaiting_replies and _ping_check. + + Important: The caller must ensure that this function is called in valid + circumstances and not called twice at the same time. _disconnect() does + not check or manipulate _state. + """ + + if self._ws is None: + # This indicates that _ws, _awaiting_replies and _ping_check are + # cleaned up + return + + await self._ws.close() + + for tasks in self._awaiting_replies.values(): + for task in tasks: + task.cancel() + + self._ping_check.cancel() + + self._ws = None + self._awaiting_replies = None + self._ping_check = None + + async def _connect(self) -> bool: + """ + Attempts once to create a ws connection. + + Important: The caller must ensure that this function is called in valid + circumstances and not called twice at the same time. _connect() does + not check or manipulate _state, nor does it perform cleanup on + _awaiting_replies or _ping_check. + """ + + try: + ws = await websockets.connect(self._url) + + self._ws = ws + self._awaiting_replies = {} + self._ping_check = asyncio.create_task( + self._disconnect_in(self.PING_TIMEOUT)) + + return True + + # TODO list all of the ways that creating a connection can go wrong + except websockets.InvalidStatusCode: + return False + + async def _disconnect_in(self, delay): + await asyncio.sleep(delay) + await self._disconnect() + + async def connect(self) -> bool: + # Special exception message for _CONNECTING. + if self._state == self._CONNECTING: + raise IncorrectStateException(("connect() may not be called" + " multiple times.")) + + if self._state != self._NOT_RUNNING: + raise IncorrectStateException(("disconnect() must complete before" + " connect() may be called again.")) + + # Now we're sure we're in the _NOT_RUNNING state, we can set our state. + # Important: No await-ing has occurred between checking the state and + # setting it. + self._state = self._CONNECTING + + if await self._connect(): + self._event_loop = asyncio.create_task(self._run()) + self._state = self._RUNNING + return True + else: + self._state = self._NOT_RUNNING + return False + + async def _reconnect(self) -> bool: + """ + This function should only be called from the event loop while the + _state is _RUNNING. + """ + + if self._state != self._RUNNING: + raise IncorrectStateException() + + self._state = self._RECONNECTING + + await self._disconnect() + success = await self._connect() + + self._state = self._RUNNING + + return success + + async def disconnect(self) -> None: + # This function is kinda complex. The comments make it harder to read, + # but hopefully easier to understand. + + # Possible states left: _NOT_RUNNING, _CONNECTING, _RUNNING, + # _RECONNECTING, _DISCONNECTING + + # Waiting until the current connection attempt is finished. + if self._state in [self._CONNECTING, self._RECONNECTING]: + # After _CONNECTING, the state can either be _NOT_RUNNING or + # _RUNNING. After _RECONNECTING, the state must be _RUNNING. + await self._connected_event.wait() + # The state is now either _NOT_RUNNING or _RUNNING. + + # Possible states left: _NOT_RUNNING, _RUNNING, _DISCONNECTING + + if self._state == self._NOT_RUNNING: + # No need to do anything since we're already disconnected + return + + # Possible states left: _RUNNING, _DISCONNECTING + + if self._state == self._DISCONNECTING: + # Wait until the disconnecting currently going on is complete. This + # is to prevent the disconnect() function from ever returning + # without the disconnecting process being finished. + await self._disconnected_event.wait() + return + + # Possible states left: _RUNNING + + # By principle of exclusion, the only state left is _RUNNING. Doing an + # explicit check though, just to make sure. + if self._state != self._RUNNING: + raise IncorrectStateException("This should never happen.") + + # Now, we can properly disconnect ^^ + await self._disconnect() + + await self._event_loop + self._event_loop = None + + self._state = self._NOT_RUNNING + + # Notify all other disconnect()s waiting + self._disconnected_event.set() + self._disconnected_event.clear() + + # Running + + async def _run(self) -> None: + """ + The main loop that runs during phase 3 + """ + + # TODO + + async def send(self, packet: Any) -> Any: + pass # TODO diff --git a/yaboli/events.py b/yaboli/events.py new file mode 100644 index 0000000..4fce41f --- /dev/null +++ b/yaboli/events.py @@ -0,0 +1,25 @@ +import asyncio +import logging +from typing import Any, Awaitable, Callable, Dict, List + +logger = logging.getLogger(__name__) + +__all__ = ["Events"] + +class Events: + def __init__(self) -> None: + self._callbacks: Dict[str, List[Callable[..., Awaitable[None]]]] = {} + + def register(self, + event: str, + callback: Callable[..., Awaitable[None]] + ) -> None: + callback_list = self._callbacks.get(event, []) + callback_list.append(callback) + self._callbacks[event] = callback_list + logger.debug(f"Registered callback for event {event!r}") + + async def fire(self, event: str, *args: Any, **kwargs: Any) -> None: + logger.debug(f"Calling callbacks for event {event!r}") + for callback in self._callbacks.get(event, []): + asyncio.create_task(callback(*args, **kwargs)) diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py index 2c951a0..63ffe77 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -5,6 +5,21 @@ __all__ = ["EuphException", "JoinException", "CouldNotConnectException", class EuphException(Exception): pass +# Connection stuff + +class IncorrectStateException(EuphException): + """ + A Connection function was called while the Connection was in the incorrect + state. + """ + pass + +class ConnectionClosedException(EuphException): + """ + The connection was closed unexpectedly. + """ + pass + # Joining a room class JoinException(EuphException): From f6f7cc5aa6113d9b6cc6d4a0844ddf26b6ab41a8 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Apr 2019 17:17:43 +0000 Subject: [PATCH 067/145] Clean up --- mypy.ini | 1 + yaboli/__init__.py | 24 +++++++++++------------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/mypy.ini b/mypy.ini index e91e90c..6fd0e6a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,3 +1,4 @@ [mypy] disallow_untyped_defs = True disallow_incomplete_defs = True +no_implicit_optional = True diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 4f04690..24b21f5 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,21 +1,19 @@ from typing import List -__all__: List[str] = [] - from .client import * -__all__ += client.__all__ - +from .connection import * +from .events import * from .exceptions import * -__all__ += client.__all__ - from .message import * -__all__ += exceptions.__all__ - from .room import * -__all__ += message.__all__ - -__all__ += room.__all__ from .user import * - -__all__ += user.__all__ from .util import * + +__all__: List[str] = [] +__all__ += client.__all__ +__all__ += connection.__all__ +__all__ += events.__all__ +__all__ += exceptions.__all__ +__all__ += message.__all__ +__all__ += room.__all__ +__all__ += user.__all__ From 0d9161fd1ec8004a821ab015baa196e68f89cd8d Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Apr 2019 17:50:15 +0000 Subject: [PATCH 068/145] Switch to asyncio.Condition --- yaboli/connection.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index be0589c..f56df19 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -30,7 +30,7 @@ class Connection: 2. call connect() 3. send and receive packets, reconnecting automatically when connection is lost - 4. call disconnect() + 4. call disconnect(), then go to 2. IN PHASE 1, parameters such as the url the Connection should connect to are @@ -67,12 +67,13 @@ class Connection: Events ending with "-ing" ("reconnecting", "disconnecting") are fired at the beginning of the process they represent. Events ending with "-ed" ("connected", "reconnected") are fired after the process they represent has - completed. + finished. Examples for the last category of events include "on_message-event", "on_part-event" and "on_ping". """ + # Maximum duration between euphoria's ping messages PING_TIMEOUT = 60 # seconds _NOT_RUNNING = "not running" @@ -93,9 +94,9 @@ class Connection: # _DISCONNECTING. # # Always be careful to set any state-dependent variables. - self._status = _NOT_RUNNING - self._connected_event = asyncio.Event() - self._disconnected_event = asyncio.Event() + self._state = self._NOT_RUNNING + self._connected_condition = asyncio.Condition() + self._disconnected_condition = asyncio.Condition() self._event_loop = None @@ -185,9 +186,17 @@ class Connection: if await self._connect(): self._event_loop = asyncio.create_task(self._run()) self._state = self._RUNNING + + async with self._connected_condition: + self._connected_condition.notify_all() + return True else: self._state = self._NOT_RUNNING + + async with self._connected_condition: + self._connected_condition.notify_all() + return False async def _reconnect(self) -> bool: @@ -205,6 +214,8 @@ class Connection: success = await self._connect() self._state = self._RUNNING + async with self._connected_condition: + self._connected_condition.notify_all() return success @@ -219,7 +230,8 @@ class Connection: if self._state in [self._CONNECTING, self._RECONNECTING]: # After _CONNECTING, the state can either be _NOT_RUNNING or # _RUNNING. After _RECONNECTING, the state must be _RUNNING. - await self._connected_event.wait() + async with self._connected_condition: + await self._connected_condition.wait() # The state is now either _NOT_RUNNING or _RUNNING. # Possible states left: _NOT_RUNNING, _RUNNING, _DISCONNECTING @@ -234,7 +246,9 @@ class Connection: # Wait until the disconnecting currently going on is complete. This # is to prevent the disconnect() function from ever returning # without the disconnecting process being finished. - await self._disconnected_event.wait() + async with self._disconnected_condition: + await self._disconnected_condition.wait() + return # Possible states left: _RUNNING @@ -253,8 +267,8 @@ class Connection: self._state = self._NOT_RUNNING # Notify all other disconnect()s waiting - self._disconnected_event.set() - self._disconnected_event.clear() + async with self._disconnected_condition: + self._disconnected_condition.notify_all() # Running From 47a8014b4c0ea9b4c9750ed4f4b769c11ba2a6e7 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Apr 2019 19:04:19 +0000 Subject: [PATCH 069/145] Fix disconnect() bug I just forgot to set the _state to _DISCONNECTING while disconnecting. --- yaboli/connection.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index f56df19..f781e48 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -258,7 +258,11 @@ class Connection: if self._state != self._RUNNING: raise IncorrectStateException("This should never happen.") - # Now, we can properly disconnect ^^ + # Now we're sure we're in the _RUNNING state, we can set our state. + # Important: No await-ing has occurred between checking the state and + # setting it. + self._state = self._DISCONNECTING + await self._disconnect() await self._event_loop From 500d91a14c467a65178cdffc895a3c5d11144201 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Apr 2019 22:24:29 +0000 Subject: [PATCH 070/145] Make mypy happy --- yaboli/connection.py | 17 ++++++++++------- yaboli/exceptions.py | 19 ++++++++++++++++--- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index f781e48..95989bd 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -1,6 +1,6 @@ import asyncio import logging -from typing import Any, Awaitable, Callable +from typing import Any, Awaitable, Callable, Dict, Optional import websockets @@ -98,13 +98,14 @@ class Connection: self._connected_condition = asyncio.Condition() self._disconnected_condition = asyncio.Condition() - self._event_loop = None + self._event_loop: Optional[asyncio.Task[None]] = None # These must always be (re)set together. If one of them is None, all # must be None. self._ws = None - self._awaiting_replies = None - self._ping_check = None + self._awaiting_replies: Optional[Dict[str, Callable[..., + Awaitable[None]]]] = None + self._ping_check: Optional[asyncio.Task[None]] = None def register_event(self, event: str, @@ -164,7 +165,7 @@ class Connection: except websockets.InvalidStatusCode: return False - async def _disconnect_in(self, delay): + async def _disconnect_in(self, delay: int) -> None: await asyncio.sleep(delay) await self._disconnect() @@ -265,8 +266,10 @@ class Connection: await self._disconnect() - await self._event_loop - self._event_loop = None + # We know that _event_loop is not None, but this is to keep mypy happy. + if self._event_loop is not None: + await self._event_loop + self._event_loop = None self._state = self._NOT_RUNNING diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py index 63ffe77..28f2cfb 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -1,6 +1,19 @@ -__all__ = ["EuphException", "JoinException", "CouldNotConnectException", - "CouldNotAuthenticateException", "RoomClosedException", - "RateLimitException", "NotLoggedInException", "UnauthorizedException"] +__all__ = [ + "EuphException", + # Connection stuff + "IncorrectStateException", + "ConnectionClosedException", + # Joining a room + "JoinException", + "CouldNotConnectException", + "CouldNotAuthenticateException", + # Doing stuff in a room + "RoomClosedException", + # Other stuff + "RateLimitException", + "NotLoggedInException", + "UnauthorizedException", +] class EuphException(Exception): pass From a24e4aa18a9e2bfd3a17b43d91bf8c93cadd4b7e Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Apr 2019 23:31:59 +0000 Subject: [PATCH 071/145] Add debug logging --- info.txt | 17 +++++++++++++ test.py | 46 +++++++++++++++++++++++++++++++++++ yaboli/connection.py | 57 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 test.py diff --git a/info.txt b/info.txt index a17672c..f33cfb7 100644 --- a/info.txt +++ b/info.txt @@ -20,3 +20,20 @@ import logging logger = logging.getLogger('websockets') logger.setLevel(logging.INFO) logger.addHandler(logging.StreamHandler()) + +Output format: See https://docs.python.org/3/library/logging.html#formatter-objects + +Example formatting: + +FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" +DATE_FORMAT = "%F %T" +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter( + fmt=FORMAT, + datefmt=DATE_FORMAT, + style="{" +)) + +logger = logging.getLogger('yaboli') +logger.setLevel(logging.DEBUG) +logger.addHandler(handler) diff --git a/test.py b/test.py new file mode 100644 index 0000000..37f7053 --- /dev/null +++ b/test.py @@ -0,0 +1,46 @@ +# These tests are not intended as serious tests, just as small scenarios to +# give yaboli something to do. + +import asyncio +import logging + +from yaboli import Connection + +FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" +DATE_FORMAT = "%F %T" +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter( + fmt=FORMAT, + datefmt=DATE_FORMAT, + style="{" +)) + +logger = logging.getLogger('yaboli') +logger.setLevel(logging.DEBUG) +logger.addHandler(handler) + +async def main(): + conn = Connection("wss://echo.websocket.org") + + print() + print(" DISCONNECTING TWICE AT THE SAME TIME") + print("Connected successfully:", await conn.connect()) + a = asyncio.create_task(conn.disconnect()) + b = asyncio.create_task(conn.disconnect()) + await a + await b + + print() + print(" DISCONNECTING WHILE CONNECTING (test not working properly)") + asyncio.create_task(conn.disconnect()) + await asyncio.sleep(0) + print("Connected successfully:", await conn.connect()) + await conn.disconnect() + + print() + print(" WAITING FOR PING TIMEOUT") + print("Connected successfully:", await conn.connect()) + await asyncio.sleep(conn.PING_TIMEOUT + 10) + await conn.disconnect() + +asyncio.run(main()) diff --git a/yaboli/connection.py b/yaboli/connection.py index 95989bd..02b9a29 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -127,16 +127,21 @@ class Connection: if self._ws is None: # This indicates that _ws, _awaiting_replies and _ping_check are # cleaned up + logger.debug("ws connection already cleaned up") return + logger.debug("Closing ws connection") await self._ws.close() + logger.debug("Cancelling tasks awaiting replies") for tasks in self._awaiting_replies.values(): for task in tasks: task.cancel() + logger.debug("Cancelling ping check task") self._ping_check.cancel() + logger.debug("Cleaning up variables") self._ws = None self._awaiting_replies = None self._ping_check = None @@ -152,10 +157,12 @@ class Connection: """ try: + logger.debug(f"Creating ws connection to {self._url!r}") ws = await websockets.connect(self._url) self._ws = ws self._awaiting_replies = {} + logger.debug("Starting ping check") self._ping_check = asyncio.create_task( self._disconnect_in(self.PING_TIMEOUT)) @@ -163,11 +170,19 @@ class Connection: # TODO list all of the ways that creating a connection can go wrong except websockets.InvalidStatusCode: + logger.debug("Connection failed") return False async def _disconnect_in(self, delay: int) -> None: await asyncio.sleep(delay) - await self._disconnect() + logger.debug(f"Disconnect timeout of {delay}s elapsed, disconnecting...") + # Starting the _disconnect function in another task because otherwise, + # its own CancelledError would inhibit _disconnect() from completing + # the disconnect. + # + # We don't need to check the state because _disconnect_in only runs + # while the _state is _RUNNING. + asyncio.create_task(self._disconnect()) async def connect(self) -> bool: # Special exception message for _CONNECTING. @@ -179,26 +194,28 @@ class Connection: raise IncorrectStateException(("disconnect() must complete before" " connect() may be called again.")) + logger.info("Connecting...") + # Now we're sure we're in the _NOT_RUNNING state, we can set our state. # Important: No await-ing has occurred between checking the state and # setting it. self._state = self._CONNECTING - if await self._connect(): + success = await self._connect() + + if success: + logger.debug("Starting event loop") self._event_loop = asyncio.create_task(self._run()) self._state = self._RUNNING - - async with self._connected_condition: - self._connected_condition.notify_all() - - return True else: self._state = self._NOT_RUNNING - async with self._connected_condition: - self._connected_condition.notify_all() + logger.debug("Sending connected notification") + async with self._connected_condition: + self._connected_condition.notify_all() - return False + logger.debug("Connected" if success else "Connection failed") + return success async def _reconnect(self) -> bool: """ @@ -209,20 +226,27 @@ class Connection: if self._state != self._RUNNING: raise IncorrectStateException() + logger.info("Reconnecting...") + self._state = self._RECONNECTING await self._disconnect() success = await self._connect() self._state = self._RUNNING + + logger.debug("Sending connected notification") async with self._connected_condition: self._connected_condition.notify_all() + logger.debug("Reconnected" if success else "Reconnection failed") return success async def disconnect(self) -> None: - # This function is kinda complex. The comments make it harder to read, - # but hopefully easier to understand. + # Fun fact: This function consists of 24 lines of comments, 19 lines of + # code, 16 lines of whitespace and 7 lines of logging statements, + # making for a total of 66 lines. Its comments to code ratio is about + # 1.263. # Possible states left: _NOT_RUNNING, _CONNECTING, _RUNNING, # _RECONNECTING, _DISCONNECTING @@ -239,6 +263,7 @@ class Connection: if self._state == self._NOT_RUNNING: # No need to do anything since we're already disconnected + logger.debug("Already disconnected") return # Possible states left: _RUNNING, _DISCONNECTING @@ -247,9 +272,11 @@ class Connection: # Wait until the disconnecting currently going on is complete. This # is to prevent the disconnect() function from ever returning # without the disconnecting process being finished. + logger.debug("Already disconnecting, waiting for it to finish...") async with self._disconnected_condition: await self._disconnected_condition.wait() + logger.debug("Disconnected, finished waiting") return # Possible states left: _RUNNING @@ -259,6 +286,8 @@ class Connection: if self._state != self._RUNNING: raise IncorrectStateException("This should never happen.") + logger.info("Disconnecting...") + # Now we're sure we're in the _RUNNING state, we can set our state. # Important: No await-ing has occurred between checking the state and # setting it. @@ -267,6 +296,7 @@ class Connection: await self._disconnect() # We know that _event_loop is not None, but this is to keep mypy happy. + logger.debug("Waiting for event loop") if self._event_loop is not None: await self._event_loop self._event_loop = None @@ -274,9 +304,12 @@ class Connection: self._state = self._NOT_RUNNING # Notify all other disconnect()s waiting + logger.debug("Send disconnected notification") async with self._disconnected_condition: self._disconnected_condition.notify_all() + logger.info("Disconnected") + # Running async def _run(self) -> None: From c60526a34ddafb7644318525b92ac6787dc08bb6 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Apr 2019 12:20:31 +0000 Subject: [PATCH 072/145] Add some event calls and event loop --- yaboli/connection.py | 120 ++++++++++++++++++++++++++++--------------- yaboli/events.py | 2 +- 2 files changed, 81 insertions(+), 41 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 02b9a29..ea5ff6d 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -124,15 +124,18 @@ class Connection: not check or manipulate _state. """ + if self._ws is not None: + logger.debug("Closing ws connection") + await self._ws.close() + + # Checking self._ws again since during the above await, another + # disconnect call could have finished cleaning up. if self._ws is None: # This indicates that _ws, _awaiting_replies and _ping_check are # cleaned up - logger.debug("ws connection already cleaned up") + logger.debug("Ws connection already cleaned up") return - logger.debug("Closing ws connection") - await self._ws.close() - logger.debug("Cancelling tasks awaiting replies") for tasks in self._awaiting_replies.values(): for task in tasks: @@ -184,6 +187,32 @@ class Connection: # while the _state is _RUNNING. asyncio.create_task(self._disconnect()) + async def _reconnect(self) -> bool: + """ + This function should only be called from the event loop while the + _state is _RUNNING. + """ + + if self._state != self._RUNNING: + raise IncorrectStateException() + + logger.debug("Reconnecting...") + self._events.fire("reconnecting") + self._state = self._RECONNECTING + + await self._disconnect() + success = await self._connect() + + self._state = self._RUNNING + self._events.fire("reconnected") + + logger.debug("Sending connected notification") + async with self._connected_condition: + self._connected_condition.notify_all() + + logger.debug("Reconnected" if success else "Reconnection failed") + return success + async def connect(self) -> bool: # Special exception message for _CONNECTING. if self._state == self._CONNECTING: @@ -194,7 +223,7 @@ class Connection: raise IncorrectStateException(("disconnect() must complete before" " connect() may be called again.")) - logger.info("Connecting...") + logger.debug("Connecting...") # Now we're sure we're in the _NOT_RUNNING state, we can set our state. # Important: No await-ing has occurred between checking the state and @@ -207,6 +236,7 @@ class Connection: logger.debug("Starting event loop") self._event_loop = asyncio.create_task(self._run()) self._state = self._RUNNING + self._events.fire("connected") else: self._state = self._NOT_RUNNING @@ -217,47 +247,23 @@ class Connection: logger.debug("Connected" if success else "Connection failed") return success - async def _reconnect(self) -> bool: - """ - This function should only be called from the event loop while the - _state is _RUNNING. - """ - - if self._state != self._RUNNING: - raise IncorrectStateException() - - logger.info("Reconnecting...") - - self._state = self._RECONNECTING - - await self._disconnect() - success = await self._connect() - - self._state = self._RUNNING - - logger.debug("Sending connected notification") - async with self._connected_condition: - self._connected_condition.notify_all() - - logger.debug("Reconnected" if success else "Reconnection failed") - return success - async def disconnect(self) -> None: - # Fun fact: This function consists of 24 lines of comments, 19 lines of + # Fun fact: This function consists of 25 lines of comments, 19 lines of # code, 16 lines of whitespace and 7 lines of logging statements, - # making for a total of 66 lines. Its comments to code ratio is about - # 1.263. + # making for a total of 67 lines. Its comments to code ratio is about + # 1.316. # Possible states left: _NOT_RUNNING, _CONNECTING, _RUNNING, # _RECONNECTING, _DISCONNECTING - # Waiting until the current connection attempt is finished. - if self._state in [self._CONNECTING, self._RECONNECTING]: + # Waiting until the current connection attempt is finished. Using a + # while loop since the event loop might have started to reconnect again + # while the await is still waiting. + while self._state in [self._CONNECTING, self._RECONNECTING]: # After _CONNECTING, the state can either be _NOT_RUNNING or # _RUNNING. After _RECONNECTING, the state must be _RUNNING. async with self._connected_condition: await self._connected_condition.wait() - # The state is now either _NOT_RUNNING or _RUNNING. # Possible states left: _NOT_RUNNING, _RUNNING, _DISCONNECTING @@ -286,7 +292,9 @@ class Connection: if self._state != self._RUNNING: raise IncorrectStateException("This should never happen.") - logger.info("Disconnecting...") + + logger.debug("Disconnecting...") + self._events.fire("disconnecting") # Now we're sure we're in the _RUNNING state, we can set our state. # Important: No await-ing has occurred between checking the state and @@ -304,11 +312,11 @@ class Connection: self._state = self._NOT_RUNNING # Notify all other disconnect()s waiting - logger.debug("Send disconnected notification") + logger.debug("Sending disconnected notification") async with self._disconnected_condition: self._disconnected_condition.notify_all() - logger.info("Disconnected") + logger.debug("Disconnected") # Running @@ -317,7 +325,39 @@ class Connection: The main loop that runs during phase 3 """ - # TODO + while True: + if self._state != self._RUNNING: + logger.debug("Exiting event loop") + return + + if self._ws is not None: + try: + logger.debug("Receiving ws packets") + async for packet in self._ws: + self._process_packet(packet) + except Exception as e: # TODO use proper exceptions + print(e) + logger.debug("Stopped receiving ws packets") + else: + logger.debug("No ws connection found") + + if self._state != self._RUNNING: + logger.debug("Exiting event loop") + return + + logger.debug("Attempting to reconnect") + while not await self._reconnect(): + logger.debug("Reconnect attempt not successful") + + if self._state != self._RUNNING: + logger.debug("Exiting event loop") + return + + logger.debug(f"Sleeping for {self.RECONNECT_DELAY}s and retrying") + await asyncio.sleep(self._RECONNECT_DELAY) + + def _process_packet(self, packet): + print(str(packet)[:50]) async def send(self, packet: Any) -> Any: pass # TODO diff --git a/yaboli/events.py b/yaboli/events.py index 4fce41f..7829ccb 100644 --- a/yaboli/events.py +++ b/yaboli/events.py @@ -19,7 +19,7 @@ class Events: self._callbacks[event] = callback_list logger.debug(f"Registered callback for event {event!r}") - async def fire(self, event: str, *args: Any, **kwargs: Any) -> None: + def fire(self, event: str, *args: Any, **kwargs: Any) -> None: logger.debug(f"Calling callbacks for event {event!r}") for callback in self._callbacks.get(event, []): asyncio.create_task(callback(*args, **kwargs)) From 2de2cbf92cc3a5cb486a476277235073f4f797d6 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Apr 2019 15:38:51 +0000 Subject: [PATCH 073/145] Document stuff Also, use the correct exceptions when interacting with _ws --- yaboli/connection.py | 123 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 16 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index ea5ff6d..cf79283 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -1,5 +1,7 @@ import asyncio +import json import logging +import socket from typing import Any, Awaitable, Callable, Dict, Optional import websockets @@ -73,9 +75,14 @@ class Connection: "on_part-event" and "on_ping". """ - # Maximum duration between euphoria's ping messages - PING_TIMEOUT = 60 # seconds + # Maximum duration between euphoria's ping messages. Euphoria usually sends + # ping messages every 20 to 30 seconds. + PING_TIMEOUT = 40 # seconds + # The delay between reconnect attempts. + RECONNECT_DELAY = 40 # seconds + + # States the Connection may be in _NOT_RUNNING = "not running" _CONNECTING = "connecting" _RUNNING = "running" @@ -111,6 +118,12 @@ class Connection: event: str, callback: Callable[..., Awaitable[None]] ) -> None: + """ + Register an event callback. + + For an overview of the possible events, see the Connection docstring. + """ + self._events.register(event, callback) # Connecting and disconnecting @@ -171,8 +184,8 @@ class Connection: return True - # TODO list all of the ways that creating a connection can go wrong - except websockets.InvalidStatusCode: + except (websockets.InvalidHandshake, websockets.InvalidStatusCode, + socket.gaierror): logger.debug("Connection failed") return False @@ -194,7 +207,7 @@ class Connection: """ if self._state != self._RUNNING: - raise IncorrectStateException() + raise IncorrectStateException("This should never happen") logger.debug("Reconnecting...") self._events.fire("reconnecting") @@ -214,6 +227,20 @@ class Connection: return success async def connect(self) -> bool: + """ + Attempt to create a connection to the Connection's url. + + Returns True if the Connection could connect to the url and is now + running. Returns False if the Connection could not connect to the url + and is not running. + + Exceptions: + + This function must be called while the connection is not running, + otherwise an IncorrectStateException will be thrown. To stop a + Connection, use disconnect(). + """ + # Special exception message for _CONNECTING. if self._state == self._CONNECTING: raise IncorrectStateException(("connect() may not be called" @@ -248,10 +275,12 @@ class Connection: return success async def disconnect(self) -> None: - # Fun fact: This function consists of 25 lines of comments, 19 lines of - # code, 16 lines of whitespace and 7 lines of logging statements, - # making for a total of 67 lines. Its comments to code ratio is about - # 1.316. + """ + Close and stop the Connection, if it is currently (re-)connecting or + running. Does nothing if the Connection is not running. + + This function returns once the Connection has stopped running. + """ # Possible states left: _NOT_RUNNING, _CONNECTING, _RUNNING, # _RECONNECTING, _DISCONNECTING @@ -318,6 +347,38 @@ class Connection: logger.debug("Disconnected") + async def reconnect(self) -> None: + """ + Forces the Connection to reconnect. + + This function may return before the reconnect process is finished. + + Exceptions: + + This function must be called while the connection is (re-)connecting or + running, otherwise an IncorrectStateException will be thrown. + """ + + if self._state in [self._CONNECTING, self._RECONNECTING]: + logger.debug("Already (re-)connecting, waiting for it to finish...") + async with self._connected_condition: + await self._connected_condition.wait() + + logger.debug("(Re-)connected, finished waiting") + return + + if self._state != self._RUNNING: + raise IncorrectStateException(("reconnect() may not be called while" + " the connection is not running.")) + + # Disconnecting via task because otherwise, the _connected_condition + # might fire before we start waiting for it. + # + # The event loop will reconenct after the ws connection has been + # disconnected. + logger.debug("Disconnecting and letting the event loop reconnect") + await self._disconnect() + # Running async def _run(self) -> None: @@ -326,6 +387,14 @@ class Connection: """ while True: + # The "Exiting event loop" checks are a bit ugly. They're in place + # so that the event loop exits on its own at predefined positions + # instead of randomly getting thrown a CancelledError. + # + # Now that I think about it, the whole function looks kinda ugly. + # Maybe one day (yeah, right), I'll clean this up. I want to get it + # working first though. + if self._state != self._RUNNING: logger.debug("Exiting event loop") return @@ -334,9 +403,9 @@ class Connection: try: logger.debug("Receiving ws packets") async for packet in self._ws: - self._process_packet(packet) - except Exception as e: # TODO use proper exceptions - print(e) + packet_data = json.loads(packet) + self._process_packet(packet_data) + except websockets.ConnectionClosed: logger.debug("Stopped receiving ws packets") else: logger.debug("No ws connection found") @@ -354,10 +423,32 @@ class Connection: return logger.debug(f"Sleeping for {self.RECONNECT_DELAY}s and retrying") - await asyncio.sleep(self._RECONNECT_DELAY) + await asyncio.sleep(self.RECONNECT_DELAY) - def _process_packet(self, packet): - print(str(packet)[:50]) + def _process_packet(self, packet: Any) -> None: + print(str(packet)[:100]) # TODO implement - async def send(self, packet: Any) -> Any: + async def send(self, + packet_type: str, + data: Any, + await_reply: bool = True + ) -> Optional[Any]: + """ + Send a packet of type packet_type to the server. + + The object passed as data will make up the packet's "data" section and + must be json-serializable. + + This function will return the complete json-deserialized reply package, + unless await_reply is set to False, in which case it will immediately + return None. + + Exceptions: + + This function must be called while the Connection is (re-)connecting or + running, otherwise an IncorrectStateException will be thrown. + + If the connection closes unexpectedly while sending the packet or + waiting for the reply, a ConnectionClosedException will be thrown. + """ pass # TODO From 2a9cd03c4754f4afee75a1d24f50af245e4df1a2 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Apr 2019 18:10:47 +0000 Subject: [PATCH 074/145] Send and receive packets --- yaboli/connection.py | 89 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index cf79283..eaf5c54 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -95,6 +95,7 @@ class Connection: self._url = url self._events = Events() + self._packet_id = 0 # This is the current status of the connection. It can be set to one of # _NOT_RUNNING, _CONNECTING, _RUNNING, _RECONNECTING, or @@ -110,8 +111,7 @@ class Connection: # These must always be (re)set together. If one of them is None, all # must be None. self._ws = None - self._awaiting_replies: Optional[Dict[str, Callable[..., - Awaitable[None]]]] = None + self._awaiting_replies: Optional[Dict[str, asyncio.Future[Any]]] = None self._ping_check: Optional[asyncio.Task[None]] = None def register_event(self, @@ -149,10 +149,9 @@ class Connection: logger.debug("Ws connection already cleaned up") return - logger.debug("Cancelling tasks awaiting replies") - for tasks in self._awaiting_replies.values(): - for task in tasks: - task.cancel() + logger.debug("Cancelling futures waiting for replies") + for future in self._awaiting_replies.values(): + future.set_exception(ConnectionClosedException()) logger.debug("Cancelling ping check task") self._ping_check.cancel() @@ -403,6 +402,7 @@ class Connection: try: logger.debug("Receiving ws packets") async for packet in self._ws: + logger.debug(f"Received packet {packet}") packet_data = json.loads(packet) self._process_packet(packet_data) except websockets.ConnectionClosed: @@ -426,7 +426,41 @@ class Connection: await asyncio.sleep(self.RECONNECT_DELAY) def _process_packet(self, packet: Any) -> None: - print(str(packet)[:100]) # TODO implement + # This function assumes that the packet is formed correctly according + # to http://api.euphoria.io/#packets. + + # First, notify whoever's waiting for this packet + packet_id = packet.get("id", None) + if packet_id is not None and self._awaiting_replies is not None: + future = self._awaiting_replies.get(packet_id, None) + if future is not None: + future.set_result(packet) + + # Then, send the corresponding event + packet_type = packet["type"] + self._events.fire(f"on_{packet_type}", packet) + + # Finally, if it's a ping command, reply as per + # http://api.euphoria.io/#ping + if packet_type == "ping-event": + logger.debug("Pong!") + asyncio.create_task(self._send_if_possible( + "ping-reply", + {"time": packet["data"]["time"]} + )) + + async def _send_if_possible(self, packet_type: str, data: Any,) -> None: + """ + This function tries to send a packet without awaiting the reply. + + It ignores IncorrectStateExceptions, meaning that if it is called while + in the wrong state, nothing will happen. + """ + + try: + await self.send(packet_type, data, await_reply=False) + except IncorrectStateException: + logger.debug("Could not send (disconnecting or already disconnected)") async def send(self, packet_type: str, @@ -451,4 +485,43 @@ class Connection: If the connection closes unexpectedly while sending the packet or waiting for the reply, a ConnectionClosedException will be thrown. """ - pass # TODO + + while self._state in [self._CONNECTING, self._RECONNECTING]: + async with self._connected_condition: + await self._connected_condition.wait() + + if self._state != self._RUNNING: + raise IncorrectStateException(("send() must be called while the" + " Connection is running")) + + # We're now definitely in the _RUNNING state + + # Since we're in the _RUNNING state, _ws and _awaiting_replies are not + # None. This check is to satisfy mypy. + if self._ws is None or self._awaiting_replies is None: + raise IncorrectStateException("This should never happen") + + packet_id = str(self._packet_id) + self._packet_id += 1 + + # Doing this before the await below since we know that + # _awaiting_replies is not None while the _state is _RUNNING. + if await_reply: + response: asyncio.Future[Any] = asyncio.Future() + self._awaiting_replies[packet_id] = response + + text = json.dumps({"id": packet_id, "type": packet_type, "data": data}) + logger.debug(f"Sending packet {text}") + try: + await self._ws.send(text) + except websockets.ConnectionClosed: + raise ConnectionClosedException() # as promised in the docstring + + if await_reply: + await response + # If the response Future was completed with a + # ConnectionClosedException via set_exception(), response.result() + # will re-raise that exception. + return response.result() + else: + return None From 8c34a450c1e578eb8e15b26c15bacdac397a2071 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Apr 2019 19:03:22 +0000 Subject: [PATCH 075/145] Clean up --- test.py | 3 ++- yaboli/client.py | 8 ++++---- yaboli/exceptions.py | 21 ++------------------- yaboli/message.py | 6 +++--- yaboli/room.py | 8 ++++---- yaboli/user.py | 4 ++-- 6 files changed, 17 insertions(+), 33 deletions(-) diff --git a/test.py b/test.py index 37f7053..98228f1 100644 --- a/test.py +++ b/test.py @@ -20,7 +20,8 @@ logger.setLevel(logging.DEBUG) logger.addHandler(handler) async def main(): - conn = Connection("wss://echo.websocket.org") + conn = Connection("wss://euphoria.io/room/test/ws") + #conn = Connection("wss://euphoria.io/room/cabal/ws") # password protected print() print(" DISCONNECTING TWICE AT THE SAME TIME") diff --git a/yaboli/client.py b/yaboli/client.py index ee868cf..be3e364 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -1,9 +1,9 @@ +from typing import List, Optional + from .message import Message from .room import Room from .user import User -from typing import List, Optional - __all__ = ["Client"] class Client: @@ -12,8 +12,8 @@ class Client: async def join(self, room_name: str, - password: str = None, - nick: str = None) -> Room: + password: Optional[str] = None, + nick: str = "") -> Room: pass async def get(self, room_name: str) -> Optional[Room]: diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py index 28f2cfb..8601641 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -1,6 +1,6 @@ __all__ = [ "EuphException", - # Connection stuff + # Connection exceptions "IncorrectStateException", "ConnectionClosedException", # Joining a room @@ -9,16 +9,12 @@ __all__ = [ "CouldNotAuthenticateException", # Doing stuff in a room "RoomClosedException", - # Other stuff - "RateLimitException", - "NotLoggedInException", - "UnauthorizedException", ] class EuphException(Exception): pass -# Connection stuff +# Connection exceptions class IncorrectStateException(EuphException): """ @@ -64,16 +60,3 @@ class RoomClosedException(EuphException): completed. """ pass - -# exception for having no username? - -# Maybe these will become real exceptions one day? - -class RateLimitException(EuphException): - pass - -class NotLoggedInException(EuphException): - pass - -class UnauthorizedException(EuphException): - pass diff --git a/yaboli/message.py b/yaboli/message.py index 088cbe7..e985c05 100644 --- a/yaboli/message.py +++ b/yaboli/message.py @@ -1,7 +1,7 @@ -from .user import User, LiveUser - -from typing import TYPE_CHECKING, Optional import datetime +from typing import TYPE_CHECKING, Optional + +from .user import LiveUser, User if TYPE_CHECKING: from .client import Client diff --git a/yaboli/room.py b/yaboli/room.py index 5d36c54..092c7b4 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,9 +1,9 @@ +from typing import List, Optional + from .exceptions import * from .message import LiveMessage from .user import LiveUser -from typing import List, Optional - __all__ = ["Room"] class Room: @@ -65,8 +65,8 @@ class Room: def __init__(self, room_name: str, - nick: str = None, - password: str = None): + nick: str = "", + password: Optional[str] = None) -> None: pass self.closed = False diff --git a/yaboli/user.py b/yaboli/user.py index 053315c..f97d7d7 100644 --- a/yaboli/user.py +++ b/yaboli/user.py @@ -1,7 +1,7 @@ -from .util import mention, atmention - from typing import TYPE_CHECKING +from .util import atmention, mention + if TYPE_CHECKING: from .client import Client from .room import Room From b437731c7f17821e33a66700ec5f5e07bbcce6c3 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Apr 2019 19:46:30 +0000 Subject: [PATCH 076/145] Improve pinging It actually resets the ping check now. Also, I'm now using the event callbacks for sending the ping-reply, which only seems appropriate. --- yaboli/connection.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index eaf5c54..7c3d8d6 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -64,15 +64,15 @@ class Connection: - "reconnecting" : No arguments - "reconnected" : No arguments - "disconnecting" : No arguments - - "on_": the packet, parsed as JSON + - "": the packet, parsed as JSON Events ending with "-ing" ("reconnecting", "disconnecting") are fired at the beginning of the process they represent. Events ending with "-ed" ("connected", "reconnected") are fired after the process they represent has finished. - Examples for the last category of events include "on_message-event", - "on_part-event" and "on_ping". + Examples for the last category of events include "message-event", + "part-event" and "ping". """ # Maximum duration between euphoria's ping messages. Euphoria usually sends @@ -114,6 +114,8 @@ class Connection: self._awaiting_replies: Optional[Dict[str, asyncio.Future[Any]]] = None self._ping_check: Optional[asyncio.Task[None]] = None + self.register_event("ping-event", self._ping_pong) + def register_event(self, event: str, callback: Callable[..., Awaitable[None]] @@ -438,16 +440,13 @@ class Connection: # Then, send the corresponding event packet_type = packet["type"] - self._events.fire(f"on_{packet_type}", packet) + self._events.fire(packet_type, packet) - # Finally, if it's a ping command, reply as per - # http://api.euphoria.io/#ping - if packet_type == "ping-event": - logger.debug("Pong!") - asyncio.create_task(self._send_if_possible( - "ping-reply", - {"time": packet["data"]["time"]} - )) + # Finally, reset the ping check + logger.debug("Resetting ping check") + self._ping_check.cancel() + self._ping_check = asyncio.create_task( + self._disconnect_in(self.PING_TIMEOUT)) async def _send_if_possible(self, packet_type: str, data: Any,) -> None: """ @@ -462,6 +461,13 @@ class Connection: except IncorrectStateException: logger.debug("Could not send (disconnecting or already disconnected)") + async def _ping_pong(self, packet): + # Implements http://api.euphoria.io/#ping and is called as "ping-event" + # callback + logger.debug("Pong!") + await self._send_if_possible("ping-reply", + {"time": packet["data"]["time"]}) + async def send(self, packet_type: str, data: Any, From 20f635a7ae805cde010e145645a367072ad4c1d5 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Apr 2019 10:54:16 +0000 Subject: [PATCH 077/145] Clean up pinging --- yaboli/connection.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 7c3d8d6..92fe6b3 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -24,6 +24,9 @@ class Connection: - Keeping the connection alive (ping, ping-reply packets) - Reconnecting (timeout while connecting, no pings received in some time) + It doesn't respond to any events other than the ping-event and is otherwise + "dumb". + Life cycle of a Connection: @@ -448,6 +451,15 @@ class Connection: self._ping_check = asyncio.create_task( self._disconnect_in(self.PING_TIMEOUT)) + async def _do_if_possible(self, coroutine: Awaitable[None]) -> None: + """ + Try to run a coroutine, ignoring any IncorrectStateExceptions. + """ + try: + await coroutine + except IncorrectStateException: + pass + async def _send_if_possible(self, packet_type: str, data: Any,) -> None: """ This function tries to send a packet without awaiting the reply. @@ -462,11 +474,16 @@ class Connection: logger.debug("Could not send (disconnecting or already disconnected)") async def _ping_pong(self, packet): - # Implements http://api.euphoria.io/#ping and is called as "ping-event" - # callback + """ + Implements http://api.euphoria.io/#ping and is called as "ping-event" + callback. + """ logger.debug("Pong!") - await self._send_if_possible("ping-reply", - {"time": packet["data"]["time"]}) + await self._do_if_possible(self.send( + "ping-reply", + {"time": packet["data"]["time"]} + await_reply=False + )) async def send(self, packet_type: str, From 40edcdc7911e6ce9ff4cbef69c5282d5034babf6 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Apr 2019 20:31:00 +0000 Subject: [PATCH 078/145] Implement Room First, the Room itself. --- test.py | 36 ++-- yaboli/__init__.py | 5 +- yaboli/client.py | 2 - yaboli/connection.py | 12 +- yaboli/exceptions.py | 17 ++ yaboli/message.py | 112 ++-------- yaboli/room.py | 499 ++++++++++++++++++++++++++++++++++++------- yaboli/session.py | 71 ++++++ yaboli/user.py | 91 -------- 9 files changed, 554 insertions(+), 291 deletions(-) create mode 100644 yaboli/session.py delete mode 100644 yaboli/user.py diff --git a/test.py b/test.py index 98228f1..a4ba165 100644 --- a/test.py +++ b/test.py @@ -4,7 +4,7 @@ import asyncio import logging -from yaboli import Connection +from yaboli import Room FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" DATE_FORMAT = "%F %T" @@ -19,29 +19,17 @@ logger = logging.getLogger('yaboli') logger.setLevel(logging.DEBUG) logger.addHandler(handler) +class TestClient: + def __init__(self): + self.room = Room("test", target_nick="testbot") + self.stop = asyncio.Event() + + async def run(self): + await self.room.connect() + await self.stop.wait() + async def main(): - conn = Connection("wss://euphoria.io/room/test/ws") - #conn = Connection("wss://euphoria.io/room/cabal/ws") # password protected - - print() - print(" DISCONNECTING TWICE AT THE SAME TIME") - print("Connected successfully:", await conn.connect()) - a = asyncio.create_task(conn.disconnect()) - b = asyncio.create_task(conn.disconnect()) - await a - await b - - print() - print(" DISCONNECTING WHILE CONNECTING (test not working properly)") - asyncio.create_task(conn.disconnect()) - await asyncio.sleep(0) - print("Connected successfully:", await conn.connect()) - await conn.disconnect() - - print() - print(" WAITING FOR PING TIMEOUT") - print("Connected successfully:", await conn.connect()) - await asyncio.sleep(conn.PING_TIMEOUT + 10) - await conn.disconnect() + tc = TestClient() + await tc.run() asyncio.run(main()) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 24b21f5..e8b9e1f 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -6,7 +6,7 @@ from .events import * from .exceptions import * from .message import * from .room import * -from .user import * +from .session import * from .util import * __all__: List[str] = [] @@ -16,4 +16,5 @@ __all__ += events.__all__ __all__ += exceptions.__all__ __all__ += message.__all__ __all__ += room.__all__ -__all__ += user.__all__ +__all__ += session.__all__ +__all__ += util.__all__ diff --git a/yaboli/client.py b/yaboli/client.py index be3e364..c405884 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -1,8 +1,6 @@ from typing import List, Optional -from .message import Message from .room import Room -from .user import User __all__ = ["Client"] diff --git a/yaboli/connection.py b/yaboli/connection.py index 92fe6b3..137d060 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -13,6 +13,9 @@ logger = logging.getLogger(__name__) __all__ = ["Connection"] +# This class could probably be cleaned up by introducing one or two well-placed +# Locks – something for the next rewrite :P + class Connection: """ The Connection handles the lower-level stuff required when connecting to @@ -447,7 +450,8 @@ class Connection: # Finally, reset the ping check logger.debug("Resetting ping check") - self._ping_check.cancel() + if self._ping_check is not None: + self._ping_check.cancel() self._ping_check = asyncio.create_task( self._disconnect_in(self.PING_TIMEOUT)) @@ -473,7 +477,7 @@ class Connection: except IncorrectStateException: logger.debug("Could not send (disconnecting or already disconnected)") - async def _ping_pong(self, packet): + async def _ping_pong(self, packet: Any) -> None: """ Implements http://api.euphoria.io/#ping and is called as "ping-event" callback. @@ -481,7 +485,7 @@ class Connection: logger.debug("Pong!") await self._do_if_possible(self.send( "ping-reply", - {"time": packet["data"]["time"]} + {"time": packet["data"]["time"]}, await_reply=False )) @@ -489,7 +493,7 @@ class Connection: packet_type: str, data: Any, await_reply: bool = True - ) -> Optional[Any]: + ) -> Any: """ Send a packet of type packet_type to the server. diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py index 8601641..cf9d94e 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -8,6 +8,8 @@ __all__ = [ "CouldNotConnectException", "CouldNotAuthenticateException", # Doing stuff in a room + "RoomNotConnectedException", + "EuphError", "RoomClosedException", ] @@ -52,6 +54,21 @@ class CouldNotAuthenticateException(JoinException): # Doing stuff in a room +class RoomNotConnectedException(EuphException): + """ + Either the Room's connect() function has not been called or it has not + completed successfully. + """ + pass + +class EuphError(EuphException): + """ + The euphoria server has sent back an "error" field in its response. + """ + pass + +# TODO This exception is not used currently, decide on whether to keep it or +# throw it away class RoomClosedException(EuphException): """ The room has been closed already. diff --git a/yaboli/message.py b/yaboli/message.py index e985c05..9e6c21e 100644 --- a/yaboli/message.py +++ b/yaboli/message.py @@ -1,108 +1,30 @@ import datetime -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional -from .user import LiveUser, User +from .session import LiveSession, Session if TYPE_CHECKING: - from .client import Client from .room import Room __all__ = ["Message", "LiveMessage"] -# "Offline" message class Message: - def __init__(self, - room_name: str, - id_: str, - parent_id: Optional[str], - timestamp: int, - sender: User, - content: str, - deleted: bool, - truncated: bool): - self._room_name = room_name - self._id = id_ - self._parent_id = parent_id - self._timestamp = timestamp - self._sender = sender - self._content = content - self._deleted = deleted - self._truncated = truncated + pass +# @property +# def room_name(self) -> str: +# return self._room_name +# +# @property +# def time(self) -> datetime.datetime: +# return datetime.datetime.fromtimestamp(self.timestamp) +# +# @property +# def timestamp(self) -> int: +# return self._timestamp - @property - def room_name(self) -> str: - return self._room_name - - @property - def id(self) -> str: - return self._id - - @property - def parent_id(self) -> Optional[str]: - return self._parent_id - - @property - def time(self) -> datetime.datetime: - return datetime.datetime.fromtimestamp(self.timestamp) - - @property - def timestamp(self) -> int: - return self._timestamp - - @property - def sender(self) -> User: - return self._sender - - @property - def content(self) -> str: - return self._content - - @property - def deleted(self) -> bool: - return self._deleted - - @property - def truncated(self) -> bool: - return self._truncated - -# "Online" message -# has a few nice functions class LiveMessage(Message): - def __init__(self, - client: 'Client', - room: 'Room', - id_: str, - parent_id: Optional[str], - timestamp: int, - sender: LiveUser, - content: str, - deleted: bool, - truncated: bool): - self._client = client - super().__init__(room.name, id_, parent_id, timestamp, sender, content, - deleted, truncated) - self._room = room - # The typechecker can't use self._sender directly, because it has type - # User. - # - # TODO Find a way to satisfy the type checker without having this - # duplicate around, if possible? - self._livesender = sender + pass - @property - def room(self) -> 'Room': - return self._room - - @property - def sender(self) -> LiveUser: - return self._livesender - - async def reply(self, text: str) -> None: - pass - - # TODO add some sort of permission guard that checks the room - # UnauthorizedException - async def delete(self, - deleted: bool = True - ) -> None: + @classmethod + def from_data(cls, room: "Room", data: Any) -> "LiveMessage": pass diff --git a/yaboli/room.py b/yaboli/room.py index 092c7b4..c25baa1 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,107 +1,460 @@ -from typing import List, Optional +import asyncio +import logging +from typing import Any, Awaitable, Callable, List, Optional, TypeVar +from .connection import Connection +from .events import Events from .exceptions import * from .message import LiveMessage -from .user import LiveUser +from .session import Account, LiveSession, LiveSessionListing + +logger = logging.getLogger(__name__) __all__ = ["Room"] +T = TypeVar("T") + + class Room: """ - A Room represents one connection to a room on euphoria, i. e. what other - implementations might consider a "client". This means that each Room has - its own session (User) and nick. + Events and parameters: - A Room can only be used once in the sense that after it has been closed, - any further actions will result in a RoomClosedException. If you need to - manually reconnect, instead just create a new Room object. + "snapshot" - snapshot of the room's messages at the time of joining + messages: List[LiveMessage] + "send" - another room member has sent a message + message: LiveMessage + "join" - somebody has joined the room + user: LiveUser - Life cycle of a Room + "part" - somebody has left the room + user: LiveUser - 1. create a new Room and register callbacks - 2. await join() - 3. do room-related stuff - 4. await part() + "nick" - another room member has changed their nick + user: LiveUser + from: str + to: str + "edit" - a message in the room has been modified or deleted + message: LiveMessage + "pm" - another session initiated a pm with you + from: str - the id of the user inviting the client to chat privately + from_nick: str - the nick of the inviting user + from_room: str - the room where the invitation was sent from + pm_id: str - the private chat can be accessed at /room/pm:PMID - IN PHASE 1, a password and a starting nick can be set. The password and - current nick are used when first connecting to the room, or when - reconnecting to the room after connection was lost. - - Usually, event callbacks are also registered during this phase. - - - - IN PHASE 2, the Room creates the initial connection to euphoria and - performs initialisations (i. e. authentication or setting the nick) where - necessary. It also starts the Room's main event loop. The join() function - returns once one of the following cases has occurred: - - 1. the room is now in phase 3, in which case join() returns None - 2. the room could not be joined, in which case one of the JoinExceptions is - returned - - - - IN PHASE 3, the usual room-related functions like say() or nick() are - available. The Room's event loop is running. - - The room will automatically reconnect if it loses connection to euphoria. - The usual room-related functions will block until the room has successfully - reconnected. - - - - IN PHASE 4, the Room is disconnected and the event loop stopped. During and - after completion of this phase, the Room is considered closed. Any further - attempts to re-join or call room action functions will result in a - RoomClosedException. + "disconect" - corresponds to http://api.euphoria.io/#disconnect-event (if + the reason is "authentication changed", the room automatically reconnects) + reason: str - the reason for disconnection """ - # Phase 1 + URL_FORMAT = "wss://euphoria.io/room/{}/ws" def __init__(self, - room_name: str, - nick: str = "", - password: Optional[str] = None) -> None: - pass + name: str, + password: Optional[str] = None, + target_nick: str = "", + url_format: str = URL_FORMAT + ) -> None: + self._name = name + self._password = password + self._target_nick = target_nick + self._url_format = url_format - self.closed = False + self._session: Optional[LiveSession] = None + self._account: Optional[Account] = None + self._private: Optional[bool] = None + self._version: Optional[str] = None + self._users: Optional[LiveSessionListing] = None + self._pm_with_nick: Optional[str] = None + self._pm_with_user_id: Optional[str] = None + self._server_version: Optional[str] = None - # Phase 2 + # Connected management + self._url = self._url_format.format(self._name) + self._connection = Connection(self._url) + self._events = Events() - # Phase 3 + self._connected = asyncio.Event() + self._connected_successfully = False + self._hello_received = False + self._snapshot_received = False - def _ensure_open(self) -> None: - if self.closed: - raise RoomClosedException() + self._connection.register_event("reconnecting", self._on_reconnecting) + self._connection.register_event("hello-event", self._on_hello_event) + self._connection.register_event("snapshot-event", self._on_snapshot_event) + self._connection.register_event("bounce-event", self._on_bounce_event) - async def _ensure_joined(self) -> None: - pass + self._connection.register_event("disconnect-event", self._on_disconnect_event) + self._connection.register_event("join-event", self._on_join_event) + self._connection.register_event("login-event", self._on_login_event) + self._connection.register_event("logout-event", self._on_logout_event) + self._connection.register_event("network-event", self._on_network_event) + self._connection.register_event("nick-event", self._on_nick_event) + self._connection.register_event("edit-message-event", self._on_edit_message_event) + self._connection.register_event("part-event", self._on_part_event) + self._connection.register_event("pm-initiate-event", self._on_pm_initiate_event) + self._connection.register_event("send-event", self._on_send_event) - async def _ensure(self) -> None: - self._ensure_open() - await self._ensure_joined() + def register_event(self, + event: str, + callback: Callable[..., Awaitable[None]] + ) -> None: + """ + Register an event callback. - # Phase 4 + For an overview of the possible events, see the Room docstring. + """ - # Other stuff + self._events.register(event, callback) + + # Connecting, reconnecting and disconnecting + + def _set_connected(self) -> None: + packets_received = self._hello_received and self._snapshot_received + if packets_received and not self._connected.is_set(): + self._connected_successfully = True + self._connected.set() + + def _set_connected_failed(self) -> None: + if not self._connected.is_set(): + self._connected_successfully = False + self._connected.set() + + def _set_connected_reset(self) -> None: + self._connected.clear() + self._connected_successfully = False + self._hello_received = False + self._snapshot_received = False + + async def _on_reconnecting(self) -> None: + self._set_connected_reset() + + async def _on_hello_event(self, packet: Any) -> None: + data = packet["data"] + + self._session = LiveSession.from_data(self, data["session"]) + self._account = Account.from_data(data) + self._private = data["room_is_private"] + self._version = data["version"] + + self._hello_received = True + self._set_connected() + + async def _on_snapshot_event(self, packet: Any) -> None: + data = packet["data"] + + self._server_version = data["version"] + self._users = LiveSessionListing.from_data(self, data["listing"]) + self._pm_with_nick = data.get("pm_with_nick") + self._pm_with_user_id = data.get("pm_with_user_id") + + # Update session nick + nick = data.get("nick") + if nick is not None and self._session is not None: + self._session = self.session.with_nick(nick) + + # Send "session" event + messages = [LiveMessage.from_data(self, msg_data) + for msg_data in data["log"]] + self._events.fire("session", messages) + + self._snapshot_received = True + self._set_connected() + + async def _on_bounce_event(self, packet: Any) -> None: + data = packet["data"] + + # Can we even authenticate? + if not "passcode" in data.get("auth_options", []): + self._set_connected_failed() + return + + # If so, do we have a password? + if self._password is None: + self._set_connected_failed() + return + + reply = await self._connection.send( + "auth", + {"type": "passcode", "passcode": self._password} + ) + + if not reply["data"]["success"]: + self._set_connected_failed() + + async def connect(self) -> bool: + """ + Attempt to connect to the room and start handling events. + + This function returns once the Room is fully connected, i. e. + authenticated, using the correct nick and able to post messages. + """ + + if not await self._connection.connect(): + return False + + await self._connected.wait() + if not self._connected_successfully: + return False + + if self._session is None or self._target_nick != self._session.nick: + await self._nick(self._target_nick) + + return True + + async def disconnect(self) -> None: + """ + Disconnect from the room and stop the Room. + + This function has the potential to mess things up, and it has not yet + been tested thoroughly. Use at your own risk, especially if you want to + call connect() after calling disconnect(). + """ + + self._set_connected_reset() + await self._connection.disconnect() + + # Other events + + async def _on_disconnect_event(self, packet: Any) -> None: + reason = packet["data"]["reason"] + + if reason == "authentication changed": + await self._connection.reconnect() + + self._events.fire("disconnect", reason) + + async def _on_join_event(self, packet: Any) -> None: + data = packet["data"] + + session = LiveSession.from_data(self, data) + self._users = self.users.with_join(session) + + self._events.fire("join", session) + + async def _on_login_event(self, packet: Any) -> None: + pass # TODO implement once cookie support is here + + async def _on_logout_event(self, packet: Any) -> None: + pass # TODO implement once cookie support is here + + async def _on_network_event(self, packet: Any) -> None: + data = packet["data"] + + if data["type"] == "partition": + server_id = data["server_id"] + server_era = data["server_era"] + + users = self.users + + for user in self.users: + if user.server_id == server_id and user.server_era == server_era: + users = users.with_part(user) + self._events.fire("part", user) + + self._users = users + + async def _on_nick_event(self, packet: Any) -> None: + data = packet["data"] + session_id = data["session_id"] + nick_from = data["from"] + nick_to = data["to"] + + session = self.users.get(session_id) + if session is not None: + self._users = self.users.with_nick(session, nick_to) + else: + await self.who() # recalibrating self._users + + self._events.fire("nick", session, nick_from, nick_to) + + async def _on_edit_message_event(self, packet: Any) -> None: + data = packet["data"] + + message = LiveMessage.from_data(self, data) + + self._events.fire("edit", message) + + async def _on_part_event(self, packet: Any) -> None: + data = packet["data"] + + session = LiveSession.from_data(self, data) + self._users = self.users.with_part(session) + + self._events.fire("part", session) + + async def _on_pm_initiate_event(self, packet: Any) -> None: + data = packet["data"] + from_id = data["from"] + from_nick = data["from_nick"] + from_room = data["from_room"] + pm_id = data["pm_id"] + + self._events.fire("pm", from_id, from_nick, from_room, pm_id) + + async def _on_send_event(self, packet: Any) -> None: + data = packet["data"] + + message = LiveMessage.from_data(self, data) + + self._events.fire("send", message) + + # Attributes, ordered the same as in __init__ + + def _wrap_optional(self, x: Optional[T]) -> T: + if x is None: + raise RoomNotConnectedException() + + return x @property def name(self) -> str: - pass - - async def say(self, - text: str, - parent_id: Optional[str] = None - ) -> LiveMessage: - pass + return self._name @property - def users(self) -> List[LiveUser]: - pass + def password(self) -> Optional[str]: + return self._password - # retrieving messages + @property + def target_nick(self) -> str: + return self._target_nick + + @property + def url_format(self) -> str: + return self._url_format + + @property + def session(self) -> LiveSession: + return self._wrap_optional(self._session) + + @property + def account(self) -> Account: + return self._wrap_optional(self._account) + + @property + def private(self) -> bool: + return self._wrap_optional(self._private) + + @property + def version(self) -> str: + return self._wrap_optional(self._version) + + @property + def users(self) -> LiveSessionListing: + return self._wrap_optional(self._users) + + @property + def pm_with_nick(self) -> str: + return self._wrap_optional(self._pm_with_nick) + + @property + def pm_with_user_id(self) -> str: + return self._wrap_optional(self._pm_with_user_id) + + @property + def url(self) -> str: + return self._url + + # Functionality + + # These functions require cookie support and are thus not implemented yet: + # + # login, logout, pm + + def _extract_data(self, packet: Any) -> Any: + error = packet.get("error") + if error is not None: + raise EuphError(error) + + return packet["data"] + + async def _ensure_connected(self) -> None: + await self._connected.wait() + + if not self._connected_successfully: + raise RoomNotConnectedException() + + async def send(self, + content: str, + parent_id: Optional[str] = None + ) -> LiveMessage: + await self._ensure_connected() + + data = {"content": content} + if parent_id is not None: + data["parent"] = parent_id + + reply = await self._connection.send("send", data) + data = self._extract_data(reply) + + return LiveMessage.from_data(self, data) + + async def _nick(self, nick: str) -> str: + """ + This function implements all of the nick-setting logic except waiting + for the room to actually connect. This is because connect() actually + uses this function to set the desired nick before the room is + connected. + """ + + logger.debug(f"Setting nick to {nick!r}") + + self._target_nick = nick + + reply = await self._connection.send("nick", {"name": nick}) + data = self._extract_data(reply) + + new_nick = data["to"] + self._target_nick = new_nick + + logger.debug(f"Set nick to {new_nick!r}") + + return new_nick + + async def nick(self, nick: str) -> str: + await self._ensure_connected() + + return await self._nick(nick) + + async def get(self, message_id: str) -> LiveMessage: + await self._ensure_connected() + + reply = await self._connection.send("get-message", {"id": message_id}) + data = self._extract_data(reply) + + return LiveMessage.from_data(self, data) + + async def log(self, + amount: int, + before_id: Optional[str] = None + ) -> List[LiveMessage]: + await self._ensure_connected() + + data: Any = {"n": amount} + if before_id is not None: + data["before"] = before_id + + reply = await self._connection.send("log", data) + data = self._extract_data(reply) + + messages = [LiveMessage.from_data(self, msg_data) + for msg_data in data["log"]] + return messages + + async def who(self) -> LiveSessionListing: + await self._ensure_connected() + + reply = await self._connection.send("who", {}) + data = self._extract_data(reply) + + own_id = self._session.session_id if self._session is not None else None + self._users = LiveSessionListing.from_data( + self, + data["listing"], + exclude_id = own_id + ) + + return self._users diff --git a/yaboli/session.py b/yaboli/session.py new file mode 100644 index 0000000..114a5c0 --- /dev/null +++ b/yaboli/session.py @@ -0,0 +1,71 @@ +from typing import TYPE_CHECKING, Any, Iterator, Optional + +if TYPE_CHECKING: + from .room import Room + +__all__ = ["Account", "Session", "LiveSession", "LiveSessionListing"] + +class Account: + pass + + @classmethod + def from_data(cls, data: Any) -> "Account": + pass + +class Session: + pass + + @property + def nick(self) -> str: + pass + + @property + def session_id(self) -> str: + pass + +class LiveSession(Session): + pass + + @classmethod + def from_data(cls, room: "Room", data: Any) -> "LiveSession": + pass + + @property + def server_id(self) -> str: + pass + + @property + def server_era(self) -> str: + pass + + def with_nick(self, nick: str) -> "LiveSession": + pass + +class LiveSessionListing: + pass + + def __iter__(self) -> Iterator[LiveSession]: + pass + + @classmethod + def from_data(cls, + room: "Room", + data: Any, + exclude_id: Optional[str] = None + ) -> "LiveSessionListing": + pass + + def get(self, session_id: str) -> Optional[LiveSession]: + pass + + def with_join(self, session: LiveSession) -> "LiveSessionListing": + pass + + def with_part(self, session: LiveSession) -> "LiveSessionListing": + pass + + def with_nick(self, + session: LiveSession, + new_nick: str + ) -> "LiveSessionListing": + pass diff --git a/yaboli/user.py b/yaboli/user.py deleted file mode 100644 index f97d7d7..0000000 --- a/yaboli/user.py +++ /dev/null @@ -1,91 +0,0 @@ -from typing import TYPE_CHECKING - -from .util import atmention, mention - -if TYPE_CHECKING: - from .client import Client - from .room import Room - -__all__ = ["User", "LiveUser"] - -class User: - def __init__(self, - room_name: str, - id_: str, - name: str, - is_staff: bool, - is_manager: bool): - self._room_name = room_name - self._id = id_ - self._name = name - self._is_staff = is_staff - self._is_manager = is_manager - - @property - def room_name(self) -> str: - return self._room_name - - @property - def id(self) -> str: - return self._id - - @property - def name(self) -> str: - # no name = empty str - return self._name - - @property - def is_staff(self) -> bool: - return self._is_staff - - @property - def is_manager(self) -> bool: - return self._is_manager - - @property - def is_account(self) -> bool: - pass - - @property - def is_agent(self) -> bool: - # TODO should catch all old ids too - pass - - @property - def is_bot(self) -> bool: - pass - - # TODO possibly add other fields - - # Properties here? Yeah sure, why not? - - @property - def mention(self) -> str: - return mention(self.name) - - @property - def atmention(self) -> str: - return atmention(self.name) - -class LiveUser(User): - def __init__(self, - client: 'Client', - room: 'Room', - id_: str, - name: str, - is_staff: bool, - is_manager: bool): - super().__init__(room.name, id_, name, is_staff, is_manager) - self._room = room - - @property - def room(self) -> 'Room': - return self._room - - # NotLoggedInException - async def pm(self) -> 'Room': - pass - - # kick - # ban - # ip_ban From bf736430a6280cf0e920170778242c499f7f7262 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Apr 2019 01:00:11 +0000 Subject: [PATCH 079/145] Implement Session-related classes --- yaboli/session.py | 299 ++++++++++++++++++++++++++++++++++++++++++---- yaboli/util.py | 20 ++-- 2 files changed, 283 insertions(+), 36 deletions(-) diff --git a/yaboli/session.py b/yaboli/session.py index 114a5c0..bc6aae9 100644 --- a/yaboli/session.py +++ b/yaboli/session.py @@ -1,4 +1,7 @@ -from typing import TYPE_CHECKING, Any, Iterator, Optional +import re +from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional + +from .util import mention, normalize if TYPE_CHECKING: from .room import Room @@ -6,46 +9,249 @@ if TYPE_CHECKING: __all__ = ["Account", "Session", "LiveSession", "LiveSessionListing"] class Account: - pass + """ + This class represents a http://api.euphoria.io/#personalaccountview, with a + few added fields stolen from the hello-event (see + http://api.euphoria.io/#hello-event). + """ + + def __init__(self, + account_id: str, + name: str, + email: str, + has_access: Optional[bool], + email_verified: Optional[bool] + ) -> None: + self._account_id = account_id + self._name = name + self._email = email + self._has_access = has_access + self._email_verified = email_verified @classmethod def from_data(cls, data: Any) -> "Account": - pass + """ + The data parameter must be the "data" part of a hello-event. + + If, in the future, a PersonalAccountView appears in other places, this + function might have to be changed. + """ + + view = data["account"] + + account_id = view["id"] + name = view["name"] + email = view["email"] + + has_access = data.get("account_has_access") + email_verified = data.get("account_email_verified") + + return cls(account_id, name, email, has_access, email_verified) + + # Attributes + + @property + def account_id(self) -> str: + return self._account_id + + @property + def name(self) -> str: + return self._name + + @property + def email(self) -> str: + return self._email + + @property + def has_access(self) -> Optional[bool]: + return self._has_access + + @property + def email_verified(self) -> Optional[bool]: + return self._email_verified class Session: - pass + _ID_SPLIT_RE = re.compile(r"(agent|account|bot):(.*)") + + def __init__(self, + room_name: str, + user_id: str, + nick: str, + server_id: str, + server_era: str, + session_id: str, + is_staff: bool, + is_manager: bool, + client_address: Optional[str] + ) -> None: + self._room_name = room_name + self._user_id = user_id + + self._id_type: Optional[str] + match = self._ID_SPLIT_RE.fullmatch(self._user_id) + if match is not None: + self._id_type = match.group(1) + else: + self._id_type = None + + self._nick = nick + 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 + + def _copy(self) -> "Session": + return Session(self.room_name, self.user_id, self.nick, self.server_id, + self.server_era, self.session_id, self.is_staff, + self.is_manager, self.client_address) + + @classmethod + def from_data(cls, room_name: str, data: Any) -> "Session": + user_id = data["id"] + nick = data["name"] + server_id = data["server_id"] + server_era = data["server_era"] + session_id = data["session_id"] + is_staff = data["is_staff"] + is_manager = data["is_manager"] + client_address = data.get("client_address") + + return cls(room_name, user_id, nick, server_id, server_era, session_id, + is_staff, is_manager, client_address) + + def with_nick(self, nick: str) -> "Session": + copy = self._copy() + copy._nick = nick + return copy + + # Attributes + + @property + def room_name(self) -> str: + return self._room_name + + @property + def user_id(self) -> str: + return self._user_id @property def nick(self) -> str: - pass - - @property - def session_id(self) -> str: - pass - -class LiveSession(Session): - pass - - @classmethod - def from_data(cls, room: "Room", data: Any) -> "LiveSession": - pass + return self._nick @property def server_id(self) -> str: - pass + return self._server_id @property def server_era(self) -> str: - pass + return self._server_era + + @property + def session_id(self) -> str: + return self._session_id + + @property + def is_staff(self) -> bool: + return self._is_staff + + @property + def is_manager(self) -> bool: + return self._is_manager + + @property + def client_address(self) -> Optional[str]: + return self._client_address + + @property + def mention(self) -> str: + return mention(self.nick, ping=False) + + @property + def atmention(self) -> str: + return mention(self.nick, ping=True) + + @property + def normalize(self) -> str: + return normalize(self.nick) + + @property + def is_person(self) -> bool: + return self._id_type is None or self._id_type in ["agent", "account"] + + @property + def is_agent(self) -> bool: + return self._id_type == "agent" + + @property + def is_account(self) -> bool: + return self._id_type == "account" + + @property + def is_bot(self) -> bool: + return self._id_type == "bot" + +class LiveSession(Session): + def __init__(self, + room: "Room", + user_id: str, + nick: str, + server_id: str, + server_era: str, + session_id: str, + is_staff: bool, + is_manager: bool, + client_address: Optional[str] + ) -> None: + super().__init__(room.name, user_id, nick, server_id, server_era, + session_id, is_staff, is_manager, client_address) + self._room = room + + def _copy(self) -> "LiveSession": + return self.from_session(self._room, self) + + # Ignoring the type discrepancy since it is more convenient this way + @classmethod + def from_data(cls, # type: ignore + room: "Room", + data: Any + ) -> "LiveSession": + return cls.from_session(room, Session.from_data(room.name, data)) + + @classmethod + def from_session(cls, room: "Room", session: Session) -> "LiveSession": + return cls(room, session.user_id, session.nick, session.server_id, + session.server_era, session.session_id, session.is_staff, + session.is_manager, session.client_address) def with_nick(self, nick: str) -> "LiveSession": - pass + copy = self._copy() + copy._nick = nick + return copy + + # Attributes + + @property + def room(self) -> "Room": + return self._room + + # Live stuff + + # TODO pm, once pm support is there. class LiveSessionListing: - pass + def __init__(self, room: "Room", sessions: Iterable[LiveSession]) -> None: + self._room = room + # just to make sure it doesn't get changed on us + self._sessions: Dict[str, LiveSession] = {session.session_id: session + for session in sessions} def __iter__(self) -> Iterator[LiveSession]: - pass + return self._sessions.values().__iter__() + + def _copy(self) -> "LiveSessionListing": + return LiveSessionListing(self.room, self) @classmethod def from_data(cls, @@ -53,19 +259,60 @@ class LiveSessionListing: data: Any, exclude_id: Optional[str] = None ) -> "LiveSessionListing": - pass + sessions = [LiveSession.from_data(room, subdata) for subdata in data] + + if exclude_id: + sessions = [session for session in sessions + if session.session_id != exclude_id] + + return cls(room, sessions) def get(self, session_id: str) -> Optional[LiveSession]: - pass + return self._sessions.get(session_id) def with_join(self, session: LiveSession) -> "LiveSessionListing": - pass + copy = self._copy() + copy._sessions[session.session_id] = session + return copy def with_part(self, session: LiveSession) -> "LiveSessionListing": - pass + copy = self._copy() + + if session.session_id in copy._sessions: + del copy._sessions[session.session_id] + + return copy def with_nick(self, session: LiveSession, new_nick: str ) -> "LiveSessionListing": - pass + copy = self._copy() + copy._sessions[session.session_id] = session.with_nick(new_nick) + return copy + + # Attributes + + @property + def room(self) -> "Room": + return self._room + + @property + def all(self) -> List[LiveSession]: + return list(self._sessions.values()) + + @property + def people(self) -> List[LiveSession]: + return [session for session in self if session.is_person] + + @property + def accounts(self) -> List[LiveSession]: + return [session for session in self if session.is_account] + + @property + def agents(self) -> List[LiveSession]: + return [session for session in self if session.is_agent] + + @property + def bots(self) -> List[LiveSession]: + return [session for session in self if session.is_bot] diff --git a/yaboli/util.py b/yaboli/util.py index 7dff6aa..27eda6e 100644 --- a/yaboli/util.py +++ b/yaboli/util.py @@ -1,15 +1,15 @@ -__all__ = ["mention", "atmention", "normalize", "compare"] +import re + +__all__ = ["mention", "normalize", "similar"] # Name/nick related functions -def mention(name: str) -> str: - pass +def mention(nick: str, ping: bool = False) -> str: + mentioned = re.sub(r"""[,.!?;&<'"\s]""", "", nick) + return "@" + mentioned if ping else mentioned -def atmention(name: str) -> str: - pass +def normalize(nick: str) -> str: + return mention(nick, ping=False).lower() -def normalize(name: str) -> str: - pass - -def compare(name_a: str, name_b: str) -> bool: - pass +def similar(nick_a: str, nick_b: str) -> bool: + return normalize(nick_a) == normalize(nick_b) From 9d0c5886852f9f3efd0545b447470f429f72aa18 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Apr 2019 01:00:30 +0000 Subject: [PATCH 080/145] Reset ping check on ping-events only --- yaboli/connection.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 137d060..aa8dd60 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -449,11 +449,12 @@ class Connection: self._events.fire(packet_type, packet) # Finally, reset the ping check - logger.debug("Resetting ping check") - if self._ping_check is not None: - self._ping_check.cancel() - self._ping_check = asyncio.create_task( - self._disconnect_in(self.PING_TIMEOUT)) + if packet_type == "ping-event": + logger.debug("Resetting ping check") + if self._ping_check is not None: + self._ping_check.cancel() + self._ping_check = asyncio.create_task( + self._disconnect_in(self.PING_TIMEOUT)) async def _do_if_possible(self, coroutine: Awaitable[None]) -> None: """ From 06af0e7faa81dafd93a2615357435e3f6b75e9cc Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Apr 2019 01:55:45 +0000 Subject: [PATCH 081/145] Fix minor crashes --- yaboli/room.py | 4 +++- yaboli/session.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/yaboli/room.py b/yaboli/room.py index c25baa1..a7d07d6 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -136,10 +136,12 @@ class Room: data = packet["data"] self._session = LiveSession.from_data(self, data["session"]) - self._account = Account.from_data(data) self._private = data["room_is_private"] self._version = data["version"] + if "account" in data: + self._account = Account.from_data(data) + self._hello_received = True self._set_connected() diff --git a/yaboli/session.py b/yaboli/session.py index bc6aae9..5adcbcb 100644 --- a/yaboli/session.py +++ b/yaboli/session.py @@ -114,8 +114,8 @@ class Session: server_id = data["server_id"] server_era = data["server_era"] session_id = data["session_id"] - is_staff = data["is_staff"] - is_manager = data["is_manager"] + is_staff = data.get("is_staff", False) + is_manager = data.get("is_manager", False) client_address = data.get("client_address") return cls(room_name, user_id, nick, server_id, server_era, session_id, From 97f05272caeb9000ec892ed23b5430cc314ab0ea Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Apr 2019 08:56:01 +0000 Subject: [PATCH 082/145] Implement Message related classes --- test.py | 23 ++++++ yaboli/message.py | 176 +++++++++++++++++++++++++++++++++++++++++----- yaboli/room.py | 2 + 3 files changed, 183 insertions(+), 18 deletions(-) diff --git a/test.py b/test.py index a4ba165..5af2d7b 100644 --- a/test.py +++ b/test.py @@ -22,12 +22,35 @@ logger.addHandler(handler) class TestClient: def __init__(self): self.room = Room("test", target_nick="testbot") + self.room.register_event("join", self.on_join) + self.room.register_event("part", self.on_part) + self.room.register_event("send", self.on_send) + self.stop = asyncio.Event() async def run(self): await self.room.connect() await self.stop.wait() + async def on_join(self, user): + print() + print(f"{user.nick} ({user.atmention}) joined.") + if user.is_person: + print("They're a person!") + elif user.is_bot: + print("They're just a bot") + else: + print("This should never happen") + print() + + async def on_part(self, user): + print(f"{user.nick} left") + + async def on_send(self, message): + await message.reply(f"You said {message.content!r}.") + msg1 = await message.room.send(f"{message.sender.atmention} said something.") + await msg1.reply("Yes, they really did.") + async def main(): tc = TestClient() await tc.run() diff --git a/yaboli/message.py b/yaboli/message.py index 9e6c21e..0e3c24b 100644 --- a/yaboli/message.py +++ b/yaboli/message.py @@ -1,5 +1,5 @@ import datetime -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, List, Optional from .session import LiveSession, Session @@ -9,22 +9,162 @@ if TYPE_CHECKING: __all__ = ["Message", "LiveMessage"] class Message: - pass -# @property -# def room_name(self) -> str: -# return self._room_name -# -# @property -# def time(self) -> datetime.datetime: -# return datetime.datetime.fromtimestamp(self.timestamp) -# -# @property -# def timestamp(self) -> int: -# return self._timestamp - -class LiveMessage(Message): - pass + def __init__(self, + room_name: str, + message_id: str, + parent_id: Optional[str], + previous_edit_id: Optional[str], + timestamp: int, + sender: Session, + content: str, + encryption_key_id: Optional[str], + edited_timestamp: Optional[int], + deleted_timestamp: Optional[int], + truncated: bool + ) -> None: + self._room_name = room_name + self._message_id = message_id + self._parent_id = parent_id + self._previous_edit_id = previous_edit_id + self._timestamp = timestamp + self._sender = sender + self._content = content + self._encryption_key_id = encryption_key_id + self._edited_timestamp = edited_timestamp + self._deleted_timestamp = deleted_timestamp + self._truncated = truncated @classmethod - def from_data(cls, room: "Room", data: Any) -> "LiveMessage": - pass + def from_data(cls, room_name: str, data: Any) -> "Message": + message_id = data["id"] + parent_id = data.get("parent") + previous_edit_id = data.get("previous_edit_id") + timestamp = data["time"] + sender = Session.from_data(room_name, data["sender"]) + content = data["content"] + encryption_key_id = data.get("encryption_key_id") + edited_timestamp = data.get("edited") + deleted_timestamp = data.get("deleted") + truncated = data.get("truncated", False) + + return cls(room_name, message_id, parent_id, previous_edit_id, + timestamp, sender, content, encryption_key_id, + edited_timestamp, deleted_timestamp, truncated) + + # Attributes + + @property + def room_name(self) -> str: + return self._room_name + + @property + def message_id(self) -> str: + return self._message_id + + @property + def parent_id(self) -> Optional[str]: + return self._parent_id + + @property + def previous_edit_id(self) -> Optional[str]: + return self._previous_edit_id + + @property + def time(self) -> datetime.datetime: + return datetime.datetime.fromtimestamp(self.timestamp) + + @property + def timestamp(self) -> int: + return self._timestamp + + @property + def sender(self) -> Session: + return self._sender + + @property + def content(self) -> str: + return self._content + + @property + def encryption_key_id(self) -> Optional[str]: + return self._encryption_key_id + + @property + def edited_time(self) -> Optional[datetime.datetime]: + if self.edited_timestamp is not None: + return datetime.datetime.fromtimestamp(self.edited_timestamp) + else: + return None + + @property + def edited_timestamp(self) -> Optional[int]: + return self._edited_timestamp + + @property + def deleted_time(self) -> Optional[datetime.datetime]: + if self.deleted_timestamp is not None: + return datetime.datetime.fromtimestamp(self.deleted_timestamp) + else: + return None + + @property + def deleted_timestamp(self) -> Optional[int]: + return self._deleted_timestamp + + @property + def truncated(self) -> bool: + return self._truncated + +class LiveMessage(Message): + def __init__(self, + room: "Room", + message_id: str, + parent_id: Optional[str], + previous_edit_id: Optional[str], + timestamp: int, + sender: LiveSession, + content: str, + encryption_key_id: Optional[str], + edited_timestamp: Optional[int], + deleted_timestamp: Optional[int], + truncated: bool + ) -> None: + super().__init__(room.name, message_id, parent_id, previous_edit_id, + timestamp, sender, content, encryption_key_id, + edited_timestamp, deleted_timestamp, truncated) + self._room = room + self._live_sender = sender + + @classmethod + def from_data(cls, # type: ignore + room: "Room", + data: Any + ) -> "LiveMessage": + return cls.from_message(room, Message.from_data(room.name, data)) + + @classmethod + def from_message(cls, room: "Room", message: Message) -> "LiveMessage": + live_sender = LiveSession.from_session(room, message.sender) + return cls(room, message.message_id, message.parent_id, + message.previous_edit_id, message.timestamp, live_sender, + message.content, message.encryption_key_id, + message.edited_timestamp, message.deleted_timestamp, + message.truncated) + + # Attributes + + @property + def room(self) -> "Room": + return self._room + + @property + def sender(self) -> LiveSession: + return self._live_sender + + # Live stuff + + async def reply(self, content: str) -> "LiveMessage": + return await self.room.send(content, parent_id=self.message_id) + + async def before(self, amount: int) -> List["LiveMessage"]: + return await self.room.log(amount, before_id=self.message_id) diff --git a/yaboli/room.py b/yaboli/room.py index a7d07d6..e470f5c 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,3 +1,5 @@ +# TODO add more logging + import asyncio import logging from typing import Any, Awaitable, Callable, List, Optional, TypeVar From 17d4fb216ea793d1091cb782b995ef969e09fdb1 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Apr 2019 09:01:08 +0000 Subject: [PATCH 083/145] Don't try to set an empty nick --- yaboli/room.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/yaboli/room.py b/yaboli/room.py index e470f5c..f984b28 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -204,7 +204,9 @@ class Room: if not self._connected_successfully: return False - if self._session is None or self._target_nick != self._session.nick: + nick_needs_updating = (self._session is None + or self._target_nick != self._session.nick) + if self._target_nick and nick_needs_updating: await self._nick(self._target_nick) return True From c3fa1bf396dc89c273162296a67eefa70bd85489 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Apr 2019 14:07:22 +0000 Subject: [PATCH 084/145] Add Client Some functionality is yet to be tested. --- test.py | 40 ++++----------- yaboli/client.py | 128 +++++++++++++++++++++++++++++++++++++++++++++-- yaboli/room.py | 7 ++- 3 files changed, 136 insertions(+), 39 deletions(-) diff --git a/test.py b/test.py index 5af2d7b..92e75e4 100644 --- a/test.py +++ b/test.py @@ -4,7 +4,7 @@ import asyncio import logging -from yaboli import Room +import yaboli FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" DATE_FORMAT = "%F %T" @@ -19,37 +19,17 @@ logger = logging.getLogger('yaboli') logger.setLevel(logging.DEBUG) logger.addHandler(handler) -class TestClient: - def __init__(self): - self.room = Room("test", target_nick="testbot") - self.room.register_event("join", self.on_join) - self.room.register_event("part", self.on_part) - self.room.register_event("send", self.on_send) +class TestClient(yaboli.Client): + DEFAULT_NICK = "testbot" - self.stop = asyncio.Event() + async def started(self): + await self.join("test") - async def run(self): - await self.room.connect() - await self.stop.wait() - - async def on_join(self, user): - print() - print(f"{user.nick} ({user.atmention}) joined.") - if user.is_person: - print("They're a person!") - elif user.is_bot: - print("They're just a bot") - else: - print("This should never happen") - print() - - async def on_part(self, user): - print(f"{user.nick} left") - - async def on_send(self, message): - await message.reply(f"You said {message.content!r}.") - msg1 = await message.room.send(f"{message.sender.atmention} said something.") - await msg1.reply("Yes, they really did.") + async def on_send(self, room, message): + if message.content == "!test": + await message.reply(f"You said {message.content!r}.") + msg1 = await room.send(f"{message.sender.atmention} said something.") + await msg1.reply("Yes, they really did.") async def main(): tc = TestClient() diff --git a/yaboli/client.py b/yaboli/client.py index c405884..3641e8b 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -1,21 +1,139 @@ -from typing import List, Optional +import asyncio +import functools +from typing import Dict, List, Optional +import logging +from .message import LiveMessage from .room import Room +from .session import LiveSession + +logger = logging.getLogger(__name__) __all__ = ["Client"] class Client: + DEFAULT_NICK = "" - # Joining and leaving rooms + def __init__(self) -> None: + self._rooms: Dict[str, List[Room]] = {} + self._stop = asyncio.Event() + + async def run(self) -> None: + await self.started() + await self._stop.wait() + + async def stop(self) -> None: + await self.stopping() + + tasks = [] + for rooms in self._rooms.values(): + for room in rooms: + tasks.append(asyncio.create_task(self.part(room))) + for task in tasks: + await task + + self._stop.set() + + # Managing rooms + + def get(self, room_name: str) -> Optional[Room]: + rooms = self._rooms.get(room_name) + if rooms: # None or [] are False-y + return rooms[0] + else: + return None + + def get_all(self, room_name: str) -> List[Room]: + return self._rooms.get(room_name, []) async def join(self, room_name: str, password: Optional[str] = None, - nick: str = "") -> Room: + nick: Optional[str] = None + ) -> Optional[Room]: + logger.info(f"Joining &{room_name}") + + if nick is None: + nick = self.DEFAULT_NICK + room = Room(room_name, password=password, target_nick=nick) + + room.register_event("snapshot", + functools.partial(self.on_snapshot, room)) + room.register_event("send", + functools.partial(self.on_send, room)) + room.register_event("join", + functools.partial(self.on_join, room)) + room.register_event("part", + functools.partial(self.on_part, room)) + room.register_event("nick", + functools.partial(self.on_nick, room)) + room.register_event("edit", + functools.partial(self.on_edit, room)) + room.register_event("pm", + functools.partial(self.on_pm, room)) + room.register_event("disconnect", + functools.partial(self.on_disconnect, room)) + + if await room.connect(): + rooms = self._rooms.get(room_name, []) + rooms.append(room) + self._rooms[room_name] = rooms + + return room + else: + logger.warn(f"Could not join &{room.name}") + return None + + async def part(self, room: Room) -> None: + logger.info(f"Leaving &{room.name}") + + rooms = self._rooms.get(room.name, []) + rooms = [r for r in rooms if r is not room] + self._rooms[room.name] = rooms + + await room.disconnect() + + # Management stuff - overwrite these functions + + async def started(self) -> None: pass - async def get(self, room_name: str) -> Optional[Room]: + async def stopping(self) -> None: pass - async def get_all(self, room_name: str) -> List[Room]: + # Event stuff - overwrite these functions + + async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None: + pass + + async def on_send(self, room: Room, message: LiveMessage) -> None: + pass + + async def on_join(self, room: Room, user: LiveSession) -> None: + pass + + async def on_part(self, room: Room, user: LiveSession) -> None: + pass + + async def on_nick(self, + room: Room, + user: LiveSession, + from_nick: str, + to_nick: str + ) -> None: + pass + + async def on_edit(self, message: LiveMessage) -> None: + pass + + async def on_pm(self, + room: Room, + from_id: str, + from_nick: str, + from_room: str, + pm_id: str + ) -> None: + pass + + async def on_disconnect(self, room: Room, reason: str) -> None: pass diff --git a/yaboli/room.py b/yaboli/room.py index f984b28..48d5af3 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -16,7 +16,6 @@ __all__ = ["Room"] T = TypeVar("T") - class Room: """ Events and parameters: @@ -28,13 +27,13 @@ class Room: message: LiveMessage "join" - somebody has joined the room - user: LiveUser + user: LiveSession "part" - somebody has left the room - user: LiveUser + user: LiveSession "nick" - another room member has changed their nick - user: LiveUser + user: LiveSession from: str to: str From ed2bd2a2c6511b3a764459beda30d8922e46604a Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Apr 2019 14:56:24 +0000 Subject: [PATCH 085/145] Add some logging to Room --- test.py | 8 ++++++-- yaboli/room.py | 5 +++++ yaboli/util.py | 5 ++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/test.py b/test.py index 92e75e4..6129252 100644 --- a/test.py +++ b/test.py @@ -6,7 +6,11 @@ import logging import yaboli -FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" +#FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" +#LEVEL = logging.DEBUG +FORMAT = "{asctime} [{levelname:<7}] <{name}>: {message}" +LEVEL = logging.INFO + DATE_FORMAT = "%F %T" handler = logging.StreamHandler() handler.setFormatter(logging.Formatter( @@ -16,7 +20,7 @@ handler.setFormatter(logging.Formatter( )) logger = logging.getLogger('yaboli') -logger.setLevel(logging.DEBUG) +logger.setLevel(LEVEL) logger.addHandler(handler) class TestClient(yaboli.Client): diff --git a/yaboli/room.py b/yaboli/room.py index 48d5af3..2fb8a92 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -9,6 +9,7 @@ from .events import Events from .exceptions import * from .message import LiveMessage from .session import Account, LiveSession, LiveSessionListing +from .util import atmention logger = logging.getLogger(__name__) @@ -238,6 +239,7 @@ class Room: session = LiveSession.from_data(self, data) self._users = self.users.with_join(session) + logger.info(f"{session.atmention} joined") self._events.fire("join", session) async def _on_login_event(self, packet: Any) -> None: @@ -258,6 +260,7 @@ class Room: for user in self.users: if user.server_id == server_id and user.server_era == server_era: users = users.with_part(user) + logger.info(f"{user.atmention} left") self._events.fire("part", user) self._users = users @@ -274,6 +277,7 @@ class Room: else: await self.who() # recalibrating self._users + logger.info(f"{atmention(nick_from)} is now called {atmention(nick_to)}") self._events.fire("nick", session, nick_from, nick_to) async def _on_edit_message_event(self, packet: Any) -> None: @@ -289,6 +293,7 @@ class Room: session = LiveSession.from_data(self, data) self._users = self.users.with_part(session) + logger.info(f"{session.atmention} left") self._events.fire("part", session) async def _on_pm_initiate_event(self, packet: Any) -> None: diff --git a/yaboli/util.py b/yaboli/util.py index 27eda6e..1d2069b 100644 --- a/yaboli/util.py +++ b/yaboli/util.py @@ -1,6 +1,6 @@ import re -__all__ = ["mention", "normalize", "similar"] +__all__ = ["mention", "atmention", "normalize", "similar"] # Name/nick related functions @@ -8,6 +8,9 @@ def mention(nick: str, ping: bool = False) -> str: mentioned = re.sub(r"""[,.!?;&<'"\s]""", "", nick) return "@" + mentioned if ping else mentioned +def atmention(nick: str) -> str: + return mention(nick, ping=True) + def normalize(nick: str) -> str: return mention(nick, ping=False).lower() From 4cd422493b3a50df2ab8e923e357def4fd8e8f38 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Apr 2019 17:07:30 +0000 Subject: [PATCH 086/145] Update own session on who command --- yaboli/room.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/yaboli/room.py b/yaboli/room.py index 2fb8a92..8526736 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -460,11 +460,13 @@ class Room: reply = await self._connection.send("who", {}) data = self._extract_data(reply) - own_id = self._session.session_id if self._session is not None else None - self._users = LiveSessionListing.from_data( - self, - data["listing"], - exclude_id = own_id - ) + users = LiveSessionListing.from_data(self, data["listing"]) + # Assumes that self._session is set (we're connected) + session = users.get(self.session.session_id) + if session is not None: + self._session = session + self._users = users.with_part(self._session) + else: + self._users = users return self._users From 72d10f5c4337db6a17f6c95a8382f34f59bde38c Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 11 Apr 2019 19:21:07 +0000 Subject: [PATCH 087/145] Implement command system --- yaboli/__init__.py | 2 + yaboli/command.py | 269 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 yaboli/command.py diff --git a/yaboli/__init__.py b/yaboli/__init__.py index e8b9e1f..10a2987 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,6 +1,7 @@ from typing import List from .client import * +from .command import * from .connection import * from .events import * from .exceptions import * @@ -11,6 +12,7 @@ from .util import * __all__: List[str] = [] __all__ += client.__all__ +__all__ += command.__all__ __all__ += connection.__all__ __all__ += events.__all__ __all__ += exceptions.__all__ diff --git a/yaboli/command.py b/yaboli/command.py new file mode 100644 index 0000000..99ce2ab --- /dev/null +++ b/yaboli/command.py @@ -0,0 +1,269 @@ +import abc +import re +from typing import (Awaitable, Callable, Dict, List, NamedTuple, Optional, + Pattern, Tuple) + +from .message import LiveMessage +from .room import Room +from .util import similar + +# Different ways of parsing commands: +# +# - raw string +# +# - split into arguments by whitespace +# - parsed into positional, optional, flags +# +# - The above two with or without bash-style escaping +# +# All of the above can be done with any argstr, even with an empty one. + +__all__ = ["FancyArgs", "ArgumentData", "SpecificArgumentData", "CommandData", + "Command", "GeneralCommandFunction", "GeneralCommand", + "SpecificCommandFunction", "SpecificCommand"] + +class FancyArgs(NamedTuple): + positional: List[str] + optional: Dict[str, Optional[str]] + flags: Dict[str, int] + +class ArgumentData: + def __init__(self, argstr: str) -> None: + self._argstr = argstr + + self._basic: Optional[List[str]] = None + self._basic_escaped: Optional[List[str]] = None + + self._fancy: Optional[FancyArgs] = None + self._fancy_escaped: Optional[FancyArgs] = None + + def _split_escaped(self, text: str) -> List[str]: + words: List[str] = [] + word: List[str] = [] + + backslash = False + quotes: Optional[str] = None + + for char in text: + if backslash: + backslash = False + word.append(char) + elif quotes is not None: + if char == quotes: + quotes = None + else: + word.append(char) + elif char.isspace(): + if word: + words.append("".join(word)) + word = [] + else: + word.append(char) + + # ignoring any left-over backslashes or open quotes at the end + + if word: + words.append("".join(word)) + + return words + + def _split(self, text: str, escaped: bool) -> List[str]: + if escaped: + return self._split_escaped(text) + else: + return text.split() + + def _parse_fancy(self, args: List[str]) -> FancyArgs: + raise NotImplementedError + + @property + def argstr(self) -> str: + return self._argstr + + def basic(self, escaped: bool = True) -> List[str]: + if escaped: + if self._basic_escaped is None: + self._basic_escaped = self._split(self._argstr, escaped) + return self._basic_escaped + else: + if self._basic is None: + self._basic = self._split(self._argstr, escaped) + return self._basic + + def fancy(self, escaped: bool = True) -> FancyArgs: + if escaped: + if self._fancy_escaped is None: + basic = self._split(self._argstr, escaped) + self._fancy_escaped = self._parse_fancy(basic) + return self._fancy_escaped + else: + if self._fancy is None: + basic = self._split(self._argstr, escaped) + self._fancy = self._parse_fancy(basic) + return self._fancy + +class SpecificArgumentData(ArgumentData): + def __init__(self, nick: str, argstr: str) -> None: + super().__init__(argstr) + + self._nick = nick + + @property + def nick(self) -> str: + return self._nick + +class CommandData: + _NAME_RE = re.compile(r"^!(\S+)") + _MENTION_RE = re.compile(r"^\s+@(\S+)") + + def __init__(self, + name: str, + general: ArgumentData, + specific: Optional[SpecificArgumentData] + ) -> None: + self._name = name + self._general = general + self._specific = specific + + @property + def name(self) -> str: + return self._name + + @property + def general(self) -> ArgumentData: + return self._general + + @property + def specific(self) -> Optional[SpecificArgumentData]: + return self._specific + + @staticmethod + def _take(pattern: Pattern, text: str) -> Optional[Tuple[str, str]]: + """ + Returns the pattern's first group and the rest of the string that + didn't get matched by the pattern. + + Anchoring the pattern to the beginning of the string is the + responsibility of the pattern writer. + """ + + match = pattern.match(text) + if not match: + return None + + group = match.group(1) + rest = text[match.end():] + + return group, rest + + @classmethod + def from_string(cls, string: str) -> "Optional[CommandData]": + # If it looks like it should work in the euphoria UI, it should work. + # Since euphoria strips whitespace chars from the beginning and end of + # messages, we do too. + string = string.strip() + + name_part = cls._take(cls._NAME_RE, string) + if name_part is None: return None + name, name_rest = name_part + + general = ArgumentData(name_rest) + + specific: Optional[SpecificArgumentData] + mention_part = cls._take(cls._MENTION_RE, name_rest) + if mention_part is None: + specific = None + else: + mention, rest = mention_part + specific = SpecificArgumentData(mention, rest) + + return cls(name, general, specific) + +class Command(abc.ABC): + def __init__(self, name: str) -> None: + self._name = name + + async def run(self, + room: Room, + message: LiveMessage, + nicks: List[str], + data: CommandData, + ) -> None: + if data.name == self._name: + await self._run(room, message, nicks, data) + + @abc.abstractmethod + async def _run(self, + room: Room, + message: LiveMessage, + nicks: List[str], + data: CommandData, + ) -> None: + pass + +# General command + +GeneralCommandFunction = Callable[[Room, LiveMessage, ArgumentData], + Awaitable[None]] + +class GeneralCommand(Command): + def __init__(self, + name: str, + cmdfunc: GeneralCommandFunction, + args: bool + ) -> None: + super().__init__(name) + + self._cmdfunc = cmdfunc + self._args = args + + async def _run(self, + room: Room, + message: LiveMessage, + nicks: List[str], + data: CommandData, + ) -> None: + # Do we have arguments if we shouldn't? + if not self._args and data.general.basic(): + return + + await self._cmdfunc(room, message, data.general) + +# Specific command + +SpecificCommandFunction = Callable[[Room, LiveMessage, SpecificArgumentData], + Awaitable[None]] + +class SpecificCommand(Command): + def __init__(self, + name: str, + cmdfunc: SpecificCommandFunction, + args: bool + ) -> None: + super().__init__(name) + + self._cmdfunc = cmdfunc + self._args = args + + async def _run(self, + room: Room, + message: LiveMessage, + nicks: List[str], + data: CommandData, + ) -> None: + # Is this a specific command? + if data.specific is None: + return + + # Are we being mentioned? + for nick in nicks: + if similar(nick, data.specific.nick): + break + else: + return # Yay, a rare occurrence of this structure! + + # Do we have arguments if we shouldn't? + if not self._args and data.specific.basic(): + return + + await self._cmdfunc(room, message, data.specific) From 82a292b5d863a08a20ffd982e367a3aa5b66ddd7 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 11 Apr 2019 21:32:20 +0000 Subject: [PATCH 088/145] Fix room not knowing its own nick initially --- yaboli/room.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/yaboli/room.py b/yaboli/room.py index 8526736..e2c267d 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -420,6 +420,9 @@ class Room: new_nick = data["to"] self._target_nick = new_nick + if self._session is not None: + self._session = self._session.with_nick(new_nick) + logger.debug(f"Set nick to {new_nick!r}") return new_nick From 27b3f4b29e6d7f04afdc55a42df0daf7175d9990 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 11 Apr 2019 21:33:17 +0000 Subject: [PATCH 089/145] Add Bot --- test.py | 39 ++++++++++++----- yaboli/__init__.py | 2 + yaboli/bot.py | 103 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 yaboli/bot.py diff --git a/test.py b/test.py index 6129252..fd7f735 100644 --- a/test.py +++ b/test.py @@ -6,10 +6,10 @@ import logging import yaboli -#FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" -#LEVEL = logging.DEBUG -FORMAT = "{asctime} [{levelname:<7}] <{name}>: {message}" -LEVEL = logging.INFO +FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" +LEVEL = logging.DEBUG +#FORMAT = "{asctime} [{levelname:<7}] <{name}>: {message}" +#LEVEL = logging.INFO DATE_FORMAT = "%F %T" handler = logging.StreamHandler() @@ -23,20 +23,39 @@ logger = logging.getLogger('yaboli') logger.setLevel(LEVEL) logger.addHandler(handler) -class TestClient(yaboli.Client): +class TestBot(yaboli.Bot): DEFAULT_NICK = "testbot" + def __init__(self): + super().__init__() + self.register_botrulez() + self.register_general("test", self.cmd_test, args=False) + self.register_general("who", self.cmd_who, args=False) + self.register_general("err", self.cmd_err, args=False) + async def started(self): await self.join("test") async def on_send(self, room, message): - if message.content == "!test": - await message.reply(f"You said {message.content!r}.") - msg1 = await room.send(f"{message.sender.atmention} said something.") - await msg1.reply("Yes, they really did.") + await self.process_commands(room, message, + aliases=["testalias", "aliastest"]) + + async def cmd_test(self, room, message, args): + await message.reply(f"You said {message.content!r}.") + msg1 = await room.send(f"{message.sender.atmention} said something.") + await msg1.reply("Yes, they really did.") + + async def cmd_who(self, room, message, args): + lines = [] + for user in await room.who(): + lines.append(repr(user.nick)) + await message.reply("\n".join(lines)) + + async def cmd_err(self, room, message, args): + await message.reply(str(1/0)) async def main(): - tc = TestClient() + tc = TestBot() await tc.run() asyncio.run(main()) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 10a2987..86a60ff 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,5 +1,6 @@ from typing import List +from .bot import * from .client import * from .command import * from .connection import * @@ -11,6 +12,7 @@ from .session import * from .util import * __all__: List[str] = [] +__all__ += bot.__all__ __all__ += client.__all__ __all__ += command.__all__ __all__ += connection.__all__ diff --git a/yaboli/bot.py b/yaboli/bot.py new file mode 100644 index 0000000..29a0010 --- /dev/null +++ b/yaboli/bot.py @@ -0,0 +1,103 @@ +from typing import List, Optional +import logging + +from .client import Client +from .command import * +from .message import LiveMessage +from .room import Room + +logger = logging.getLogger(__name__) + +__all__ = ["Bot"] + +class Bot(Client): + PING_REPLY: str = "Pong!" + HELP_GENERAL: Optional[str] = None + HELP_SPECIFIC: Optional[str] = None + + def __init__(self) -> None: + super().__init__() + + self._commands: List[Command] = [] + + # Registering commands + + def register(self, command: Command) -> None: + self._commands.append(command) + + def register_general(self, + name: str, + cmdfunc: GeneralCommandFunction, + args: bool = True + ) -> None: + command = GeneralCommand(name, cmdfunc, args) + self.register(command) + + def register_specific(self, + name: str, + cmdfunc: SpecificCommandFunction, + args: bool = True + ) -> None: + command = SpecificCommand(name, cmdfunc, args) + self.register(command) + + # Processing commands + + async def process_commands(self, + room: Room, + message: LiveMessage, + aliases: List[str] = [] + ) -> None: + nicks = [room.session.nick] + aliases + print() + print(nicks) + print() + data = CommandData.from_string(message.content) + + if data is not None: + logger.debug(f"Processing command from {message.content!r}") + for command in self._commands: + await command.run(room, message, nicks, data) + + async def on_send(self, room: Room, message: LiveMessage) -> None: + await self.process_commands(room, message) + + # Botrulez + + def register_botrulez(self, + ping: bool = True, + help_: bool = True + ) -> None: + if ping: + self.register_general("ping", self.cmd_ping, args=False) + self.register_specific("ping", self.cmd_ping, args=False) + + if help_: + if self.HELP_GENERAL is None and self.HELP_SPECIFIC is None: + logger.warn(("HELP_GENERAL and HELP_SPECIFIC are None, but the" + " help command is enabled")) + self.register_general("help", self.cmd_help_general, args=False) + self.register_specific("help", self.cmd_help_specific, args=False) + + async def cmd_ping(self, + room: Room, + message: LiveMessage, + args: ArgumentData + ) -> None: + await message.reply(self.PING_REPLY) + + async def cmd_help_general(self, + room: Room, + message: LiveMessage, + args: ArgumentData + ) -> None: + if self.HELP_GENERAL is not None: + await message.reply(self.HELP_GENERAL) + + async def cmd_help_specific(self, + room: Room, + message: LiveMessage, + args: SpecificArgumentData + ) -> None: + if self.HELP_SPECIFIC is not None: + await message.reply(self.HELP_SPECIFIC) From 788b116d83b0c8309515f983783e35dc8f045c2c Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 11 Apr 2019 21:44:05 +0000 Subject: [PATCH 090/145] Clean up --- yaboli/bot.py | 2 +- yaboli/client.py | 2 +- yaboli/room.py | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index 29a0010..b7f4c1a 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,5 +1,5 @@ -from typing import List, Optional import logging +from typing import List, Optional from .client import Client from .command import * diff --git a/yaboli/client.py b/yaboli/client.py index 3641e8b..820ef29 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -1,7 +1,7 @@ import asyncio import functools -from typing import Dict, List, Optional import logging +from typing import Dict, List, Optional from .message import LiveMessage from .room import Room diff --git a/yaboli/room.py b/yaboli/room.py index e2c267d..458cdea 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,5 +1,3 @@ -# TODO add more logging - import asyncio import logging from typing import Any, Awaitable, Callable, List, Optional, TypeVar From ee726b93ff893f142178b612a0f01005ca637593 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 11 Apr 2019 23:26:58 +0000 Subject: [PATCH 091/145] Check if a command has arguments --- yaboli/command.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/yaboli/command.py b/yaboli/command.py index 99ce2ab..d716eda 100644 --- a/yaboli/command.py +++ b/yaboli/command.py @@ -102,6 +102,9 @@ class ArgumentData: self._fancy = self._parse_fancy(basic) return self._fancy + def has_args(self) -> bool: + return bool(self.basic()) # The list of arguments is empty + class SpecificArgumentData(ArgumentData): def __init__(self, nick: str, argstr: str) -> None: super().__init__(argstr) @@ -224,7 +227,7 @@ class GeneralCommand(Command): data: CommandData, ) -> None: # Do we have arguments if we shouldn't? - if not self._args and data.general.basic(): + if not self._args and data.general.has_args(): return await self._cmdfunc(room, message, data.general) @@ -263,7 +266,7 @@ class SpecificCommand(Command): return # Yay, a rare occurrence of this structure! # Do we have arguments if we shouldn't? - if not self._args and data.specific.basic(): + if not self._args and data.specific.has_args(): return await self._cmdfunc(room, message, data.specific) From b7831cfeb2b35c8e00da3648b40eee8960feebdd Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 11 Apr 2019 23:28:28 +0000 Subject: [PATCH 092/145] Fix method signature --- yaboli/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yaboli/client.py b/yaboli/client.py index 820ef29..c1f19ff 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -123,7 +123,7 @@ class Client: ) -> None: pass - async def on_edit(self, message: LiveMessage) -> None: + async def on_edit(self, room: Room, message: LiveMessage) -> None: pass async def on_pm(self, From 147ea9210223b8038d7e90b9a9559501186215c6 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 11 Apr 2019 23:32:44 +0000 Subject: [PATCH 093/145] Improve help formatting --- yaboli/bot.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index b7f4c1a..942470d 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -3,7 +3,7 @@ from typing import List, Optional from .client import Client from .command import * -from .message import LiveMessage +from .message import LiveMessage, Message from .room import Room logger = logging.getLogger(__name__) @@ -13,7 +13,7 @@ __all__ = ["Bot"] class Bot(Client): PING_REPLY: str = "Pong!" HELP_GENERAL: Optional[str] = None - HELP_SPECIFIC: Optional[str] = None + HELP_SPECIFIC: Optional[List[str]] = None def __init__(self) -> None: super().__init__() @@ -49,9 +49,6 @@ class Bot(Client): aliases: List[str] = [] ) -> None: nicks = [room.session.nick] + aliases - print() - print(nicks) - print() data = CommandData.from_string(message.content) if data is not None: @@ -62,6 +59,17 @@ class Bot(Client): async def on_send(self, room: Room, message: LiveMessage) -> None: await self.process_commands(room, message) + # Help util + + def format_help(self, room: Room, lines: List[str]) -> str: + text = "\n".join(lines) + params = { + "nick": room.session.nick, + "mention": room.session.mention, + "atmention": room.session.atmention, + } + return text.format(**params) + # Botrulez def register_botrulez(self, @@ -92,7 +100,7 @@ class Bot(Client): args: ArgumentData ) -> None: if self.HELP_GENERAL is not None: - await message.reply(self.HELP_GENERAL) + await message.reply(self.format_help(room, [self.HELP_GENERAL])) async def cmd_help_specific(self, room: Room, @@ -100,4 +108,4 @@ class Bot(Client): args: SpecificArgumentData ) -> None: if self.HELP_SPECIFIC is not None: - await message.reply(self.HELP_SPECIFIC) + await message.reply(self.format_help(room, self.HELP_SPECIFIC)) From 40cb7917c864268686d3323f66ed5be64fd42aa5 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 11 Apr 2019 23:33:06 +0000 Subject: [PATCH 094/145] Fix escaping --- yaboli/command.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/yaboli/command.py b/yaboli/command.py index d716eda..44d629d 100644 --- a/yaboli/command.py +++ b/yaboli/command.py @@ -38,6 +38,20 @@ class ArgumentData: self._fancy_escaped: Optional[FancyArgs] = None def _split_escaped(self, text: str) -> List[str]: + """ + Splits the string into individual arguments, while allowing + bash-inspired quoting/escaping. + + A single backslash escapes the immediately following character. + + Double quotes allow backslash escapes, but escape all other characters. + + Single quotes escape all characters. + + The remaining string is split at all unescaped while space characters + (using str.isspace), similar to str.split without any arguments. + """ + words: List[str] = [] word: List[str] = [] @@ -49,10 +63,16 @@ class ArgumentData: backslash = False word.append(char) elif quotes is not None: - if char == quotes: + if quotes == "\"" and char == "\\": + backslash = True + elif char == quotes: quotes = None else: word.append(char) + elif char == "\\": + backslash = True + elif char in ["\"", "'"]: + quotes = char elif char.isspace(): if word: words.append("".join(word)) From 9cb38c4ca86dd2570e7ac7424b0536eccad48277 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 11 Apr 2019 23:35:44 +0000 Subject: [PATCH 095/145] Add modules --- test.py | 61 +++++++++-------- yaboli/__init__.py | 2 + yaboli/module.py | 160 +++++++++++++++++++++++++++++++++++++++++++++ yaboli/util.py | 14 +++- 4 files changed, 208 insertions(+), 29 deletions(-) create mode 100644 yaboli/module.py diff --git a/test.py b/test.py index fd7f735..d749473 100644 --- a/test.py +++ b/test.py @@ -23,39 +23,44 @@ logger = logging.getLogger('yaboli') logger.setLevel(LEVEL) logger.addHandler(handler) -class TestBot(yaboli.Bot): - DEFAULT_NICK = "testbot" +class TestModule(yaboli.Module): + PING_REPLY = "ModulePong!" + DESCRIPTION = "ModuleDescription" + HELP_GENERAL = "ModuleGeneralHelp" + HELP_SPECIFIC = ["ModuleGeneralHelp"] - def __init__(self): - super().__init__() - self.register_botrulez() - self.register_general("test", self.cmd_test, args=False) - self.register_general("who", self.cmd_who, args=False) - self.register_general("err", self.cmd_err, args=False) +class EchoModule(yaboli.Module): + DEFAULT_NICK = "echo" + DESCRIPTION = "echoes back the input arguments" + HELP_GENERAL = "/me " + DESCRIPTION + HELP_SPECIFIC = [ + "!echo – output the arguments, each in its own line" + #"!fancyecho – same as !echo, but different parser" + ] + + def __init__(self, standalone: bool) -> None: + super().__init__(standalone) + + self.register_general("echo", self.cmd_echo) + #self.register_general("fancyecho", self.cmd_fancyecho) + + async def cmd_echo(self, room, message, args): + if args.has_args(): + lines = [repr(arg) for arg in args.basic()] + await message.reply("\n".join(lines)) + else: + await message.reply("No arguments") + +class TestBot(yaboli.ModuleBot): + DEFAULT_NICK = "testbot" async def started(self): await self.join("test") - async def on_send(self, room, message): - await self.process_commands(room, message, - aliases=["testalias", "aliastest"]) - - async def cmd_test(self, room, message, args): - await message.reply(f"You said {message.content!r}.") - msg1 = await room.send(f"{message.sender.atmention} said something.") - await msg1.reply("Yes, they really did.") - - async def cmd_who(self, room, message, args): - lines = [] - for user in await room.who(): - lines.append(repr(user.nick)) - await message.reply("\n".join(lines)) - - async def cmd_err(self, room, message, args): - await message.reply(str(1/0)) - async def main(): - tc = TestBot() - await tc.run() + tb = TestBot() + tb.register_module("test", TestModule(standalone=False)) + tb.register_module("echo", EchoModule(standalone=False)) + await tb.run() asyncio.run(main()) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 86a60ff..e749ce5 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -7,6 +7,7 @@ from .connection import * from .events import * from .exceptions import * from .message import * +from .module import * from .room import * from .session import * from .util import * @@ -19,6 +20,7 @@ __all__ += connection.__all__ __all__ += events.__all__ __all__ += exceptions.__all__ __all__ += message.__all__ +__all__ += module.__all__ __all__ += room.__all__ __all__ += session.__all__ __all__ += util.__all__ diff --git a/yaboli/module.py b/yaboli/module.py new file mode 100644 index 0000000..3fe1baf --- /dev/null +++ b/yaboli/module.py @@ -0,0 +1,160 @@ +import logging +from typing import Dict, List, Optional + +from .bot import Bot +from .command import * +from .message import LiveMessage +from .room import Room +from .session import LiveSession +from .util import * + +logger = logging.getLogger(__name__) + +__all__ = ["Module", "ModuleBot"] + +class Module(Bot): + DESCRIPTION: Optional[str] = None + + def __init__(self, standalone: bool) -> None: + super().__init__() + + self.standalone = standalone + +class ModuleBot(Bot): + HELP_PRE: Optional[List[str]] = [ + "This bot contains the following modules:" + ] + HELP_POST: Optional[List[str]] = [ + "" + "Use \"!help {atmention} \" to get more information on a" + " specific module." + ] + MODULE_HELP_LIMIT = 5 + + def __init__(self) -> None: + super().__init__() + + self.modules: Dict[str, Module] = {} + + self.register_botrulez(help_=False) + self.register_general("help", self.cmd_help_general, args=False) + self.register_specific("help", self.cmd_help_specific, args=True) + + def register_module(self, name: str, module: Module) -> None: + if name in self.modules: + logger.warn(f"Module {name!r} is already registered, overwriting...") + self.modules[name] = module + + def compile_module_overview(self) -> List[str]: + lines = [] + + if self.HELP_PRE is not None: + lines.extend(self.HELP_PRE) + + modules_without_desc: List[str] = [] + for module_name in sorted(self.modules): + module = self.modules[module_name] + + if module.DESCRIPTION is None: + modules_without_desc.append(module_name) + else: + line = f"\t{module_name} — {module.DESCRIPTION}" + lines.append(line) + + if modules_without_desc: + lines.append(", ".join(modules_without_desc)) + + if self.HELP_POST is not None: + lines.extend(self.HELP_POST) + + return lines + + def compile_module_help(self, module_name: str) -> List[str]: + module = self.modules.get(module_name) + if module is None: + return [f"Module {module_name!r} not found."] + + elif module.HELP_SPECIFIC is None: + return [f"Module {module_name!r} has no detailed help message."] + + return module.HELP_SPECIFIC + + # Overwriting the botrulez help function + async def cmd_help_specific(self, + room: Room, + message: LiveMessage, + args: SpecificArgumentData + ) -> None: + if args.has_args(): + if len(args.basic()) > self.MODULE_HELP_LIMIT: + limit = self.MODULE_HELP_LIMIT + text = f"A maximum of {limit} module{plural(limit)} is allowed." + await message.reply(text) + else: + for module_name in args.basic(): + help_lines = self.compile_module_help(module_name) + await message.reply(self.format_help(room, help_lines)) + else: + help_lines = self.compile_module_overview() + await message.reply(self.format_help(room, help_lines)) + + # Sending along all kinds of events + + async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None: + await super().on_snapshot(room, messages) + + for module in self.modules.values(): + await module.on_snapshot(room, messages) + + async def on_send(self, room: Room, message: LiveMessage) -> None: + await super().on_send(room, message) + + for module in self.modules.values(): + await module.on_send(room, message) + + async def on_join(self, room: Room, user: LiveSession) -> None: + await super().on_join(room, user) + + for module in self.modules.values(): + await module.on_join(room, user) + + async def on_part(self, room: Room, user: LiveSession) -> None: + await super().on_part(room, user) + + for module in self.modules.values(): + await module.on_part(room, user) + + async def on_nick(self, + room: Room, + user: LiveSession, + from_nick: str, + to_nick: str + ) -> None: + await super().on_nick(room, user, from_nick, to_nick) + + for module in self.modules.values(): + await module.on_nick(room, user, from_nick, to_nick) + + async def on_edit(self, room: Room, message: LiveMessage) -> None: + await super().on_edit(room, message) + + for module in self.modules.values(): + await module.on_edit(room, message) + + async def on_pm(self, + room: Room, + from_id: str, + from_nick: str, + from_room: str, + pm_id: str + ) -> None: + await super().on_pm(room, from_id, from_nick, from_room, pm_id) + + for module in self.modules.values(): + await module.on_pm(room, from_id, from_nick, from_room, pm_id) + + async def on_disconnect(self, room: Room, reason: str) -> None: + await super().on_disconnect(room, reason) + + for module in self.modules.values(): + await module.on_disconnect(room, reason) diff --git a/yaboli/util.py b/yaboli/util.py index 1d2069b..5353ec1 100644 --- a/yaboli/util.py +++ b/yaboli/util.py @@ -1,6 +1,6 @@ import re -__all__ = ["mention", "atmention", "normalize", "similar"] +__all__ = ["mention", "atmention", "normalize", "similar", "plural"] # Name/nick related functions @@ -16,3 +16,15 @@ def normalize(nick: str) -> str: def similar(nick_a: str, nick_b: str) -> bool: return normalize(nick_a) == normalize(nick_b) + +# Other formatting + +def plural( + number: int, + if_plural: str = "s", + if_singular: str = "" + ) -> str: + if number in [1, -1]: + return if_singular + else: + return if_plural From 8dd94b6ac8ee26cfbda0f8a8bd1e74d50ed51c6e Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 11 Apr 2019 23:42:41 +0000 Subject: [PATCH 096/145] Clean up --- LICENSE | 2 +- todo.txt | 12 ++++++++++++ yaboli/command.py | 2 +- yaboli/exceptions.py | 12 ------------ 4 files changed, 14 insertions(+), 14 deletions(-) create mode 100644 todo.txt diff --git a/LICENSE b/LICENSE index 8c068df..f2fd14f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 Garmelon +Copyright (c) 2018 - 2019 Garmelon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..d752445 --- /dev/null +++ b/todo.txt @@ -0,0 +1,12 @@ +TODO +- config file support for bots, used by default +- package in a distutils-compatible way (users should be able to install yaboli + using "pip install git+https://github.com/Garmelon/yaboli") +- cookie support +- fancy argument parsing +- document new classes (docstrings, maybe comments) +- document yaboli (markdown files in a "docs" folder?) +- write project readme +- write examples + +DONE diff --git a/yaboli/command.py b/yaboli/command.py index 44d629d..aec01c7 100644 --- a/yaboli/command.py +++ b/yaboli/command.py @@ -94,7 +94,7 @@ class ArgumentData: return text.split() def _parse_fancy(self, args: List[str]) -> FancyArgs: - raise NotImplementedError + raise NotImplementedError # TODO @property def argstr(self) -> str: diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py index cf9d94e..034aaad 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -10,7 +10,6 @@ __all__ = [ # Doing stuff in a room "RoomNotConnectedException", "EuphError", - "RoomClosedException", ] class EuphException(Exception): @@ -66,14 +65,3 @@ class EuphError(EuphException): The euphoria server has sent back an "error" field in its response. """ pass - -# TODO This exception is not used currently, decide on whether to keep it or -# throw it away -class RoomClosedException(EuphException): - """ - The room has been closed already. - - This means that phase 4 (see the docstring of Room) has been initiated or - completed. - """ - pass From 2bf512d8dc0f55ec740f3030234a0fb68ff3963d Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 00:28:08 +0000 Subject: [PATCH 097/145] Rename argstr to raw --- yaboli/command.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/yaboli/command.py b/yaboli/command.py index aec01c7..e355bb0 100644 --- a/yaboli/command.py +++ b/yaboli/command.py @@ -28,8 +28,8 @@ class FancyArgs(NamedTuple): flags: Dict[str, int] class ArgumentData: - def __init__(self, argstr: str) -> None: - self._argstr = argstr + def __init__(self, raw: str) -> None: + self._raw = raw self._basic: Optional[List[str]] = None self._basic_escaped: Optional[List[str]] = None @@ -97,28 +97,28 @@ class ArgumentData: raise NotImplementedError # TODO @property - def argstr(self) -> str: - return self._argstr + def raw(self) -> str: + return self._raw def basic(self, escaped: bool = True) -> List[str]: if escaped: if self._basic_escaped is None: - self._basic_escaped = self._split(self._argstr, escaped) + self._basic_escaped = self._split(self._raw, escaped) return self._basic_escaped else: if self._basic is None: - self._basic = self._split(self._argstr, escaped) + self._basic = self._split(self._raw, escaped) return self._basic def fancy(self, escaped: bool = True) -> FancyArgs: if escaped: if self._fancy_escaped is None: - basic = self._split(self._argstr, escaped) + basic = self._split(self._raw, escaped) self._fancy_escaped = self._parse_fancy(basic) return self._fancy_escaped else: if self._fancy is None: - basic = self._split(self._argstr, escaped) + basic = self._split(self._raw, escaped) self._fancy = self._parse_fancy(basic) return self._fancy @@ -126,8 +126,8 @@ class ArgumentData: return bool(self.basic()) # The list of arguments is empty class SpecificArgumentData(ArgumentData): - def __init__(self, nick: str, argstr: str) -> None: - super().__init__(argstr) + def __init__(self, nick: str, raw: str) -> None: + super().__init__(raw) self._nick = nick From 14b4e74c7e73c87dbaccb9e6a5665044e88190ac Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 00:28:51 +0000 Subject: [PATCH 098/145] Add README --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++ examples/echobot.py | 29 ++++++++++++++++++++ test.py | 66 --------------------------------------------- todo.txt | 12 --------- 4 files changed, 93 insertions(+), 78 deletions(-) create mode 100644 README.md create mode 100644 examples/echobot.py delete mode 100644 test.py delete mode 100644 todo.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..c43c6d7 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Yaboli + +Yaboli (**Y**et **A**nother **Bo**t **Li**brary) is a Python library for +creating bots for [euphoria.io](https://euphoria.io). + +Soon, markdown files containing documentation and troubleshooting info will be +available. + +## Example echo bot + +A simple echo bot that conforms to the +[botrulez](https://github.com/jedevc/botrulez) can be written like so: + +```python +class EchoBot(yaboli.Bot): + DEFAULT_NICK = "EchoBot" + HELP_GENERAL = "/me echoes back what you said" + HELP_SPECIFIC = [ + "This bot only has one command:", + "!echo – reply with exactly ", + ] + + def __init__(self): + super().__init__() + self.register_botrulez() + self.register_general("echo", self.cmd_echo) + + async def started(self): + await self.join("test") + + async def cmd_echo(self, room, message, args): + await message.reply(args.raw) +``` + +When joining a room, the bot sets its nick to the value in `DEFAULT_NICK`. + +The help command from the botrulez uses the `HELP_GENERAL` and `HELP_SPECIFIC` +fields. + +In the `__init__` function, the bot's commands are registered. + +The `started` function is called when the bot has been started and is ready to +connect to rooms and do other bot stuff. It can be used to load config files or +directly connect to rooms. + +In the `cmd_echo` function, the echo command is implemented. In this case, the +bot replies to the message containing the command with the raw argument string, +i. e. the text between the end of the "!echo" and the end of the whole message. + +## TODOs + +- [ ] implement !uptime for proper botrulez conformity +- [ ] implement !kill +- [ ] implement !restart and add an easier way to run bots +- [ ] config file support for bots, used by default +- [ ] package in a distutils-compatible way (users should be able to install + yaboli using `pip install git+https://github.com/Garmelon/yaboli`) +- [ ] document yaboli (markdown files in a "docs" folder?) +- [ ] make it easier to enable log messages +- [ ] cookie support +- [ ] fancy argument parsing +- [ ] document new classes (docstrings, maybe comments) +- [ ] write project readme +- [ ] write examples diff --git a/examples/echobot.py b/examples/echobot.py new file mode 100644 index 0000000..c462b64 --- /dev/null +++ b/examples/echobot.py @@ -0,0 +1,29 @@ +import asyncio + +import yaboli + +class EchoBot(yaboli.Bot): + DEFAULT_NICK = "EchoBot" + HELP_GENERAL = "/me echoes back what you said" + HELP_SPECIFIC = [ + "This bot only has one command:", + "!echo – reply with exactly ", + ] + + def __init__(self): + super().__init__() + self.register_botrulez() + self.register_general("echo", self.cmd_echo) + + async def started(self): + await self.join("test") + + async def cmd_echo(self, room, message, args): + await message.reply(args.raw) + +async def main(): + bot = EchoBot() + await bot.run() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/test.py b/test.py deleted file mode 100644 index d749473..0000000 --- a/test.py +++ /dev/null @@ -1,66 +0,0 @@ -# These tests are not intended as serious tests, just as small scenarios to -# give yaboli something to do. - -import asyncio -import logging - -import yaboli - -FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" -LEVEL = logging.DEBUG -#FORMAT = "{asctime} [{levelname:<7}] <{name}>: {message}" -#LEVEL = logging.INFO - -DATE_FORMAT = "%F %T" -handler = logging.StreamHandler() -handler.setFormatter(logging.Formatter( - fmt=FORMAT, - datefmt=DATE_FORMAT, - style="{" -)) - -logger = logging.getLogger('yaboli') -logger.setLevel(LEVEL) -logger.addHandler(handler) - -class TestModule(yaboli.Module): - PING_REPLY = "ModulePong!" - DESCRIPTION = "ModuleDescription" - HELP_GENERAL = "ModuleGeneralHelp" - HELP_SPECIFIC = ["ModuleGeneralHelp"] - -class EchoModule(yaboli.Module): - DEFAULT_NICK = "echo" - DESCRIPTION = "echoes back the input arguments" - HELP_GENERAL = "/me " + DESCRIPTION - HELP_SPECIFIC = [ - "!echo – output the arguments, each in its own line" - #"!fancyecho – same as !echo, but different parser" - ] - - def __init__(self, standalone: bool) -> None: - super().__init__(standalone) - - self.register_general("echo", self.cmd_echo) - #self.register_general("fancyecho", self.cmd_fancyecho) - - async def cmd_echo(self, room, message, args): - if args.has_args(): - lines = [repr(arg) for arg in args.basic()] - await message.reply("\n".join(lines)) - else: - await message.reply("No arguments") - -class TestBot(yaboli.ModuleBot): - DEFAULT_NICK = "testbot" - - async def started(self): - await self.join("test") - -async def main(): - tb = TestBot() - tb.register_module("test", TestModule(standalone=False)) - tb.register_module("echo", EchoModule(standalone=False)) - await tb.run() - -asyncio.run(main()) diff --git a/todo.txt b/todo.txt deleted file mode 100644 index d752445..0000000 --- a/todo.txt +++ /dev/null @@ -1,12 +0,0 @@ -TODO -- config file support for bots, used by default -- package in a distutils-compatible way (users should be able to install yaboli - using "pip install git+https://github.com/Garmelon/yaboli") -- cookie support -- fancy argument parsing -- document new classes (docstrings, maybe comments) -- document yaboli (markdown files in a "docs" folder?) -- write project readme -- write examples - -DONE From a0f7c8e84a908f25c25bdf59a8ab7e12c880d97f Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 11:37:36 +0000 Subject: [PATCH 099/145] Implement !uptime --- README.md | 3 ++- yaboli/bot.py | 20 +++++++++++++++++++- yaboli/util.py | 37 ++++++++++++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c43c6d7..4e53a3e 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ i. e. the text between the end of the "!echo" and the end of the whole message. ## TODOs -- [ ] implement !uptime for proper botrulez conformity - [ ] implement !kill - [ ] implement !restart and add an easier way to run bots +- [ ] untruncate LiveMessage-s - [ ] config file support for bots, used by default - [ ] package in a distutils-compatible way (users should be able to install yaboli using `pip install git+https://github.com/Garmelon/yaboli`) @@ -62,3 +62,4 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [ ] document new classes (docstrings, maybe comments) - [ ] write project readme - [ ] write examples +- [x] implement !uptime for proper botrulez conformity diff --git a/yaboli/bot.py b/yaboli/bot.py index 942470d..cc6a74a 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,3 +1,4 @@ +import datetime import logging from typing import List, Optional @@ -5,6 +6,7 @@ from .client import Client from .command import * from .message import LiveMessage, Message from .room import Room +from .util import * logger = logging.getLogger(__name__) @@ -20,6 +22,8 @@ class Bot(Client): self._commands: List[Command] = [] + self.start_time = datetime.datetime.now() + # Registering commands def register(self, command: Command) -> None: @@ -74,7 +78,8 @@ class Bot(Client): def register_botrulez(self, ping: bool = True, - help_: bool = True + help_: bool = True, + uptime: bool = True ) -> None: if ping: self.register_general("ping", self.cmd_ping, args=False) @@ -87,6 +92,9 @@ class Bot(Client): self.register_general("help", self.cmd_help_general, args=False) self.register_specific("help", self.cmd_help_specific, args=False) + if uptime: + self.register_specific("uptime", self.cmd_uptime, args=False) + async def cmd_ping(self, room: Room, message: LiveMessage, @@ -109,3 +117,13 @@ class Bot(Client): ) -> None: if self.HELP_SPECIFIC is not None: await message.reply(self.format_help(room, self.HELP_SPECIFIC)) + + async def cmd_uptime(self, + room: Room, + message: LiveMessage, + args: SpecificArgumentData + ) -> None: + time = format_time(self.start_time) + delta = format_delta(datetime.datetime.now() - self.start_time) + text = f"/me has been up since {time} UTC ({delta})" + await message.reply(text) diff --git a/yaboli/util.py b/yaboli/util.py index 5353ec1..6439799 100644 --- a/yaboli/util.py +++ b/yaboli/util.py @@ -1,6 +1,8 @@ +import datetime import re -__all__ = ["mention", "atmention", "normalize", "similar", "plural"] +__all__ = ["mention", "atmention", "normalize", "similar", "plural", + "format_time", "format_delta"] # Name/nick related functions @@ -28,3 +30,36 @@ def plural( return if_singular else: return if_plural + +def format_time(time: datetime.datetime) -> str: + return time.strftime("%F %T") + +def format_delta(delta: datetime.timedelta) -> str: + seconds = int(delta.total_seconds()) + negative = seconds < 0 + seconds = abs(seconds) + + days = seconds // (60 * 60 * 24) + seconds -= days * (60 * 60 * 24) + + hours = seconds // (60 * 60) + seconds -= hours * (60 * 60) + + minutes = seconds // 60 + seconds -= minutes * 60 + + text: str + + if days > 0: + text = f"{days}d {hours}h {minutes}m {seconds}s" + elif hours > 0: + text = f"{hours}h {minutes}m {seconds}s" + elif minutes > 0: + text = f"{minutes}m {seconds}s" + else: + text = f"{seconds}s" + + if negative: + text = "- " + text + + return text From 3255ea770e89ddacfe6a496a4e87add8a140e390 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 12:08:26 +0000 Subject: [PATCH 100/145] Implement !kill --- README.md | 2 +- yaboli/bot.py | 15 ++++++++++++++- yaboli/connection.py | 5 +++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4e53a3e..00dc19c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,6 @@ i. e. the text between the end of the "!echo" and the end of the whole message. ## TODOs -- [ ] implement !kill - [ ] implement !restart and add an easier way to run bots - [ ] untruncate LiveMessage-s - [ ] config file support for bots, used by default @@ -63,3 +62,4 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [ ] write project readme - [ ] write examples - [x] implement !uptime for proper botrulez conformity +- [x] implement !kill diff --git a/yaboli/bot.py b/yaboli/bot.py index cc6a74a..92e55ab 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -16,6 +16,7 @@ class Bot(Client): PING_REPLY: str = "Pong!" HELP_GENERAL: Optional[str] = None HELP_SPECIFIC: Optional[List[str]] = None + KILL_REPLY: str = "/me dies" def __init__(self) -> None: super().__init__() @@ -79,7 +80,8 @@ class Bot(Client): def register_botrulez(self, ping: bool = True, help_: bool = True, - uptime: bool = True + uptime: bool = True, + kill: bool = False, ) -> None: if ping: self.register_general("ping", self.cmd_ping, args=False) @@ -95,6 +97,9 @@ class Bot(Client): if uptime: self.register_specific("uptime", self.cmd_uptime, args=False) + if kill: + self.register_specific("kill", self.cmd_kill, args=False) + async def cmd_ping(self, room: Room, message: LiveMessage, @@ -127,3 +132,11 @@ class Bot(Client): delta = format_delta(datetime.datetime.now() - self.start_time) text = f"/me has been up since {time} UTC ({delta})" await message.reply(text) + + async def cmd_kill(self, + room: Room, + message: LiveMessage, + args: SpecificArgumentData + ) -> None: + await message.reply(self.KILL_REPLY) + await self.part(room) diff --git a/yaboli/connection.py b/yaboli/connection.py index aa8dd60..fbb354f 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -438,10 +438,11 @@ class Connection: # to http://api.euphoria.io/#packets. # First, notify whoever's waiting for this packet - packet_id = packet.get("id", None) + packet_id = packet.get("id") if packet_id is not None and self._awaiting_replies is not None: - future = self._awaiting_replies.get(packet_id, None) + future = self._awaiting_replies.get(packet_id) if future is not None: + del self._awaiting_replies[packet_id] future.set_result(packet) # Then, send the corresponding event From a78f57db7a4726a1ac66296bfb86eabfa4b4d036 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 12:12:04 +0000 Subject: [PATCH 101/145] Untruncate LiveMessages --- README.md | 2 +- yaboli/message.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 00dc19c..941d270 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,6 @@ i. e. the text between the end of the "!echo" and the end of the whole message. ## TODOs - [ ] implement !restart and add an easier way to run bots -- [ ] untruncate LiveMessage-s - [ ] config file support for bots, used by default - [ ] package in a distutils-compatible way (users should be able to install yaboli using `pip install git+https://github.com/Garmelon/yaboli`) @@ -63,3 +62,4 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [ ] write examples - [x] implement !uptime for proper botrulez conformity - [x] implement !kill +- [x] untruncate LiveMessage-s diff --git a/yaboli/message.py b/yaboli/message.py index 0e3c24b..ebad87c 100644 --- a/yaboli/message.py +++ b/yaboli/message.py @@ -166,5 +166,8 @@ class LiveMessage(Message): async def reply(self, content: str) -> "LiveMessage": return await self.room.send(content, parent_id=self.message_id) + async def get(self) -> "LiveMessage": + return await self.room.get(self.message_id) + async def before(self, amount: int) -> List["LiveMessage"]: return await self.room.log(amount, before_id=self.message_id) From 8cd2c8d125dedef7938ab5e6c91796b1aa2295b0 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 12:53:34 +0000 Subject: [PATCH 102/145] Use config files for bots --- README.md | 16 ++++------------ examples/echo/bot.conf | 5 +++++ examples/{ => echo}/echobot.py | 11 ++++------- yaboli/bot.py | 18 ++++++++++++++++-- yaboli/client.py | 7 +++---- yaboli/module.py | 8 ++++---- 6 files changed, 36 insertions(+), 29 deletions(-) create mode 100644 examples/echo/bot.conf rename examples/{ => echo}/echobot.py (76%) diff --git a/README.md b/README.md index 941d270..ee4dddc 100644 --- a/README.md +++ b/README.md @@ -13,36 +13,28 @@ A simple echo bot that conforms to the ```python class EchoBot(yaboli.Bot): - DEFAULT_NICK = "EchoBot" HELP_GENERAL = "/me echoes back what you said" HELP_SPECIFIC = [ "This bot only has one command:", "!echo – reply with exactly ", ] - def __init__(self): - super().__init__() + def __init__(self, config_file): + super().__init__(config_file) self.register_botrulez() self.register_general("echo", self.cmd_echo) - async def started(self): - await self.join("test") - async def cmd_echo(self, room, message, args): await message.reply(args.raw) ``` -When joining a room, the bot sets its nick to the value in `DEFAULT_NICK`. +The bot's nick and default rooms are specified in a config file. The help command from the botrulez uses the `HELP_GENERAL` and `HELP_SPECIFIC` fields. In the `__init__` function, the bot's commands are registered. -The `started` function is called when the bot has been started and is ready to -connect to rooms and do other bot stuff. It can be used to load config files or -directly connect to rooms. - In the `cmd_echo` function, the echo command is implemented. In this case, the bot replies to the message containing the command with the raw argument string, i. e. the text between the end of the "!echo" and the end of the whole message. @@ -50,7 +42,6 @@ i. e. the text between the end of the "!echo" and the end of the whole message. ## TODOs - [ ] implement !restart and add an easier way to run bots -- [ ] config file support for bots, used by default - [ ] package in a distutils-compatible way (users should be able to install yaboli using `pip install git+https://github.com/Garmelon/yaboli`) - [ ] document yaboli (markdown files in a "docs" folder?) @@ -63,3 +54,4 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [x] implement !uptime for proper botrulez conformity - [x] implement !kill - [x] untruncate LiveMessage-s +- [x] config file support for bots, used by default diff --git a/examples/echo/bot.conf b/examples/echo/bot.conf new file mode 100644 index 0000000..87719ea --- /dev/null +++ b/examples/echo/bot.conf @@ -0,0 +1,5 @@ +[basic] +name = EchoBot + +[rooms] +test diff --git a/examples/echobot.py b/examples/echo/echobot.py similarity index 76% rename from examples/echobot.py rename to examples/echo/echobot.py index c462b64..7f5b103 100644 --- a/examples/echobot.py +++ b/examples/echo/echobot.py @@ -2,27 +2,24 @@ import asyncio import yaboli + class EchoBot(yaboli.Bot): - DEFAULT_NICK = "EchoBot" HELP_GENERAL = "/me echoes back what you said" HELP_SPECIFIC = [ "This bot only has one command:", "!echo – reply with exactly ", ] - def __init__(self): - super().__init__() + def __init__(self, config_file): + super().__init__(config_file) self.register_botrulez() self.register_general("echo", self.cmd_echo) - async def started(self): - await self.join("test") - async def cmd_echo(self, room, message, args): await message.reply(args.raw) async def main(): - bot = EchoBot() + bot = EchoBot("bot.conf") await bot.run() if __name__ == "__main__": diff --git a/yaboli/bot.py b/yaboli/bot.py index 92e55ab..3ca7171 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,3 +1,4 @@ +import configparser import datetime import logging from typing import List, Optional @@ -18,13 +19,26 @@ class Bot(Client): HELP_SPECIFIC: Optional[List[str]] = None KILL_REPLY: str = "/me dies" - def __init__(self) -> None: - super().__init__() + BASIC_SECTION = "basic" + ROOMS_SECTION = "rooms" + + def __init__(self, config_file: str) -> None: + self.config = configparser.ConfigParser(allow_no_value=True) + self.config.read(config_file) + + super().__init__(self.config[self.BASIC_SECTION].get("name", "")) self._commands: List[Command] = [] self.start_time = datetime.datetime.now() + async def started(self) -> None: + for room, password in self.config[self.ROOMS_SECTION].items(): + if password is None: + await self.join(room) + else: + await self.join(room, password=password) + # Registering commands def register(self, command: Command) -> None: diff --git a/yaboli/client.py b/yaboli/client.py index c1f19ff..e937a82 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -12,9 +12,8 @@ logger = logging.getLogger(__name__) __all__ = ["Client"] class Client: - DEFAULT_NICK = "" - - def __init__(self) -> None: + def __init__(self, default_nick: str) -> None: + self._default_nick = default_nick self._rooms: Dict[str, List[Room]] = {} self._stop = asyncio.Event() @@ -54,7 +53,7 @@ class Client: logger.info(f"Joining &{room_name}") if nick is None: - nick = self.DEFAULT_NICK + nick = self._default_nick room = Room(room_name, password=password, target_nick=nick) room.register_event("snapshot", diff --git a/yaboli/module.py b/yaboli/module.py index 3fe1baf..2dc9b0f 100644 --- a/yaboli/module.py +++ b/yaboli/module.py @@ -15,8 +15,8 @@ __all__ = ["Module", "ModuleBot"] class Module(Bot): DESCRIPTION: Optional[str] = None - def __init__(self, standalone: bool) -> None: - super().__init__() + def __init__(self, config_file: str, standalone: bool) -> None: + super().__init__(config_file) self.standalone = standalone @@ -31,8 +31,8 @@ class ModuleBot(Bot): ] MODULE_HELP_LIMIT = 5 - def __init__(self) -> None: - super().__init__() + def __init__(self, config_file: str) -> None: + super().__init__(config_file) self.modules: Dict[str, Module] = {} From 62e5adc8783338af4a640cc6fb3d226f285dc5a0 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 13:25:20 +0000 Subject: [PATCH 103/145] Add logging and bot starting utils --- README.md | 11 +++++++---- examples/echo/echobot.py | 10 +++------- yaboli/__init__.py | 36 ++++++++++++++++++++++++++++++++++-- yaboli/bot.py | 1 + 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ee4dddc..70ad6a7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ class EchoBot(yaboli.Bot): def __init__(self, config_file): super().__init__(config_file) - self.register_botrulez() + self.register_botrulez(kill=True) self.register_general("echo", self.cmd_echo) async def cmd_echo(self, room, message, args): @@ -33,7 +33,9 @@ The bot's nick and default rooms are specified in a config file. The help command from the botrulez uses the `HELP_GENERAL` and `HELP_SPECIFIC` fields. -In the `__init__` function, the bot's commands are registered. +In the `__init__` function, the bot's commands are registered. The required +botrulez commands (!ping, !help, !uptime) are enabled by default. Other +commands like !kill need to be enabled explicitly. In the `cmd_echo` function, the echo command is implemented. In this case, the bot replies to the message containing the command with the raw argument string, @@ -41,11 +43,10 @@ i. e. the text between the end of the "!echo" and the end of the whole message. ## TODOs -- [ ] implement !restart and add an easier way to run bots +- [ ] implement !restart - [ ] package in a distutils-compatible way (users should be able to install yaboli using `pip install git+https://github.com/Garmelon/yaboli`) - [ ] document yaboli (markdown files in a "docs" folder?) -- [ ] make it easier to enable log messages - [ ] cookie support - [ ] fancy argument parsing - [ ] document new classes (docstrings, maybe comments) @@ -55,3 +56,5 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [x] implement !kill - [x] untruncate LiveMessage-s - [x] config file support for bots, used by default +- [x] make it easier to enable log messages +- [x] make it easier to run bots diff --git a/examples/echo/echobot.py b/examples/echo/echobot.py index 7f5b103..4804992 100644 --- a/examples/echo/echobot.py +++ b/examples/echo/echobot.py @@ -1,5 +1,3 @@ -import asyncio - import yaboli @@ -12,15 +10,13 @@ class EchoBot(yaboli.Bot): def __init__(self, config_file): super().__init__(config_file) - self.register_botrulez() + self.register_botrulez(kill=True) self.register_general("echo", self.cmd_echo) async def cmd_echo(self, room, message, args): await message.reply(args.raw) -async def main(): - bot = EchoBot("bot.conf") - await bot.run() if __name__ == "__main__": - asyncio.run(main()) + yaboli.enable_logging() + yaboli.run(EchoBot) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index e749ce5..553b8d7 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,4 +1,6 @@ -from typing import List +import asyncio +import logging +from typing import Callable from .bot import * from .client import * @@ -12,7 +14,9 @@ from .room import * from .session import * from .util import * -__all__: List[str] = [] +__all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging", + "run"] + __all__ += bot.__all__ __all__ += client.__all__ __all__ += command.__all__ @@ -24,3 +28,31 @@ __all__ += module.__all__ __all__ += room.__all__ __all__ += session.__all__ __all__ += util.__all__ + +STYLE = "{" +FORMAT = "{asctime} [{levelname:<7}] <{name}>: {message}" +DATE_FORMAT = "%F %T" + +FORMATTER = logging.Formatter( + fmt=FORMAT, + datefmt=DATE_FORMAT, + style=STYLE +) + +def enable_logging(name: str = "yaboli", level: int = logging.INFO) -> None: + handler = logging.StreamHandler() + handler.setFormatter(FORMATTER) + + logger = logging.getLogger(name) + logger.setLevel(level) + logger.addHandler(handler) + +def run( + client: Callable[[str], Client], + config_file: str = "bot.conf" + ) -> None: + async def _run(): + client_ = client(config_file) + await client_.run() + + asyncio.run(_run()) diff --git a/yaboli/bot.py b/yaboli/bot.py index 3ca7171..1777498 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -152,5 +152,6 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: + logger.info(f"Killed in &{room.name} by {message.sender.atmention}") await message.reply(self.KILL_REPLY) await self.part(room) From 903ba4973b696e35e67d2f642ce3b32ececffc44 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 18:10:36 +0000 Subject: [PATCH 104/145] Use setuptools --- CHANGELOG.md | 5 +++++ README.md | 19 +++++++++++++++++-- setup.py | 8 ++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 setup.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..441303b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 (2019-04-12) + +- use setuptools diff --git a/README.md b/README.md index 70ad6a7..27b0d91 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,24 @@ Yaboli (**Y**et **A**nother **Bo**t **Li**brary) is a Python library for creating bots for [euphoria.io](https://euphoria.io). +- [Changelog](CHANGELOG.md) + Soon, markdown files containing documentation and troubleshooting info will be available. +## Installation + +Ensure that you have at least Python 3.7 installed. The commands below assume +that `python` points this version of Python. + +In your project directory, run: + +``` +$ python -m venv . +$ . bin/activate +$ pip install git+https://github.com/Garmelon/yaboli +``` + ## Example echo bot A simple echo bot that conforms to the @@ -44,8 +59,6 @@ i. e. the text between the end of the "!echo" and the end of the whole message. ## TODOs - [ ] implement !restart -- [ ] package in a distutils-compatible way (users should be able to install - yaboli using `pip install git+https://github.com/Garmelon/yaboli`) - [ ] document yaboli (markdown files in a "docs" folder?) - [ ] cookie support - [ ] fancy argument parsing @@ -58,3 +71,5 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [x] config file support for bots, used by default - [x] make it easier to enable log messages - [x] make it easier to run bots +- [x] package in a distutils-compatible way (users should be able to install + yaboli using `pip install git+https://github.com/Garmelon/yaboli`) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5e26be5 --- /dev/null +++ b/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup + +setup( + name="yaboli", + version="0.1.0", + packages=["yaboli"], + install_requires=["websockets==7.0"], +) From 1d66b3a5180fe2fd09ee970fdce5ead9f24b7425 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 19:14:53 +0000 Subject: [PATCH 105/145] Clean up --- .gitignore | 2 +- example.py | 26 -------------------------- info.txt | 39 --------------------------------------- requirements.txt | 1 - 4 files changed, 1 insertion(+), 67 deletions(-) delete mode 100644 example.py delete mode 100644 info.txt delete mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index bf7ff1a..1d164cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # python stuff -*/__pycache__/ +__pycache__/ # venv stuff bin/ diff --git a/example.py b/example.py deleted file mode 100644 index 97aff03..0000000 --- a/example.py +++ /dev/null @@ -1,26 +0,0 @@ -import yyb - -class MyClient(yyb.Client): - async def on_join(self, room): - await room.say("Hello!") - - async def on_message(self, message): - if message.content == "reply to me"): - reply = await message.reply("reply") - await reply.reply("reply to the reply") - await message.room.say("stuff going on") - - elif message.content == "hey, join &test!": - # returns room in phase 3, or throws JoinException - room = await self.join("test") - if room: - room.say("hey, I joined!") - else: - message.reply("didn't work :(") - - async def before_part(self, room): - await room.say("Goodbye!") - -# Something like this, I guess. It's still missing password fields though. -c = MyClient("my:bot:") -c.run("test", "bots") diff --git a/info.txt b/info.txt deleted file mode 100644 index f33cfb7..0000000 --- a/info.txt +++ /dev/null @@ -1,39 +0,0 @@ -Signature of a normal function: - -def a(b: int, c: str) -> bool: - pass - -a # type: Callable[[int, str], bool] - -Signature of an async function: - -async def a(b: int, c: str) -> bool: - pass - -a # type: Callable[[int, str], Awaitable[bool]] - - - -Enable logging (from the websockets docs): - -import logging -logger = logging.getLogger('websockets') -logger.setLevel(logging.INFO) -logger.addHandler(logging.StreamHandler()) - -Output format: See https://docs.python.org/3/library/logging.html#formatter-objects - -Example formatting: - -FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" -DATE_FORMAT = "%F %T" -handler = logging.StreamHandler() -handler.setFormatter(logging.Formatter( - fmt=FORMAT, - datefmt=DATE_FORMAT, - style="{" -)) - -logger = logging.getLogger('yaboli') -logger.setLevel(logging.DEBUG) -logger.addHandler(handler) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4789da4..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -websockets==7.0 From 1d772e72157d06521a252f1b6cf1d9717da37112 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 19:28:39 +0000 Subject: [PATCH 106/145] Set version number update reminder --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index 5e26be5..d544d71 100644 --- a/setup.py +++ b/setup.py @@ -6,3 +6,7 @@ setup( packages=["yaboli"], install_requires=["websockets==7.0"], ) + +# When updating the version, also: +# - set a tag to the update commit +# - update the changelog From f46ca47a28d10a95c8f65b70febe1bf3a1fe6929 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 20:05:36 +0000 Subject: [PATCH 107/145] Add ALIASES variable to Bot --- CHANGELOG.md | 4 ++++ yaboli/bot.py | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 441303b..ed9a9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Next version + +- add ALIASES variable to Bot + ## 0.1.0 (2019-04-12) - use setuptools diff --git a/yaboli/bot.py b/yaboli/bot.py index 1777498..23ecf32 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -14,6 +14,8 @@ logger = logging.getLogger(__name__) __all__ = ["Bot"] class Bot(Client): + ALIASES: List[str] = [] + PING_REPLY: str = "Pong!" HELP_GENERAL: Optional[str] = None HELP_SPECIFIC: Optional[List[str]] = None @@ -76,7 +78,7 @@ class Bot(Client): await command.run(room, message, nicks, data) async def on_send(self, room: Room, message: LiveMessage) -> None: - await self.process_commands(room, message) + await self.process_commands(room, message, aliases=self.ALIASES) # Help util From f40fb2d45d7ddb6fd1ed968606f7b5bbea5aa92f Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 20:14:22 +0000 Subject: [PATCH 108/145] Add on_connected to client --- CHANGELOG.md | 3 ++- yaboli/client.py | 5 +++++ yaboli/room.py | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9a9d9..e1e14a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Next version -- add ALIASES variable to Bot +- add `ALIASES` variable to `Bot` +- add `on_connected` function to `Client` ## 0.1.0 (2019-04-12) diff --git a/yaboli/client.py b/yaboli/client.py index e937a82..5117c45 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -56,6 +56,8 @@ class Client: nick = self._default_nick room = Room(room_name, password=password, target_nick=nick) + room.register_event("connected", + functools.partial(self.on_connected, room)) room.register_event("snapshot", functools.partial(self.on_snapshot, room)) room.register_event("send", @@ -102,6 +104,9 @@ class Client: # Event stuff - overwrite these functions + async def on_connected(self, room: Room) -> None: + pass + async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None: pass diff --git a/yaboli/room.py b/yaboli/room.py index 458cdea..4196d5f 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -19,6 +19,10 @@ class Room: """ Events and parameters: + "connected" - fired after the Room has authenticated, joined and set its + nick, meaning that now, messages can be sent + no parameters + "snapshot" - snapshot of the room's messages at the time of joining messages: List[LiveMessage] @@ -207,6 +211,7 @@ class Room: if self._target_nick and nick_needs_updating: await self._nick(self._target_nick) + self._events.fire("connected") return True async def disconnect(self) -> None: From 5586020d1e1022e6ae10b91f60faea0dd071f23d Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 20:40:27 +0000 Subject: [PATCH 109/145] Change config file format --- CHANGELOG.md | 1 + examples/echo/bot.conf | 4 ++-- yaboli/bot.py | 8 ++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e14a0..a0ed73a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Next version +- change config file format - add `ALIASES` variable to `Bot` - add `on_connected` function to `Client` diff --git a/examples/echo/bot.conf b/examples/echo/bot.conf index 87719ea..8d48222 100644 --- a/examples/echo/bot.conf +++ b/examples/echo/bot.conf @@ -1,5 +1,5 @@ -[basic] -name = EchoBot +[general] +nick = EchoBot [rooms] test diff --git a/yaboli/bot.py b/yaboli/bot.py index 23ecf32..7c96f29 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -21,14 +21,18 @@ class Bot(Client): HELP_SPECIFIC: Optional[List[str]] = None KILL_REPLY: str = "/me dies" - BASIC_SECTION = "basic" + GENERAL_SECTION = "general" ROOMS_SECTION = "rooms" def __init__(self, config_file: str) -> None: self.config = configparser.ConfigParser(allow_no_value=True) self.config.read(config_file) - super().__init__(self.config[self.BASIC_SECTION].get("name", "")) + nick = self.config[self.GENERAL_SECTION].get("nick") + if nick is None: + logger.warn("No nick set in config file. Defaulting to empty nick") + nick = "" + super().__init__(nick) self._commands: List[Command] = [] From 6741d360092bc4b29d0f6c349c0e5ba251305bf9 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 20:40:47 +0000 Subject: [PATCH 110/145] Clean up --- README.md | 1 + yaboli/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 27b0d91..9d3c26e 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [ ] document new classes (docstrings, maybe comments) - [ ] write project readme - [ ] write examples +- [ ] make yaboli package play nice with mypy - [x] implement !uptime for proper botrulez conformity - [x] implement !kill - [x] untruncate LiveMessage-s diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 553b8d7..ac1c244 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -51,7 +51,7 @@ def run( client: Callable[[str], Client], config_file: str = "bot.conf" ) -> None: - async def _run(): + async def _run() -> None: client_ = client(config_file) await client_.run() From 8f576b1147d569861764a6f9399b8d5d445dcabc Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 20:49:30 +0000 Subject: [PATCH 111/145] Update installation instructions --- README.md | 2 +- setup.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9d3c26e..62e3551 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ In your project directory, run: ``` $ python -m venv . $ . bin/activate -$ pip install git+https://github.com/Garmelon/yaboli +$ pip install git+https://github.com/Garmelon/yaboli@v0.1.0 ``` ## Example echo bot diff --git a/setup.py b/setup.py index d544d71..27cd5f6 100644 --- a/setup.py +++ b/setup.py @@ -8,5 +8,6 @@ setup( ) # When updating the version, also: -# - set a tag to the update commit +# - update the README.md installation instructions # - update the changelog +# - set a tag to the update commit From b7579b5b78e89d5ac8f0f16d562529ed5dd68c38 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 12 Apr 2019 21:04:51 +0000 Subject: [PATCH 112/145] Bump version to 0.2.0 --- CHANGELOG.md | 2 ++ README.md | 2 +- setup.py | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ed73a..f754075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +## 0.2.0 (2019-04-12) + - change config file format - add `ALIASES` variable to `Bot` - add `on_connected` function to `Client` diff --git a/README.md b/README.md index 62e3551..0c0b0bd 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ In your project directory, run: ``` $ python -m venv . $ . bin/activate -$ pip install git+https://github.com/Garmelon/yaboli@v0.1.0 +$ pip install git+https://github.com/Garmelon/yaboli@v0.2.0 ``` ## Example echo bot diff --git a/setup.py b/setup.py index 27cd5f6..eb4d419 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name="yaboli", - version="0.1.0", + version="0.2.0", packages=["yaboli"], install_requires=["websockets==7.0"], ) @@ -11,3 +11,18 @@ setup( # - update the README.md installation instructions # - update the changelog # - set a tag to the update commit + +# Meanings of version numbers +# +# Format: a.b.c +# +# a - increased when: major change such as a rewrite +# b - increased when: changes breaking backwards compatibility +# c - increased when: minor changes preserving backwards compatibility +# +# To specify version requirements for yaboli, the following format is +# recommended if you need version a.b.c: +# +# yaboli >=a.b.c, Date: Fri, 12 Apr 2019 23:14:16 +0000 Subject: [PATCH 113/145] Add !restart command to botrulez --- CHANGELOG.md | 2 ++ README.md | 2 +- yaboli/__init__.py | 5 +++-- yaboli/bot.py | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f754075..0437e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +- add !restart to botrulez + ## 0.2.0 (2019-04-12) - change config file format diff --git a/README.md b/README.md index 0c0b0bd..e779c40 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,6 @@ i. e. the text between the end of the "!echo" and the end of the whole message. ## TODOs -- [ ] implement !restart - [ ] document yaboli (markdown files in a "docs" folder?) - [ ] cookie support - [ ] fancy argument parsing @@ -74,3 +73,4 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [x] make it easier to run bots - [x] package in a distutils-compatible way (users should be able to install yaboli using `pip install git+https://github.com/Garmelon/yaboli`) +- [x] implement !restart diff --git a/yaboli/__init__.py b/yaboli/__init__.py index ac1c244..a964c61 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -52,7 +52,8 @@ def run( config_file: str = "bot.conf" ) -> None: async def _run() -> None: - client_ = client(config_file) - await client_.run() + while True: + client_ = client(config_file) + await client_.run() asyncio.run(_run()) diff --git a/yaboli/bot.py b/yaboli/bot.py index 7c96f29..de6af0e 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -20,6 +20,7 @@ class Bot(Client): HELP_GENERAL: Optional[str] = None HELP_SPECIFIC: Optional[List[str]] = None KILL_REPLY: str = "/me dies" + RESTART_REPLY: str = "/me restarts" GENERAL_SECTION = "general" ROOMS_SECTION = "rooms" @@ -102,6 +103,7 @@ class Bot(Client): help_: bool = True, uptime: bool = True, kill: bool = False, + restart: bool = False, ) -> None: if ping: self.register_general("ping", self.cmd_ping, args=False) @@ -120,6 +122,9 @@ class Bot(Client): if kill: self.register_specific("kill", self.cmd_kill, args=False) + if restart: + self.register_specific("restart", self.cmd_restart, args=False) + async def cmd_ping(self, room: Room, message: LiveMessage, @@ -161,3 +166,12 @@ class Bot(Client): logger.info(f"Killed in &{room.name} by {message.sender.atmention}") await message.reply(self.KILL_REPLY) await self.part(room) + + async def cmd_restart(self, + room: Room, + message: LiveMessage, + args: SpecificArgumentData + ) -> None: + logger.info(f"Restarted in &{room.name} by {message.sender.atmention}") + await message.reply(self.RESTART_REPLY) + await self.stop() From ac70f45229bf4f340c5ff13a47dd95f44e0fdec9 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 13 Apr 2019 00:22:42 +0000 Subject: [PATCH 114/145] Add some basic documentation --- README.md | 6 ++-- docs/index.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 docs/index.md diff --git a/README.md b/README.md index e779c40..5e2e933 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Yaboli (**Y**et **A**nother **Bo**t **Li**brary) is a Python library for creating bots for [euphoria.io](https://euphoria.io). - [Changelog](CHANGELOG.md) +- [Documentation](docs/index.md) -Soon, markdown files containing documentation and troubleshooting info will be -available. +Soon, markdown files containing troubleshooting info will be available. ## Installation @@ -62,7 +62,6 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [ ] cookie support - [ ] fancy argument parsing - [ ] document new classes (docstrings, maybe comments) -- [ ] write project readme - [ ] write examples - [ ] make yaboli package play nice with mypy - [x] implement !uptime for proper botrulez conformity @@ -74,3 +73,4 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [x] package in a distutils-compatible way (users should be able to install yaboli using `pip install git+https://github.com/Garmelon/yaboli`) - [x] implement !restart +- [x] write project readme diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..97d28a3 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,79 @@ +# Index for yaboli docs + +Links to specific sections will be added here. + +## Library structure overview + +### Message, Session + +A `Message` represents a single message. It contains all the fields [specified +in the API](http://api.euphoria.io/#message), in addition to a few utility +functions. + +Similar to a `Message`, a `Session` represents a session and also contains +almost all the fields [specified in the +API](http://api.euphoria.io/#sessionview), in addition to a few utility +functions. + +`Message`s and `Session`s also both contain the name of the room they +originated from. + +### Room + +A `Room` represents a single connection to a room on euphoria. It tries to keep +connected and reconnects if it loses connection. When connecting and +reconnecting, it automatically authenticates and sets a nick. + +In addition, a `Room` also keeps track of its own session and the sessions of +all other people and bots connected to the room. It doesn't remember any +messages though, since no "correct" solution to do that exists and the method +depends on the design of the bot using the `Room` (keeping the last few +messages in memory, storing messages in a database etc.). + +### LiveMessage, LiveSession + +`LiveMessage`s and `LiveSession`s function the same as `Message`s and +`Session`s, with the difference that they contain the `Room` object they +originated from, instead of just a room name. This allows them to also include +a few convenience functions, like `Message.reply`. + +Usually, `Room`s and `Client`s (and thus `Bot`s) will pass `LiveMessage`s and +`LiveSession`s instead of their `Message` and `Session` counterparts. + +### Client + +A `Client` may be connected to a few rooms on euphoria and thus manages a few +`Room` objects. It has functions for joining and leaving rooms on euphoria, and +it can also be connected to the same room multiple times (resulting in multiple +`Room` objects). + +The `Client` has a few `on_` functions (e. g. `on_message`, `on_join`) +that are triggered by events in any of the `Room` objects it manages. This +allows a `Client` to react to various things happening in its `Room`s. + +### Bot + +A `Bot` is a client that: + +- is configured using a config file +- reacts to commands using a command system +- implements most commands specified in the + [botrulez](https://github.com/jedevc/botrulez) + +The config file includes the bot's default nick, initial rooms and bot-specific +configuration. Upon starting a `Bot`, it joins the rooms specified in the +config, setting its nick to the default nick. + +The command system can react to general and specific commands as specified in +the botrulez, and can parse command arguments with or without bash-style string +escaping, and with or without unix-like syntax (flags and optional arguments). + +### Module, ModuleBot + +A `Module` is a `Bot` that can also be used as a module in a `ModuleBot`. This +is like combining multiple bots into a single bot. + +The most notable differences are the new `DESCRIPTION` and `standalone` fields. +The `DESCRIPTION` field contains a short description of the module, whereas the +`standalone` field answers the question whether the `Module` is being run as +standalone bot or part of a `ModuleBot`. From 7b7ddaa0d1a847b876b3c7288bfc1dbb9c5b79be Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 13 Apr 2019 00:29:49 +0000 Subject: [PATCH 115/145] Save bot config file --- CHANGELOG.md | 1 + yaboli/bot.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0437e49..7adfa30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Next version - add !restart to botrulez +- save (overwrite) `Bot` config file ## 0.2.0 (2019-04-12) diff --git a/yaboli/bot.py b/yaboli/bot.py index de6af0e..d08bcdd 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -26,8 +26,10 @@ class Bot(Client): ROOMS_SECTION = "rooms" def __init__(self, config_file: str) -> None: + self.config_file = config_file + self.config = configparser.ConfigParser(allow_no_value=True) - self.config.read(config_file) + self.config.read(self.config_file) nick = self.config[self.GENERAL_SECTION].get("nick") if nick is None: @@ -39,6 +41,10 @@ class Bot(Client): self.start_time = datetime.datetime.now() + def save_config(self) -> None: + with open(self.config_file, "w") as f: + self.config.write(f) + async def started(self) -> None: for room, password in self.config[self.ROOMS_SECTION].items(): if password is None: From e09e2d215f5847e58cad48717b1e943f97cedab9 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 13 Apr 2019 15:32:58 +0000 Subject: [PATCH 116/145] Add cookie support --- CHANGELOG.md | 1 + README.md | 2 +- yaboli/bot.py | 11 +++++-- yaboli/client.py | 30 ++++++++++++++--- yaboli/connection.py | 12 +++++-- yaboli/cookiejar.py | 77 ++++++++++++++++++++++++++++++++++++++++++++ yaboli/room.py | 5 +-- 7 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 yaboli/cookiejar.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7adfa30..e42a1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Next version +- add cookie support - add !restart to botrulez - save (overwrite) `Bot` config file diff --git a/README.md b/README.md index 5e2e933..94bef94 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,6 @@ i. e. the text between the end of the "!echo" and the end of the whole message. ## TODOs - [ ] document yaboli (markdown files in a "docs" folder?) -- [ ] cookie support - [ ] fancy argument parsing - [ ] document new classes (docstrings, maybe comments) - [ ] write examples @@ -74,3 +73,4 @@ i. e. the text between the end of the "!echo" and the end of the whole message. yaboli using `pip install git+https://github.com/Garmelon/yaboli`) - [x] implement !restart - [x] write project readme +- [x] cookie support diff --git a/yaboli/bot.py b/yaboli/bot.py index d08bcdd..3006773 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -33,9 +33,16 @@ class Bot(Client): nick = self.config[self.GENERAL_SECTION].get("nick") if nick is None: - logger.warn("No nick set in config file. Defaulting to empty nick") + logger.warn(("'nick' not set in config file. Defaulting to empty" + " nick")) nick = "" - super().__init__(nick) + + cookie_file = self.config[self.GENERAL_SECTION].get("cookie_file") + if cookie_file is None: + logger.warn(("'cookie_file' not set in config file. Using no cookie" + " file.")) + + super().__init__(nick, cookie_file=cookie_file) self._commands: List[Command] = [] diff --git a/yaboli/client.py b/yaboli/client.py index 5117c45..46777b0 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -1,7 +1,7 @@ import asyncio import functools import logging -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from .message import LiveMessage from .room import Room @@ -12,8 +12,12 @@ logger = logging.getLogger(__name__) __all__ = ["Client"] class Client: - def __init__(self, default_nick: str) -> None: + def __init__(self, + default_nick: str, + cookie_file: Optional[str] = None, + ) -> None: self._default_nick = default_nick + self._cookie_file = cookie_file self._rooms: Dict[str, List[Room]] = {} self._stop = asyncio.Event() @@ -48,13 +52,31 @@ class Client: async def join(self, room_name: str, password: Optional[str] = None, - nick: Optional[str] = None + nick: Optional[str] = None, + cookie_file: Union[str, bool] = True, ) -> Optional[Room]: + """ + cookie_file is the name of the file to store the cookies in. If it is + True, the client default is used. If it is False, no cookie file name + will be used. + """ + logger.info(f"Joining &{room_name}") if nick is None: nick = self._default_nick - room = Room(room_name, password=password, target_nick=nick) + + this_cookie_file: Optional[str] + + if isinstance(cookie_file, str): # This way, mypy doesn't complain + this_cookie_file = cookie_file + elif cookie_file: + this_cookie_file = self._cookie_file + else: + this_cookie_file = None + + room = Room(room_name, password=password, target_nick=nick, + cookie_file=this_cookie_file) room.register_event("connected", functools.partial(self.on_connected, room)) diff --git a/yaboli/connection.py b/yaboli/connection.py index fbb354f..af31d1c 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -6,6 +6,7 @@ from typing import Any, Awaitable, Callable, Dict, Optional import websockets +from .cookiejar import CookieJar from .events import Events from .exceptions import * @@ -97,8 +98,9 @@ class Connection: # Initialising - def __init__(self, url: str) -> None: + def __init__(self, url: str, cookie_file: Optional[str] = None) -> None: self._url = url + self._cookie_jar = CookieJar(cookie_file) self._events = Events() self._packet_id = 0 @@ -181,7 +183,8 @@ class Connection: try: logger.debug(f"Creating ws connection to {self._url!r}") - ws = await websockets.connect(self._url) + ws = await websockets.connect(self._url, + extra_headers=self._cookie_jar.get_cookies_as_headers()) self._ws = ws self._awaiting_replies = {} @@ -189,6 +192,11 @@ class Connection: self._ping_check = asyncio.create_task( self._disconnect_in(self.PING_TIMEOUT)) + # Put received cookies into cookie jar + for set_cookie in ws.response_headers.get_all("Set-Cookie"): + self._cookie_jar.add_cookie(set_cookie) + self._cookie_jar.save() + return True except (websockets.InvalidHandshake, websockets.InvalidStatusCode, diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py new file mode 100644 index 0000000..833dbcb --- /dev/null +++ b/yaboli/cookiejar.py @@ -0,0 +1,77 @@ +import contextlib +import http.cookies as cookies +import logging +from typing import List, Optional, Tuple + +logger = logging.getLogger(__name__) + +__all__ = ["CookieJar"] + +class CookieJar: + """ + Keeps your cookies in a file. + + CookieJar doesn't attempt to discard old cookies, but that doesn't appear + to be necessary for keeping euphoria session cookies. + """ + + def __init__(self, filename: Optional[str] = None) -> None: + self._filename = filename + self._cookies = cookies.SimpleCookie() + + if not self._filename: + logger.warning("Could not load cookies, no filename given.") + return + + with contextlib.suppress(FileNotFoundError): + logger.info(f"Loading cookies from {self._filename!r}") + with open(self._filename, "r") as f: + for line in f: + self._cookies.load(line) + + def get_cookies(self) -> List[str]: + return [morsel.OutputString(attrs=[]) + for morsel in self._cookies.values()] + + def get_cookies_as_headers(self) -> List[Tuple[str, str]]: + """ + Return all stored cookies as tuples in a list. The first tuple entry is + always "Cookie". + """ + + return [("Cookie", cookie) for cookie in self.get_cookies()] + + def add_cookie(self, cookie: str) -> None: + """ + Parse cookie and add it to the jar. + + Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; + HttpOnly; Secure" + """ + + logger.debug(f"Adding cookie {cookie!r}") + self._cookies.load(cookie) + + def save(self) -> None: + """ + Saves all current cookies to the cookie jar file. + """ + + if not self._filename: + logger.warning("Could not save cookies, no filename given.") + return + + logger.info(f"Saving cookies to {self._filename!r}") + + with open(self._filename, "w") as f: + for morsel in self._cookies.values(): + cookie_string = morsel.OutputString() + f.write(f"{cookie_string}\n") + + def clear(self) -> None: + """ + Removes all cookies from the cookie jar. + """ + + logger.debug("OMNOMNOM, cookies are all gone!") + self._cookies = cookies.SimpleCookie() diff --git a/yaboli/room.py b/yaboli/room.py index 4196d5f..053e398 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -60,7 +60,8 @@ class Room: name: str, password: Optional[str] = None, target_nick: str = "", - url_format: str = URL_FORMAT + url_format: str = URL_FORMAT, + cookie_file: Optional[str] = None, ) -> None: self._name = name self._password = password @@ -78,7 +79,7 @@ class Room: # Connected management self._url = self._url_format.format(self._name) - self._connection = Connection(self._url) + self._connection = Connection(self._url, cookie_file=cookie_file) self._events = Events() self._connected = asyncio.Event() From 0d58f616527f45682891bb36779bd873ab26bd85 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 13 Apr 2019 15:36:02 +0000 Subject: [PATCH 117/145] Clean up readme --- README.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 94bef94..ca1c982 100644 --- a/README.md +++ b/README.md @@ -3,24 +3,19 @@ Yaboli (**Y**et **A**nother **Bo**t **Li**brary) is a Python library for creating bots for [euphoria.io](https://euphoria.io). -- [Changelog](CHANGELOG.md) - [Documentation](docs/index.md) - -Soon, markdown files containing troubleshooting info will be available. +- [Changelog](CHANGELOG.md) ## Installation -Ensure that you have at least Python 3.7 installed. The commands below assume -that `python` points this version of Python. - -In your project directory, run: +Ensure that you have at least Python 3.7 installed. ``` -$ python -m venv . -$ . bin/activate $ pip install git+https://github.com/Garmelon/yaboli@v0.2.0 ``` +The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. + ## Example echo bot A simple echo bot that conforms to the @@ -43,7 +38,7 @@ class EchoBot(yaboli.Bot): await message.reply(args.raw) ``` -The bot's nick and default rooms are specified in a config file. +The bot's nick, cookie file and default rooms are specified in a config file. The help command from the botrulez uses the `HELP_GENERAL` and `HELP_SPECIFIC` fields. From 135640ca443f83f2378fa3c072acf9f02e6efd92 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 13 Apr 2019 20:23:02 +0000 Subject: [PATCH 118/145] Log in/out and pm --- CHANGELOG.md | 2 + yaboli/client.py | 6 +++ yaboli/room.py | 122 ++++++++++++++++++++++++++++++++++++++-------- yaboli/session.py | 10 +++- 4 files changed, 117 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e42a1fe..812a225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +- add login and logout command to room +- add pm command to room - add cookie support - add !restart to botrulez - save (overwrite) `Bot` config file diff --git a/yaboli/client.py b/yaboli/client.py index 46777b0..75806fb 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -152,6 +152,12 @@ class Client: async def on_edit(self, room: Room, message: LiveMessage) -> None: pass + async def on_login(self, room: Room, account_id: str) -> None: + pass + + async def on_logout(self, room: Room) -> None: + pass + async def on_pm(self, room: Room, from_id: str, diff --git a/yaboli/room.py b/yaboli/room.py index 053e398..905162f 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,6 +1,6 @@ import asyncio import logging -from typing import Any, Awaitable, Callable, List, Optional, TypeVar +from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar from .connection import Connection from .events import Events @@ -43,6 +43,12 @@ class Room: "edit" - a message in the room has been modified or deleted message: LiveMessage + "login" - this session has been logged in from another session + account_id: str + + "logout" - this session has been logged out from another session + no parameters + "pm" - another session initiated a pm with you from: str - the id of the user inviting the client to chat privately from_nick: str - the nick of the inviting user @@ -117,11 +123,22 @@ class Room: # Connecting, reconnecting and disconnecting - def _set_connected(self) -> None: + async def _try_set_connected(self) -> None: packets_received = self._hello_received and self._snapshot_received if packets_received and not self._connected.is_set(): - self._connected_successfully = True - self._connected.set() + await self._set_nick_if_necessary() + self._set_connected() + + async def _set_nick_if_necessary(self) -> None: + nick_needs_updating = (self._session is None + or self._target_nick != self._session.nick) + + if self._target_nick and nick_needs_updating: + await self._nick(self._target_nick) + + def _set_connected(self) -> None: + self._connected_successfully = True + self._connected.set() def _set_connected_failed(self) -> None: if not self._connected.is_set(): @@ -148,7 +165,7 @@ class Room: self._account = Account.from_data(data) self._hello_received = True - self._set_connected() + await self._try_set_connected() async def _on_snapshot_event(self, packet: Any) -> None: data = packet["data"] @@ -169,7 +186,7 @@ class Room: self._events.fire("session", messages) self._snapshot_received = True - self._set_connected() + await self._try_set_connected() async def _on_bounce_event(self, packet: Any) -> None: data = packet["data"] @@ -207,11 +224,6 @@ class Room: if not self._connected_successfully: return False - nick_needs_updating = (self._session is None - or self._target_nick != self._session.nick) - if self._target_nick and nick_needs_updating: - await self._nick(self._target_nick) - self._events.fire("connected") return True @@ -243,14 +255,34 @@ class Room: session = LiveSession.from_data(self, data) self._users = self.users.with_join(session) - logger.info(f"{session.atmention} joined") + logger.info(f"&{self.name}: {session.atmention} joined") self._events.fire("join", session) async def _on_login_event(self, packet: Any) -> None: - pass # TODO implement once cookie support is here + """ + Just reconnect, see + https://github.com/euphoria-io/heim/blob/master/client/lib/stores/chat.js#L275-L276 + """ + + data = packet["data"] + + account_id = data["account_id"] + + self._events.fire("login", account_id) + logger.info(f"&{self.name}: Got logged in to {account_id}, reconnecting") + + await self._connection.reconnect() async def _on_logout_event(self, packet: Any) -> None: - pass # TODO implement once cookie support is here + """ + Just reconnect, see + https://github.com/euphoria-io/heim/blob/master/client/lib/stores/chat.js#L275-L276 + """ + + self._events.fire("logout") + logger.info(f"&{self.name}: Got logged out, reconnecting") + + await self._connection.reconnect() async def _on_network_event(self, packet: Any) -> None: data = packet["data"] @@ -264,7 +296,7 @@ class Room: for user in self.users: if user.server_id == server_id and user.server_era == server_era: users = users.with_part(user) - logger.info(f"{user.atmention} left") + logger.info(f"&{self.name}: {user.atmention} left") self._events.fire("part", user) self._users = users @@ -281,7 +313,7 @@ class Room: else: await self.who() # recalibrating self._users - logger.info(f"{atmention(nick_from)} is now called {atmention(nick_to)}") + logger.info(f"&{self.name}: {atmention(nick_from)} is now called {atmention(nick_to)}") self._events.fire("nick", session, nick_from, nick_to) async def _on_edit_message_event(self, packet: Any) -> None: @@ -297,7 +329,7 @@ class Room: session = LiveSession.from_data(self, data) self._users = self.users.with_part(session) - logger.info(f"{session.atmention} left") + logger.info(f"&{self.name}: {session.atmention} left") self._events.fire("part", session) async def _on_pm_initiate_event(self, packet: Any) -> None: @@ -374,10 +406,6 @@ class Room: # Functionality - # These functions require cookie support and are thus not implemented yet: - # - # login, logout, pm - def _extract_data(self, packet: Any) -> Any: error = packet.get("error") if error is not None: @@ -477,3 +505,55 @@ class Room: self._users = users return self._users + + async def login(self, email: str, password: str) -> Tuple[bool, str]: + """ + Since euphoria appears to only support email authentication, this way + of logging in is hardcoded here. + + Returns whether the login was successful. If it was, the second + parameter is the account id. If it wasn't, the second parameter is the + reason why the login failed. + """ + + data: Any = { + "namespace": "email", + "id": email, + "password": password, + } + + reply = await self._connection.send("login", data) + data = self._extract_data(reply) + + success: bool = data["success"] + account_id_or_reason = data.get("account_id") or data["reason"] + + if success: + logger.info(f"&{self.name}: Logged in as {account_id_or_reason}") + else: + logger.info(f"&{self.name}: Failed to log in with {email} because {account_id_or_reason}") + + await self._connection.reconnect() + + return success, account_id_or_reason + + async def logout(self) -> None: + await self._connection.send("logout", {}) + + logger.info(f"&{self.name}: Logged out") + + await self._connection.reconnect() + + async def pm(self, user_id: str) -> Tuple[str, str]: + """ + Returns the pm_id of the pm and the nick of the person being pinged. + """ + + data = {"user_id": user_id} + + reply = await self._connection.send("pm-initiate", data) + data = self._extract_data(reply) + + pm_id = data["pm_id"] + to_nick = data["to_nick"] + return pm_id, to_nick diff --git a/yaboli/session.py b/yaboli/session.py index 5adcbcb..e59c81a 100644 --- a/yaboli/session.py +++ b/yaboli/session.py @@ -1,5 +1,6 @@ import re -from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional +from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, + Optional, Tuple) from .util import mention, normalize @@ -238,7 +239,12 @@ class LiveSession(Session): # Live stuff - # TODO pm, once pm support is there. + async def pm(self) -> Tuple[str, str]: + """ + See Room.pm + """ + + return await self.room.pm(self.user_id) class LiveSessionListing: def __init__(self, room: "Room", sessions: Iterable[LiveSession]) -> None: From 24128a460a5ddb4939899ed4206ee596f9c3e690 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 13 Apr 2019 21:47:27 +0000 Subject: [PATCH 119/145] Add fancy argument parsing --- CHANGELOG.md | 1 + README.md | 2 +- yaboli/command.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 812a225..eea3778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Next version +- add fancy argument parsing - add login and logout command to room - add pm command to room - add cookie support diff --git a/README.md b/README.md index ca1c982..5cc43d8 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,6 @@ i. e. the text between the end of the "!echo" and the end of the whole message. ## TODOs - [ ] document yaboli (markdown files in a "docs" folder?) -- [ ] fancy argument parsing - [ ] document new classes (docstrings, maybe comments) - [ ] write examples - [ ] make yaboli package play nice with mypy @@ -69,3 +68,4 @@ i. e. the text between the end of the "!echo" and the end of the whole message. - [x] implement !restart - [x] write project readme - [x] cookie support +- [x] fancy argument parsing diff --git a/yaboli/command.py b/yaboli/command.py index e355bb0..08ac3f7 100644 --- a/yaboli/command.py +++ b/yaboli/command.py @@ -23,9 +23,70 @@ __all__ = ["FancyArgs", "ArgumentData", "SpecificArgumentData", "CommandData", "SpecificCommandFunction", "SpecificCommand"] class FancyArgs(NamedTuple): + """ + The fancy argument parser supports arguments of the following formats: + + + FLAGS: + + These are one or more characters preceded by a single dash. Examples: + + -a, -fghf, -vv + + The fancy argument parser counts how often each character (also called + flag) appears. Each flag that appears once or more gets an entry in the + "flags" dict of the form: flags[flag] = amount + + Exception: A single dash ("-") is interpreted as a positional argument. + + + OPTIONAL: + + These are arguments of the form -- or --=, where + is the name of the optional argument and is its (optional) value. + + Due to this syntax, the may not include any "=" signs. + + The optional arguments are collected in a dict of the form: + + optional[name] = value or None + + If the optional argument included a "=" after the name, but no further + characters, its value is the empty string. If it didn't include a "=" after + the name, its value is None. + + If more than one optional argument appears with the same name, the last + argument's value is kept and all previous values discarded. + + + POSITIONAL: + + Positional arguments are all arguments that don't start with "-" or "--". + They are compiled in a list and ordered in the same order they appeared in + after the command. + + + RAW: + + At any time, a single "--" argument may be inserted. This separates the + positional and optional arguments and the flags from the raw arguments. All + arguments after the "--" are interpreted as raw arguments, even flags, + optional arguments and further "--"s. + + For example, consider the following arguments: + + ab -cd -c --ef=g --h i -- j --klm -nop -- qr + + positional: ["ab", "i"] + optional: {"ef": "g", "h": None} + flags: {"c": 2, "d": 1} + raw: ["j", "--klm", "-nop", "--", "qr"] + """ + positional: List[str] optional: Dict[str, Optional[str]] flags: Dict[str, int] + raw: List[str] class ArgumentData: def __init__(self, raw: str) -> None: @@ -94,7 +155,38 @@ class ArgumentData: return text.split() def _parse_fancy(self, args: List[str]) -> FancyArgs: - raise NotImplementedError # TODO + positional: List[str] = [] + optional: Dict[str, Optional[str]] = {} + flags: Dict[str, int] = {} + raw: List[str] = [] + + is_raw = False + + for arg in args: + # raw arguments + if is_raw: + raw.append(arg) + # raw arguments separator + elif arg == "--": + is_raw = True + # optional arguments + elif arg[:2] == "--": + split = arg[2:].split("=", maxsplit=1) + name = split[0] + value = split[1] if len(split) == 2 else None + optional[name] = value + # the "-" exception + elif arg == "-": + positional.append(arg) + # flags + elif arg[:1] == "-": + for char in arg[1:]: + flags[char] = flags.get(char, 0) + 1 + # positional arguments + else: + positional.append(arg) + + return FancyArgs(positional, optional, flags, raw) @property def raw(self) -> str: From 7e56de60da7e70627af76499ce390e17bb9ef1c3 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 13 Apr 2019 21:49:00 +0000 Subject: [PATCH 120/145] Clean up changelog and readme --- CHANGELOG.md | 3 ++- docs/index.md | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea3778..fc01070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ - add pm command to room - add cookie support - add !restart to botrulez -- save (overwrite) `Bot` config file +- add Bot config file saving +- fix the Room not setting its nick correctly upon reconnecting ## 0.2.0 (2019-04-12) diff --git a/docs/index.md b/docs/index.md index 97d28a3..3311e56 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,10 +10,9 @@ A `Message` represents a single message. It contains all the fields [specified in the API](http://api.euphoria.io/#message), in addition to a few utility functions. -Similar to a `Message`, a `Session` represents a session and also contains -almost all the fields [specified in the -API](http://api.euphoria.io/#sessionview), in addition to a few utility -functions. +Similar to a `Message`, a `Session` represents a [session +view](http://api.euphoria.io/#sessionview) and also contains almost all the +fields specified in the API, in addition to a few utility functions. `Message`s and `Session`s also both contain the name of the room they originated from. From 2f7502723bcaaa36584582e7e054d349010e9aab Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 13 Apr 2019 22:09:58 +0000 Subject: [PATCH 121/145] Bump version to 1.0.0 --- CHANGELOG.md | 2 ++ README.md | 2 +- setup.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc01070..a77a9bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +# 1.0.0 (2019-04-13) + - add fancy argument parsing - add login and logout command to room - add pm command to room diff --git a/README.md b/README.md index 5cc43d8..c42a5d6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ creating bots for [euphoria.io](https://euphoria.io). Ensure that you have at least Python 3.7 installed. ``` -$ pip install git+https://github.com/Garmelon/yaboli@v0.2.0 +$ pip install git+https://github.com/Garmelon/yaboli@v1.0.0 ``` The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. diff --git a/setup.py b/setup.py index eb4d419..0ae307f 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name="yaboli", - version="0.2.0", + version="1.0.0", packages=["yaboli"], install_requires=["websockets==7.0"], ) From 86472afb3f6eeddfc6b22e8b76c89a98d92c238b Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 14 Apr 2019 16:07:37 +0000 Subject: [PATCH 122/145] Pass along ConfigParsers instead of file names --- CHANGELOG.md | 3 ++ yaboli/__init__.py | 32 ++++++++++++++--- yaboli/bot.py | 15 ++++---- yaboli/module.py | 86 +++++++++++++++++++++++++++++++++++++--------- 4 files changed, 109 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a77a9bd..e8064c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Next version +- change how config files are passed along +- change module system to support config file changes + # 1.0.0 (2019-04-13) - add fancy argument parsing diff --git a/yaboli/__init__.py b/yaboli/__init__.py index a964c61..a9df9c2 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,6 +1,7 @@ import asyncio +import configparser import logging -from typing import Callable +from typing import Callable, Dict from .bot import * from .client import * @@ -48,12 +49,33 @@ def enable_logging(name: str = "yaboli", level: int = logging.INFO) -> None: logger.addHandler(handler) def run( - client: Callable[[str], Client], - config_file: str = "bot.conf" + bot_constructor: BotConstructor, + config_file: str = "bot.conf", ) -> None: + # Load the config file + config = configparser.ConfigParser(allow_no_value=True) + config.read(config_file) + async def _run() -> None: while True: - client_ = client(config_file) - await client_.run() + bot = bot_constructor(config, config_file) + await bot.run() + + asyncio.run(_run()) + +def run_modulebot( + modulebot_constructor: ModuleBotConstructor, + module_constructors: Dict[str, ModuleConstructor], + config_file: str = "bot.conf", + ) -> None: + # Load the config file + config = configparser.ConfigParser(allow_no_value=True) + config.read(config_file) + + async def _run() -> None: + while True: + modulebot = modulebot_constructor(config, config_file, + module_constructors) + await modulebot.run() asyncio.run(_run()) diff --git a/yaboli/bot.py b/yaboli/bot.py index 3006773..c696820 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,7 +1,7 @@ import configparser import datetime import logging -from typing import List, Optional +from typing import Callable, List, Optional from .client import Client from .command import * @@ -11,7 +11,7 @@ from .util import * logger = logging.getLogger(__name__) -__all__ = ["Bot"] +__all__ = ["Bot", "BotConstructor"] class Bot(Client): ALIASES: List[str] = [] @@ -25,12 +25,13 @@ class Bot(Client): GENERAL_SECTION = "general" ROOMS_SECTION = "rooms" - def __init__(self, config_file: str) -> None: + def __init__(self, + config: configparser.ConfigParser, + config_file: str, + ) -> None: + self.config = config self.config_file = config_file - self.config = configparser.ConfigParser(allow_no_value=True) - self.config.read(self.config_file) - nick = self.config[self.GENERAL_SECTION].get("nick") if nick is None: logger.warn(("'nick' not set in config file. Defaulting to empty" @@ -188,3 +189,5 @@ class Bot(Client): logger.info(f"Restarted in &{room.name} by {message.sender.atmention}") await message.reply(self.RESTART_REPLY) await self.stop() + +BotConstructor = Callable[[configparser.ConfigParser, str], Bot] diff --git a/yaboli/module.py b/yaboli/module.py index 2dc9b0f..ac750bf 100644 --- a/yaboli/module.py +++ b/yaboli/module.py @@ -1,5 +1,6 @@ +import configparser import logging -from typing import Dict, List, Optional +from typing import Callable, Dict, List, Optional from .bot import Bot from .command import * @@ -10,49 +11,77 @@ from .util import * logger = logging.getLogger(__name__) -__all__ = ["Module", "ModuleBot"] +__all__ = ["Module", "ModuleConstructor", "ModuleBot", "ModuleBotConstructor"] class Module(Bot): DESCRIPTION: Optional[str] = None - def __init__(self, config_file: str, standalone: bool) -> None: - super().__init__(config_file) + def __init__(self, + config: configparser.ConfigParser, + config_file: str, + standalone: bool = True, + ) -> None: + super().__init__(config, config_file) self.standalone = standalone +ModuleConstructor = Callable[[configparser.ConfigParser, str, bool], Module] + class ModuleBot(Bot): HELP_PRE: Optional[List[str]] = [ "This bot contains the following modules:" ] HELP_POST: Optional[List[str]] = [ - "" - "Use \"!help {atmention} \" to get more information on a" - " specific module." + "", + "For module-specific help, try \"!help {atmention} \".", ] MODULE_HELP_LIMIT = 5 - def __init__(self, config_file: str) -> None: - super().__init__(config_file) + MODULES_SECTION = "modules" + def __init__(self, + config: configparser.ConfigParser, + config_file: str, + module_constructors: Dict[str, ModuleConstructor], + ) -> None: + super().__init__(config, config_file) + + self.module_constructors = module_constructors self.modules: Dict[str, Module] = {} - self.register_botrulez(help_=False) - self.register_general("help", self.cmd_help_general, args=False) - self.register_specific("help", self.cmd_help_specific, args=True) + # Load initial modules + for module_name in self.config[self.MODULES_SECTION]: + module_constructor = self.module_constructors.get(module_name) + if module_constructor is None: + logger.warn(f"Module {module_name} not found") + continue + # standalone is set to False + module = module_constructor(self.config, self.config_file, False) + self.load_module(module_name, module) - def register_module(self, name: str, module: Module) -> None: + def load_module(self, name: str, module: Module) -> None: if name in self.modules: logger.warn(f"Module {name!r} is already registered, overwriting...") self.modules[name] = module + def unload_module(self, name: str) -> None: + if name in self.modules: + del self.modules[name] + + # Better help messages + def compile_module_overview(self) -> List[str]: lines = [] if self.HELP_PRE is not None: lines.extend(self.HELP_PRE) + any_modules = False + modules_without_desc: List[str] = [] for module_name in sorted(self.modules): + any_modules = True + module = self.modules[module_name] if module.DESCRIPTION is None: @@ -62,7 +91,10 @@ class ModuleBot(Bot): lines.append(line) if modules_without_desc: - lines.append(", ".join(modules_without_desc)) + lines.append("\t" + ", ".join(modules_without_desc)) + + if not any_modules: + lines.append("No modules loaded.") if self.HELP_POST is not None: lines.extend(self.HELP_POST) @@ -79,8 +111,7 @@ class ModuleBot(Bot): return module.HELP_SPECIFIC - # Overwriting the botrulez help function - async def cmd_help_specific(self, + async def cmd_modules_help(self, room: Room, message: LiveMessage, args: SpecificArgumentData @@ -100,6 +131,12 @@ class ModuleBot(Bot): # Sending along all kinds of events + async def on_connected(self, room: Room) -> None: + await super().on_connected(room) + + for module in self.modules.values(): + await module.on_connected(room) + async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None: await super().on_snapshot(room, messages) @@ -141,6 +178,18 @@ class ModuleBot(Bot): for module in self.modules.values(): await module.on_edit(room, message) + async def on_login(self, room: Room, account_id: str) -> None: + await super().on_login(room, account_id) + + for module in self.modules.values(): + await module.on_login(room, account_id) + + async def on_logout(self, room: Room) -> None: + await super().on_logout(room) + + for module in self.modules.values(): + await module.on_logout(room) + async def on_pm(self, room: Room, from_id: str, @@ -158,3 +207,8 @@ class ModuleBot(Bot): for module in self.modules.values(): await module.on_disconnect(room, reason) + +ModuleBotConstructor = Callable[ + [configparser.ConfigParser, str, Dict[str, ModuleConstructor]], + Bot +] From 7780cb92dea9d305d2ce33e847227190a9075225 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 14 Apr 2019 16:10:40 +0000 Subject: [PATCH 123/145] Update the docs --- docs/bot_setup.md | 13 +++++++++++++ docs/index.md | 13 ++++++++++++- examples/gitignore_with_venv | 13 +++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 docs/bot_setup.md create mode 100644 examples/gitignore_with_venv diff --git a/docs/bot_setup.md b/docs/bot_setup.md new file mode 100644 index 0000000..cf6722d --- /dev/null +++ b/docs/bot_setup.md @@ -0,0 +1,13 @@ +# Setting up and running a bot + +## Installing yaboli + +TODO + +## Configuring the bot + +TODO + +## Running the bot + +TODO diff --git a/docs/index.md b/docs/index.md index 3311e56..9f4835f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,17 @@ # Index for yaboli docs -Links to specific sections will be added here. + - [Setting up and running a bot](bot_setup.md) + - Classes + - [Bot](bot.md) + +## Getting started + +First, read the [overview](#library-structure-overview) below. + +To set up your project, follow the [setup guide](bot_setup.md). + +To get a feel for how bots are structured, have a look at the example bots or +read through the docstrings in the `Bot` class. ## Library structure overview diff --git a/examples/gitignore_with_venv b/examples/gitignore_with_venv new file mode 100644 index 0000000..191feb7 --- /dev/null +++ b/examples/gitignore_with_venv @@ -0,0 +1,13 @@ +# python stuff +__pycache__/ + +# venv stuff +bin/ +include/ +lib/ +lib64 +pyvenv.cfg + +# config files +*.conf +cookie_jar From 7e74499f8197ec48d58e0ed8c0172f9298d55708 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 14 Apr 2019 16:12:33 +0000 Subject: [PATCH 124/145] Bump version to 1.1.0 --- CHANGELOG.md | 2 ++ README.md | 3 ++- setup.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8064c5..5e18410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +## 1.1.0 (2019-04-14) + - change how config files are passed along - change module system to support config file changes diff --git a/README.md b/README.md index c42a5d6..c7ef69a 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ creating bots for [euphoria.io](https://euphoria.io). Ensure that you have at least Python 3.7 installed. +To install yaboli or update your installation to the latest version, run: ``` -$ pip install git+https://github.com/Garmelon/yaboli@v1.0.0 +$ pip install git+https://github.com/Garmelon/yaboli@v1.1.0 ``` The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. diff --git a/setup.py b/setup.py index 0ae307f..bf3a4ab 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name="yaboli", - version="1.0.0", + version="1.1.0", packages=["yaboli"], install_requires=["websockets==7.0"], ) From c579adca9a265675ae4eaab7201a979d28bd1363 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 14 Apr 2019 19:27:16 +0000 Subject: [PATCH 125/145] Re-add database --- CHANGELOG.md | 2 ++ yaboli/__init__.py | 2 ++ yaboli/database.py | 40 ++++++++++++++++++++++++++++++++++++++++ yaboli/util.py | 12 ++++++++++-- 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 yaboli/database.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e18410..147d068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +- add database class for easier sqlite3 access + ## 1.1.0 (2019-04-14) - change how config files are passed along diff --git a/yaboli/__init__.py b/yaboli/__init__.py index a9df9c2..b138c88 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -7,6 +7,7 @@ from .bot import * from .client import * from .command import * from .connection import * +from .database import * from .events import * from .exceptions import * from .message import * @@ -22,6 +23,7 @@ __all__ += bot.__all__ __all__ += client.__all__ __all__ += command.__all__ __all__ += connection.__all__ +__all__ += database.__all__ __all__ += events.__all__ __all__ += exceptions.__all__ __all__ += message.__all__ diff --git a/yaboli/database.py b/yaboli/database.py new file mode 100644 index 0000000..84af548 --- /dev/null +++ b/yaboli/database.py @@ -0,0 +1,40 @@ +import asyncio +import logging +import sqlite3 +from typing import Any, Awaitable, Callable, TypeVar + +from .util import asyncify + +logger = logging.getLogger(__name__) + +__all__ = ["Database", "operation"] + +T = TypeVar('T') + +def operation(func: Callable[..., T]) -> Callable[..., Awaitable[T]]: + async def wrapper(self: Any, *args: Any, **kwargs: Any) -> T: + async with self as db: + while True: + try: + return await asyncify(func, self, db, *args, **kwargs) + except sqlite3.OperationalError as e: + logger.warn(f"Operational error encountered: {e}") + await asyncio.sleep(5) + return wrapper + +class Database: + def __init__(self, database: str) -> None: + self._connection = sqlite3.connect(database, check_same_thread=False) + self._lock = asyncio.Lock() + + self.initialize(self._connection) + + def initialize(self, db: Any) -> None: + pass + + async def __aenter__(self) -> Any: + await self._lock.__aenter__() + return self._connection + + async def __aexit__(self, *args: Any, **kwargs: Any) -> Any: + return await self._lock.__aexit__(*args, **kwargs) diff --git a/yaboli/util.py b/yaboli/util.py index 6439799..e8395d9 100644 --- a/yaboli/util.py +++ b/yaboli/util.py @@ -1,8 +1,16 @@ +import asyncio import datetime +import functools import re +from typing import Any, Callable -__all__ = ["mention", "atmention", "normalize", "similar", "plural", - "format_time", "format_delta"] +__all__ = ["asyncify", "mention", "atmention", "normalize", "similar", + "plural", "format_time", "format_delta"] + +async def asyncify(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + func_with_args = functools.partial(func, *args, **kwargs) + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, func_with_args) # Name/nick related functions From 838c364066f3b91f9d678456f44b55d3113c28ed Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 14 Apr 2019 19:28:40 +0000 Subject: [PATCH 126/145] Bump version to 1.1.1 --- CHANGELOG.md | 2 ++ README.md | 2 +- setup.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 147d068..169e87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +## 1.1.1 (2019-04-14) + - add database class for easier sqlite3 access ## 1.1.0 (2019-04-14) diff --git a/README.md b/README.md index c7ef69a..b51b517 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Ensure that you have at least Python 3.7 installed. To install yaboli or update your installation to the latest version, run: ``` -$ pip install git+https://github.com/Garmelon/yaboli@v1.1.0 +$ pip install git+https://github.com/Garmelon/yaboli@v1.1.1 ``` The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. diff --git a/setup.py b/setup.py index bf3a4ab..a3b8de7 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name="yaboli", - version="1.1.0", + version="1.1.1", packages=["yaboli"], install_requires=["websockets==7.0"], ) From 1297cf201b24f4ca02976a238074bf74aef28dff Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 14 Apr 2019 19:56:14 +0000 Subject: [PATCH 127/145] Fix room authentication --- CHANGELOG.md | 2 ++ yaboli/room.py | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 169e87f..399f28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +- fix room authentication + ## 1.1.1 (2019-04-14) - add database class for easier sqlite3 access diff --git a/yaboli/room.py b/yaboli/room.py index 905162f..5ea5e03 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -191,8 +191,11 @@ class Room: async def _on_bounce_event(self, packet: Any) -> None: data = packet["data"] - # Can we even authenticate? - if not "passcode" in data.get("auth_options", []): + # Can we even authenticate? (Assuming that passcode authentication is + # available if no authentication options are given: Euphoria doesn't + # (always) send authentication options, even when passcode + # authentication works.) + if not "passcode" in data.get("auth_options", ["passcode"]): self._set_connected_failed() return From e53ce42e99acc3efc8625b3a2367ae9926df1b9c Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 14 Apr 2019 20:03:29 +0000 Subject: [PATCH 128/145] Bump version to 1.1.2 --- CHANGELOG.md | 3 +++ README.md | 2 +- setup.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 399f28f..86bd86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ ## Next version +## 1.1.2 (2019-04-14) + - fix room authentication +- resolve to test yaboli more thoroughly before publishing a new version ## 1.1.1 (2019-04-14) diff --git a/README.md b/README.md index b51b517..92f1e14 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Ensure that you have at least Python 3.7 installed. To install yaboli or update your installation to the latest version, run: ``` -$ pip install git+https://github.com/Garmelon/yaboli@v1.1.1 +$ pip install git+https://github.com/Garmelon/yaboli@v1.1.2 ``` The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. diff --git a/setup.py b/setup.py index a3b8de7..b3b1208 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name="yaboli", - version="1.1.1", + version="1.1.2", packages=["yaboli"], install_requires=["websockets==7.0"], ) From d9f25a04fbc0358dd56c69f2e14ae3d23d060461 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 14 Apr 2019 22:25:42 +0000 Subject: [PATCH 129/145] Time out when creating the ws connections --- CHANGELOG.md | 2 ++ yaboli/connection.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86bd86a..f996329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +- add timeout for creating ws connections + ## 1.1.2 (2019-04-14) - fix room authentication diff --git a/yaboli/connection.py b/yaboli/connection.py index af31d1c..8af43c3 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -82,6 +82,9 @@ class Connection: "part-event" and "ping". """ + # Timeout for waiting for the ws connection to be established + CONNECT_TIMEOUT = 10 # seconds + # Maximum duration between euphoria's ping messages. Euphoria usually sends # ping messages every 20 to 30 seconds. PING_TIMEOUT = 40 # seconds @@ -183,8 +186,12 @@ class Connection: try: logger.debug(f"Creating ws connection to {self._url!r}") - ws = await websockets.connect(self._url, - extra_headers=self._cookie_jar.get_cookies_as_headers()) + ws = await asyncio.wait_for( + websockets.connect(self._url, + extra_headers=self._cookie_jar.get_cookies_as_headers()), + self.CONNECT_TIMEOUT + ) + logger.debug(f"Established ws connection to {self._url!r}") self._ws = ws self._awaiting_replies = {} @@ -200,7 +207,7 @@ class Connection: return True except (websockets.InvalidHandshake, websockets.InvalidStatusCode, - socket.gaierror): + socket.gaierror, asyncio.TimeoutError): logger.debug("Connection failed") return False From de4ba53de85cfa40cd184023e5ee73e4bda18c9a Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 19 Apr 2019 09:57:09 +0000 Subject: [PATCH 130/145] Fix config file not reloading on bot restart --- CHANGELOG.md | 1 + yaboli/__init__.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f996329..9838fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Next version - add timeout for creating ws connections +- fix config file not reloading when restarting bots ## 1.1.2 (2019-04-14) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index b138c88..241bf0e 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -54,12 +54,12 @@ def run( bot_constructor: BotConstructor, config_file: str = "bot.conf", ) -> None: - # Load the config file - config = configparser.ConfigParser(allow_no_value=True) - config.read(config_file) - async def _run() -> None: while True: + # Load the config file + config = configparser.ConfigParser(allow_no_value=True) + config.read(config_file) + bot = bot_constructor(config, config_file) await bot.run() @@ -70,12 +70,12 @@ def run_modulebot( module_constructors: Dict[str, ModuleConstructor], config_file: str = "bot.conf", ) -> None: - # Load the config file - config = configparser.ConfigParser(allow_no_value=True) - config.read(config_file) - async def _run() -> None: while True: + # Load the config file + config = configparser.ConfigParser(allow_no_value=True) + config.read(config_file) + modulebot = modulebot_constructor(config, config_file, module_constructors) await modulebot.run() From 83af4ff9e850c5043eb4ce8f6c6ab41eed8608a9 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 19 Apr 2019 10:01:50 +0000 Subject: [PATCH 131/145] Bump version to 1.1.3 --- CHANGELOG.md | 4 ++++ README.md | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9838fd8..2a2f6ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Next version +Nothing yet + +## 1.1.3 (2019-04-19) + - add timeout for creating ws connections - fix config file not reloading when restarting bots diff --git a/README.md b/README.md index 92f1e14..becb80c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Ensure that you have at least Python 3.7 installed. To install yaboli or update your installation to the latest version, run: ``` -$ pip install git+https://github.com/Garmelon/yaboli@v1.1.2 +$ pip install git+https://github.com/Garmelon/yaboli@v1.1.3 ``` The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. diff --git a/setup.py b/setup.py index b3b1208..4ddafc0 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name="yaboli", - version="1.1.2", + version="1.1.3", packages=["yaboli"], install_requires=["websockets==7.0"], ) From ca56de710c31c18fafdc0a67a809e5d4c2f4ab53 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 19 Apr 2019 10:04:12 +0000 Subject: [PATCH 132/145] Fix changelog formatting --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a2f6ec..1cf5072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Nothing yet - change how config files are passed along - change module system to support config file changes -# 1.0.0 (2019-04-13) +## 1.0.0 (2019-04-13) - add fancy argument parsing - add login and logout command to room @@ -35,9 +35,9 @@ Nothing yet ## 0.2.0 (2019-04-12) -- change config file format - add `ALIASES` variable to `Bot` - add `on_connected` function to `Client` +- change config file format ## 0.1.0 (2019-04-12) From eb9cc4f9bd2c583ddf77ff1e9f1e506b3ea21c94 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 19 Apr 2019 10:52:39 +0000 Subject: [PATCH 133/145] Make KILL_REPLY and RESTART_REPLY optional --- yaboli/bot.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/yaboli/bot.py b/yaboli/bot.py index c696820..eba8669 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -19,8 +19,8 @@ class Bot(Client): PING_REPLY: str = "Pong!" HELP_GENERAL: Optional[str] = None HELP_SPECIFIC: Optional[List[str]] = None - KILL_REPLY: str = "/me dies" - RESTART_REPLY: str = "/me restarts" + KILL_REPLY: Optional[str] = "/me dies" + RESTART_REPLY: Optional[str] = "/me restarts" GENERAL_SECTION = "general" ROOMS_SECTION = "rooms" @@ -178,7 +178,10 @@ class Bot(Client): args: SpecificArgumentData ) -> None: logger.info(f"Killed in &{room.name} by {message.sender.atmention}") - await message.reply(self.KILL_REPLY) + + if self.KILL_REPLY is not None: + await message.reply(self.KILL_REPLY) + await self.part(room) async def cmd_restart(self, @@ -187,7 +190,10 @@ class Bot(Client): args: SpecificArgumentData ) -> None: logger.info(f"Restarted in &{room.name} by {message.sender.atmention}") - await message.reply(self.RESTART_REPLY) + + if self.RESTART_REPLY is not None: + await message.reply(self.RESTART_REPLY) + await self.stop() BotConstructor = Callable[[configparser.ConfigParser, str], Bot] From 6a15e1a9488464322203633ca558ff333e81e706 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 19 Apr 2019 11:06:27 +0000 Subject: [PATCH 134/145] Add docstrings to Bot functions --- CHANGELOG.md | 3 +- yaboli/bot.py | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf5072..a927e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Next version -Nothing yet +- add docstrings to `Bot` +- change `KILL_REPLY` and `RESTART_REPLY` to be optional in `Bot` ## 1.1.3 (2019-04-19) diff --git a/yaboli/bot.py b/yaboli/bot.py index eba8669..97385cb 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -14,6 +14,36 @@ logger = logging.getLogger(__name__) __all__ = ["Bot", "BotConstructor"] class Bot(Client): + """ + A Bot is a Client that responds to commands and uses a config file to + automatically set its nick and join rooms. + + The config file is loaded as a ConfigParser by the run() or run_modulebot() + functions and has the following structure: + + A "general" section which contains: + - nick - the default nick of the bot (set to the empty string if you don't + want to set a nick) + - cookie_file (optional) - the file the cookie should be saved in + + A "rooms" section which contains a list of rooms that the bot should + automatically join. This section is optional if you overwrite started(). + The room list should have the format "roomname" or "roomname = password". + + A bot has the following attributes: + - ALIASES - list of alternate nicks the bot responds to (see + process_commands()) + - PING_REPLY - used by cmd_ping() + - HELP_GENERAL - used by cmd_help_general() + - HELP_SPECIFIC - used by cmd_help_specific() + - KILL_REPLY - used by cmd_kill() + - RESTART_REPLY - used by cmd_restart() + - GENERAL_SECTION - the name of the "general" section in the config file + (see above) (default: "general") + - ROOMS_SECTION - the name of the "rooms" section in the config file (see + above) (default: "rooms") + """ + ALIASES: List[str] = [] PING_REPLY: str = "Pong!" @@ -50,10 +80,26 @@ class Bot(Client): self.start_time = datetime.datetime.now() def save_config(self) -> None: + """ + Save the current state of self.config to the file passed in __init__ as + the config_file parameter. + + Usually, this is the file that self.config was loaded from (if you use + run or run_modulebot). + """ + with open(self.config_file, "w") as f: self.config.write(f) async def started(self) -> None: + """ + This Client function is overwritten in order to join all the rooms + listed in the "rooms" section of self.config. + + If you need to overwrite this function but want to keep the auto-join + functionality, make sure to await super().started(). + """ + for room, password in self.config[self.ROOMS_SECTION].items(): if password is None: await self.join(room) @@ -63,6 +109,12 @@ class Bot(Client): # Registering commands def register(self, command: Command) -> None: + """ + Register a Command (from the yaboli.command submodule). + + Usually, you don't have to call this function yourself. + """ + self._commands.append(command) def register_general(self, @@ -70,6 +122,23 @@ class Bot(Client): cmdfunc: GeneralCommandFunction, args: bool = True ) -> None: + """ + Register a function as general bot command (i. e. no @mention of the + bot nick after the !command). This function will be called by + process_commands() when the bot encounters a matching command. + + name - the name of the command (If you want your command to be !hello, + the name is "hello".) + + cmdfunc - the function that is called with the Room, LiveMessage and + ArgumentData when the bot encounters a matching command + + args - whether the command may have arguments (If set to False, the + ArgumentData's has_args() function must also return False for the + command function to be called. If set to True, all ArgumentData is + valid.) + """ + command = GeneralCommand(name, cmdfunc, args) self.register(command) @@ -78,6 +147,21 @@ class Bot(Client): cmdfunc: SpecificCommandFunction, args: bool = True ) -> None: + """ + Register a function as specific bot command (i. e. @mention of the bot + nick after the !command is required). This function will be called by + process_commands() when the bot encounters a matching command. + + name - the name of the command (see register_general() for an + explanation) + + cmdfunc - the function that is called with the Room, LiveMessage and + SpecificArgumentData when the bot encounters a matching command + + args - whether the command may have arguments (see register_general() + for an explanation) + """ + command = SpecificCommand(name, cmdfunc, args) self.register(command) @@ -88,6 +172,13 @@ class Bot(Client): message: LiveMessage, aliases: List[str] = [] ) -> None: + """ + If the message contains a command, call all matching command functions + that were previously registered. + + This function is usually called by the overwritten on_send() function. + """ + nicks = [room.session.nick] + aliases data = CommandData.from_string(message.content) @@ -97,11 +188,31 @@ class Bot(Client): await command.run(room, message, nicks, data) async def on_send(self, room: Room, message: LiveMessage) -> None: + """ + This Client function is overwritten in order to automatically call + process_commands() with self.ALIASES. + + If you need to overwrite this function, make sure to await + process_commands() with self.ALIASES somewhere in your function, or + await super().on_send(). + """ + await self.process_commands(room, message, aliases=self.ALIASES) # Help util def format_help(self, room: Room, lines: List[str]) -> str: + """ + Format a list of strings into a string, replacing certain placeholders + with the actual values. + + This function uses the str.format() function to replace the following: + + - {nick} - the bot's current nick + - {mention} - the bot's current nick, run through mention() + - {atmention} - the bot's current nick, run through atmention() + """ + text = "\n".join(lines) params = { "nick": room.session.nick, @@ -119,6 +230,36 @@ class Bot(Client): kill: bool = False, restart: bool = False, ) -> None: + """ + Register the commands necessary for the bot to conform to the botrulez + (https://github.com/jedevc/botrulez). Also includes a few optional + botrulez commands that are disabled by default. + + - ping - register general and specific cmd_ping() + - help_ - register cmd_help_general() and cmd_help_specific() + - uptime - register specific cmd_uptime + - kill - register specific cmd_kill (disabled by default) + - uptime - register specific cmd_uptime (disabled by default) + + All commands are registered with args=False. + + If you want to implement your own versions of these commands, it is + recommended that you set the respective argument to False in your call + to register_botrulez(), overwrite the existing command functions or + create your own, and then register them manually. + + For help, that might look something like this, if you've written a + custom specific help that takes extra arguments but are using the + botrulez general help: + + self.register_botrulez(help_=False) + self.register_general("help", self.cmd_help_general, args=False) + self.register_specific("help", self.cmd_help_custom) + + In case you're asking, the help_ parameter has an underscore at the end + so it doesn't overlap the help() function. + """ + if ping: self.register_general("ping", self.cmd_ping, args=False) self.register_specific("ping", self.cmd_ping, args=False) @@ -144,6 +285,10 @@ class Bot(Client): message: LiveMessage, args: ArgumentData ) -> None: + """ + Reply with self.PING_REPLY. + """ + await message.reply(self.PING_REPLY) async def cmd_help_general(self, @@ -151,6 +296,10 @@ class Bot(Client): message: LiveMessage, args: ArgumentData ) -> None: + """ + Reply with self.HELP_GENERAL, if it is not None. Uses format_help(). + """ + if self.HELP_GENERAL is not None: await message.reply(self.format_help(room, [self.HELP_GENERAL])) @@ -159,6 +308,10 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: + """ + Reply with self.HELP_SPECIFIC, if it is not None. Uses format_help(). + """ + if self.HELP_SPECIFIC is not None: await message.reply(self.format_help(room, self.HELP_SPECIFIC)) @@ -167,6 +320,15 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: + """ + Reply with the bot's uptime in the format specified by the botrulez. + + This uses the time that the Bot was first started, not the time the + respective Room was created. A !restart (see register_botrulez()) will + reset the bot uptime, but leaving and re-joining a room or losing + connection won't. + """ + time = format_time(self.start_time) delta = format_delta(datetime.datetime.now() - self.start_time) text = f"/me has been up since {time} UTC ({delta})" @@ -177,6 +339,13 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: + """ + Remove the bot from this room. + + If self.KILL_REPLY is not None, replies with that before leaving the + room. + """ + logger.info(f"Killed in &{room.name} by {message.sender.atmention}") if self.KILL_REPLY is not None: @@ -189,6 +358,15 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: + """ + Restart the whole Bot. + + This is done by stopping the Bot, since the run() or run_modulebot() + functions start the Bot in a while True loop. + + If self.RESTART_REPLY is not None, replies with that before restarting. + """ + logger.info(f"Restarted in &{room.name} by {message.sender.atmention}") if self.RESTART_REPLY is not None: From 74a8adfa587c9de2193e54f7875b53c507f48edc Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 19 Apr 2019 11:09:08 +0000 Subject: [PATCH 135/145] Fix imports --- CHANGELOG.md | 1 + yaboli/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a927e16..4ad540e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - add docstrings to `Bot` - change `KILL_REPLY` and `RESTART_REPLY` to be optional in `Bot` +- fix imports ## 1.1.3 (2019-04-19) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 241bf0e..527eaeb 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -17,7 +17,7 @@ from .session import * from .util import * __all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging", - "run"] + "run", "run_modulebot"] __all__ += bot.__all__ __all__ += client.__all__ From 1c409601dbe309c2438f0061fdc59f9cffcfddee Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 20 Apr 2019 18:55:47 +0000 Subject: [PATCH 136/145] Update echobot to latest yaboli version --- CHANGELOG.md | 1 + examples/echo/bot.conf | 1 + examples/echo/echobot.py | 7 ++++--- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ad540e..a0e5c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - add docstrings to `Bot` - change `KILL_REPLY` and `RESTART_REPLY` to be optional in `Bot` +- fix echobot example - fix imports ## 1.1.3 (2019-04-19) diff --git a/examples/echo/bot.conf b/examples/echo/bot.conf index 8d48222..940e8e4 100644 --- a/examples/echo/bot.conf +++ b/examples/echo/bot.conf @@ -1,5 +1,6 @@ [general] nick = EchoBot +cookie_file = bot.cookie [rooms] test diff --git a/examples/echo/echobot.py b/examples/echo/echobot.py index 4804992..e404f3c 100644 --- a/examples/echo/echobot.py +++ b/examples/echo/echobot.py @@ -8,13 +8,14 @@ class EchoBot(yaboli.Bot): "!echo – reply with exactly ", ] - def __init__(self, config_file): - super().__init__(config_file) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.register_botrulez(kill=True) self.register_general("echo", self.cmd_echo) async def cmd_echo(self, room, message, args): - await message.reply(args.raw) + text = args.raw.strip() # ignoring leading and trailing whitespace + await message.reply(text) if __name__ == "__main__": From 7024686ff217986e495230d678bfd98c12b46898 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 20 Apr 2019 19:01:25 +0000 Subject: [PATCH 137/145] Update example gitignore to latest version --- CHANGELOG.md | 3 ++- examples/echo/.gitignore | 17 +++++++++++++++++ examples/echo/{bot.conf => bot.conf.default} | 0 examples/gitignore_with_venv | 8 ++++++-- 4 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 examples/echo/.gitignore rename examples/echo/{bot.conf => bot.conf.default} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e5c4b..ddb365e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ - add docstrings to `Bot` - change `KILL_REPLY` and `RESTART_REPLY` to be optional in `Bot` -- fix echobot example - fix imports +- update echobot example to newest version +- update example gitignore to newest version ## 1.1.3 (2019-04-19) diff --git a/examples/echo/.gitignore b/examples/echo/.gitignore new file mode 100644 index 0000000..f69b963 --- /dev/null +++ b/examples/echo/.gitignore @@ -0,0 +1,17 @@ +# python stuff +__pycache__/ + +# venv stuff +bin/ +include/ +lib/ +lib64 +pyvenv.cfg + +# bot stuff +# +# These files are ignored because they may contain sensitive information you +# wouldn't want in your repo. If you need to have a config file in your repo, +# store a bot.conf.default with default settings. +*.conf +*.cookie diff --git a/examples/echo/bot.conf b/examples/echo/bot.conf.default similarity index 100% rename from examples/echo/bot.conf rename to examples/echo/bot.conf.default diff --git a/examples/gitignore_with_venv b/examples/gitignore_with_venv index 191feb7..f69b963 100644 --- a/examples/gitignore_with_venv +++ b/examples/gitignore_with_venv @@ -8,6 +8,10 @@ lib/ lib64 pyvenv.cfg -# config files +# bot stuff +# +# These files are ignored because they may contain sensitive information you +# wouldn't want in your repo. If you need to have a config file in your repo, +# store a bot.conf.default with default settings. *.conf -cookie_jar +*.cookie From 2215e75c34629b76f178e6db5100315377b4ad1f Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 20 Apr 2019 19:26:46 +0000 Subject: [PATCH 138/145] Add config file to example --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index becb80c..9af43a9 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,17 @@ class EchoBot(yaboli.Bot): await message.reply(args.raw) ``` -The bot's nick, cookie file and default rooms are specified in a config file. +The bot's nick, cookie file and default rooms are specified in a config file, +like so: + +```ini +[general] +nick = EchoBot +cookie_file = bot.cookie + +[rooms] +test +``` The help command from the botrulez uses the `HELP_GENERAL` and `HELP_SPECIFIC` fields. @@ -52,6 +62,9 @@ In the `cmd_echo` function, the echo command is implemented. In this case, the bot replies to the message containing the command with the raw argument string, i. e. the text between the end of the "!echo" and the end of the whole message. +The full version of this echobot can be found [in the +examples](examples/echo/). + ## TODOs - [ ] document yaboli (markdown files in a "docs" folder?) From 66b56a450e7327ddea59d1692e4570b3889cdf75 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 21 Jun 2019 07:21:50 +0000 Subject: [PATCH 139/145] Fix room firing incorrect event --- CHANGELOG.md | 1 + yaboli/room.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb365e..26c1d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - add docstrings to `Bot` - change `KILL_REPLY` and `RESTART_REPLY` to be optional in `Bot` - fix imports +- fix room firing incorrect event - update echobot example to newest version - update example gitignore to newest version diff --git a/yaboli/room.py b/yaboli/room.py index 5ea5e03..d1304ee 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -180,10 +180,10 @@ class Room: if nick is not None and self._session is not None: self._session = self.session.with_nick(nick) - # Send "session" event + # Send "snapshot" event messages = [LiveMessage.from_data(self, msg_data) for msg_data in data["log"]] - self._events.fire("session", messages) + self._events.fire("snapshot", messages) self._snapshot_received = True await self._try_set_connected() From 1b9860ba1ef06c0e5e1e758008e2cbf14b359b70 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 21 Jun 2019 07:23:49 +0000 Subject: [PATCH 140/145] Bump version to 1.1.4 --- CHANGELOG.md | 4 ++++ README.md | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c1d86..46bbd89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Next version +Nothing yet. + +## 1.1.4 (2019-06-21) + - add docstrings to `Bot` - change `KILL_REPLY` and `RESTART_REPLY` to be optional in `Bot` - fix imports diff --git a/README.md b/README.md index 9af43a9..af5aef5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Ensure that you have at least Python 3.7 installed. To install yaboli or update your installation to the latest version, run: ``` -$ pip install git+https://github.com/Garmelon/yaboli@v1.1.3 +$ pip install git+https://github.com/Garmelon/yaboli@v1.1.4 ``` The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. diff --git a/setup.py b/setup.py index 4ddafc0..56127a0 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name="yaboli", - version="1.1.3", + version="1.1.4", packages=["yaboli"], install_requires=["websockets==7.0"], ) From 455d2af251a595915c4053b01e1d199f1c2623c8 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 30 Nov 2019 16:30:52 +0000 Subject: [PATCH 141/145] Use IOError to catch more exceptions --- yaboli/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yaboli/connection.py b/yaboli/connection.py index 8af43c3..fcc27fe 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -207,7 +207,7 @@ class Connection: return True except (websockets.InvalidHandshake, websockets.InvalidStatusCode, - socket.gaierror, asyncio.TimeoutError): + OSError, asyncio.TimeoutError): logger.debug("Connection failed") return False From 1d25b596bbd84e95314d637787c209f248a7e505 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 26 Jan 2020 22:50:20 +0000 Subject: [PATCH 142/145] Bump version to 1.1.5 --- CHANGELOG.md | 4 +++- README.md | 2 +- setup.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46bbd89..8dd46b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## Next version -Nothing yet. +## 1.1.5 (2020-01-26) + +- more stability (I think) ## 1.1.4 (2019-06-21) diff --git a/README.md b/README.md index af5aef5..b02a6a8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Ensure that you have at least Python 3.7 installed. To install yaboli or update your installation to the latest version, run: ``` -$ pip install git+https://github.com/Garmelon/yaboli@v1.1.4 +$ pip install git+https://github.com/Garmelon/yaboli@v1.1.5 ``` The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. diff --git a/setup.py b/setup.py index 56127a0..ebbea69 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name="yaboli", - version="1.1.4", + version="1.1.5", packages=["yaboli"], install_requires=["websockets==7.0"], ) From 74caea4e922a249deec2ae33b89a7c3471cd693b Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 21 Aug 2022 14:09:02 +0200 Subject: [PATCH 143/145] Update websockets dependency --- CHANGELOG.md | 2 ++ setup.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd46b4..60cfd94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +- update websockets dependency + ## 1.1.5 (2020-01-26) - more stability (I think) diff --git a/setup.py b/setup.py index ebbea69..8de8af1 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name="yaboli", version="1.1.5", packages=["yaboli"], - install_requires=["websockets==7.0"], + install_requires=["websockets >=10.3, <11"], ) # When updating the version, also: From 37c4ba703a89d44d89185ac37888b0060e5637d3 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 21 Aug 2022 14:24:05 +0200 Subject: [PATCH 144/145] Switch to pyproject.toml style setuptools config --- .gitignore | 14 +++----------- CHANGELOG.md | 1 + examples/echo/.gitignore | 12 ------------ setup.py => pyproject.toml | 16 +++++++++------- 4 files changed, 13 insertions(+), 30 deletions(-) rename setup.py => pyproject.toml (78%) diff --git a/.gitignore b/.gitignore index 1d164cd..7ce48d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,4 @@ -# python stuff __pycache__/ - -# venv stuff -bin/ -include/ -lib/ -lib64 -pyvenv.cfg - -# mypy stuff -.mypy_cache/ +*.egg-info/ +/.mypy_cache/ +/.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 60cfd94..8df3702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Next version - update websockets dependency +- switch to pyproject.toml style setuptools config ## 1.1.5 (2020-01-26) diff --git a/examples/echo/.gitignore b/examples/echo/.gitignore index f69b963..da78a19 100644 --- a/examples/echo/.gitignore +++ b/examples/echo/.gitignore @@ -1,15 +1,3 @@ -# python stuff -__pycache__/ - -# venv stuff -bin/ -include/ -lib/ -lib64 -pyvenv.cfg - -# bot stuff -# # These files are ignored because they may contain sensitive information you # wouldn't want in your repo. If you need to have a config file in your repo, # store a bot.conf.default with default settings. diff --git a/setup.py b/pyproject.toml similarity index 78% rename from setup.py rename to pyproject.toml index 8de8af1..dbb7c7e 100644 --- a/setup.py +++ b/pyproject.toml @@ -1,11 +1,13 @@ -from setuptools import setup +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" -setup( - name="yaboli", - version="1.1.5", - packages=["yaboli"], - install_requires=["websockets >=10.3, <11"], -) +[project] +name = "yaboli" +version = "1.1.5" +dependencies = [ + "websockets >=10.3, <11" +] # When updating the version, also: # - update the README.md installation instructions From eba398e5d31ff9da05befb1e98459843b8513023 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 21 Aug 2022 14:26:04 +0200 Subject: [PATCH 145/145] Bump version to 1.2.0 --- CHANGELOG.md | 2 ++ README.md | 2 +- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df3702..e0f1801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next version +## 1.2.0 (2022-08-21) + - update websockets dependency - switch to pyproject.toml style setuptools config diff --git a/README.md b/README.md index b02a6a8..2cd4eb1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Ensure that you have at least Python 3.7 installed. To install yaboli or update your installation to the latest version, run: ``` -$ pip install git+https://github.com/Garmelon/yaboli@v1.1.5 +$ pip install git+https://github.com/Garmelon/yaboli@v1.2.0 ``` The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. diff --git a/pyproject.toml b/pyproject.toml index dbb7c7e..79ad530 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "yaboli" -version = "1.1.5" +version = "1.2.0" dependencies = [ "websockets >=10.3, <11" ]