diff --git a/TestBot.py b/TestBot.py new file mode 100644 index 0000000..be41127 --- /dev/null +++ b/TestBot.py @@ -0,0 +1,48 @@ +import yaboli +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) + + #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 m in messages: + for j in range(2): + newm = await self.room.send(f"{message.content}.{j}", m.message_id) + newmessages.append(newm) + messages = newmessages + newmessages = [] + +if __name__ == "__main__": + bot = TestBot("TestSummoner") + run_controller(bot, "test") diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 8aed845..5e86afe 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,10 +1,14 @@ -from .bot import Bot -from .botmanager import BotManager -from .callbacks import Callbacks -from .connection import Connection -from .exceptions import * -from .session import Session -from .message import Message -from .sessions import Sessions -from .messages import Messages -from .room import Room +import logging +#logging.basicConfig(level=logging.DEBUG) +logging.basicConfig(level=logging.INFO) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +from .bot import * +from .connection import * +from .controller import * +from .room import * +from .utils import * + +__all__ = connection.__all__ + room.__all__ + controller.__all__ + utils.__all__ diff --git a/yaboli/bot.py b/yaboli/bot.py index 68ee2f8..a274d77 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,600 +1,176 @@ +import asyncio +import logging +import re import time +from .callbacks import * +from .controller import * +from .utils import * -from . import callbacks -from . import exceptions -from . import room +logger = logging.getLogger(__name__) +__all__ = ["Bot"] -class Bot(): - """ - Empty bot class that can be built upon. - Takes care of extended botrulez. - """ + + +class Bot(Controller): + # ^ and $ not needed since we're doing a re.fullmatch + SPECIFIC_RE = r"!(\S+)\s+@(\S+)([\S\s]*)" + GENERIC_RE = r"!(\S+)([\S\s]*)" - def __init__(self, roomname, nick="yaboli", password=None, manager=None, - created_in=None, created_by=None): - """ - roomname - name of the room to connect to - nick - nick to assume, None -> no nick - password - room password (in case the room is private) - created_in - room the bot was created in - created_by - nick of the person the bot was created by - """ + def __init__(self, nick): + super().__init__(nick) self.start_time = time.time() - self.created_by = created_by - self.created_in = created_in + self._callbacks = Callbacks() + self.register_default_callbacks() - self.manager = manager - - # modify/customize this in your __init__() function (or anywhere else you want, for that matter) - self.bot_description = ("This bot complies with the botrulez™ (https://github.com/jedevc/botrulez),\n" - "plus a few extra commands.") - - self.helptexts = {} - self.detailed_helptexts = {} - - self.room = room.Room(roomname, nick=nick, password=password) - self.room.add_callback("message", self.on_message) - - self.commands = callbacks.Callbacks() - self.bot_specific_commands = [] - - self.add_command("clone", self.clone_command, "Clone this bot to another room.", # possibly add option to set nick? - ("!clone @bot [ [ --pw= ] ]\n" - " : the name of the room to clone the bot to\n" - "--pw : the room's password\n\n" - "Clone this bot to the room specified.\n" - "If the target room is passworded, you can use the --pw option to set\n" - "a password for the bot to use.\n" - "If no room is specified, this will use the current room and password."), - bot_specific=False) - - self.add_command("help", self.help_command, "Show help information about the bot.", - ("!help @bot [ -s | -c | ]\n" - "-s : general syntax help\n" - "-c : only list the commands\n" - " : any command from !help @bot -c\n\n" - "Shows detailed help for a command if you specify a command name.\n" - "Shows a list of commands and short description if no arguments are given.")) - - self.add_command("kill", self.kill_command, "Kill (stop) the bot.", - ("!kill @bot [ -r ]\n" - "-r : restart the bot (will change the id)\n\n" - "The bot disconnects from the room and stops.")) - - self.add_command("ping", self.ping_command, "Replies 'Pong!'.", - ("!ping @bot\n\n" - "This command was originally used to help distinguish bots from\n" - "people. Since the Great UI Change, this is no longer necessary as\n" - "bots and people are displayed separately.")) - - self.add_command("restart", self.restart_command, "Restart the bot (shorthand for !kill @bot -r).", - ("!restart @bot\n\n" - "Restart the bot.\n" - "Short for !kill @bot -r")) - - self.add_command("send", self.send_command, "Send the bot to another room.", - ("!send @bot [ --pw= ]\n" - "--pw : the room's password\n\n" - "Sends this bot to the room specified. If the target room is passworded,\n" - "you can use the --pw option to set a password for the bot to use.")) - - self.add_command("uptime", self.uptime_command, "Show bot uptime since last (re-)start.", - ("!uptime @bot [ -i s]\n" - "-i : show more detailed information\n\n" - "Shows the bot's uptime since the last start or restart.\n" - "Shows additional information (i.e. id) if the -i flag is set.")) - - - self.add_command("show", self.show_command, detailed_helptext="You've found a hidden command! :)") - - self.room.launch() + # settings (modify in your bot's __init__) + self.general_help = None # None -> does not respond to general help + self.killable = True + self.kill_message = "/me *poof*" # how to respond to !kill, whether killable or not - def stop(self): - """ - stop() -> None - - Kill this bot. - """ - - self.room.stop() + def register_callback(self, event, callback, specific=True): + self._callbacks.add((event, specific), callback) - def add_command(self, command, function, helptext=None, detailed_helptext=None, - bot_specific=True): - """ - add_command(command, function, helptext, detailed_helptext, bot_specific) -> None + async def on_send(self, message): + parsed = self.parse_message(message.content) + if not parsed: + return + command, args = parsed - Subscribe a function to a command and add a help text. - If no help text is provided, the command won't be displayed by the !help command. - This overwrites any previously added command. + # general callback (specific set to False) + general = asyncio.ensure_future( + self._callbacks.call((command, False), message, args) + ) - You can "hide" commands by specifying only the detailed helptext, - or no helptext at all. + if len(args) > 0: + mention = args[0] + args = args[1:] + if mention[:1] == "@" and similar(mention[1:], self.nick): + # specific callback (specific set to True) + await self._callbacks.call((command, True), message, args) - If the command is not bot specific, no id has to be specified if there are multiple bots - with the same nick in a room. - """ - - command = command.lower() - - self.commands.remove(command) - self.commands.add(command, function) - - if helptext and not command in self.helptexts: - self.helptexts[command] = helptext - elif not helptext and command in self.helptexts: - self.helptexts.pop(command) - - if detailed_helptext and not command in self.detailed_helptexts: - self.detailed_helptexts[command] = detailed_helptext - elif not detailed_helptext and command in self.detailed_helptexts: - self.detailed_helptexts.pop(command) - - if bot_specific and not command in self.bot_specific_commands: - self.bot_specific_commands.append(command) - elif not bot_specific and command in self.bot_specific_commands: - self.bot_specific_commands.remove(command) + await general - def call_command(self, message): + def parse_message(self, content): """ - call_command(message) -> None + (command, args) = parse_message(content) - Calls all functions subscribed to the command with the arguments supplied in the message. - Deals with the situation that multiple bots of the same type and nick are in the same room. + Returns None, not a (None, None) tuple, when message could not be parsed """ - try: - command, bot_id, nick, arguments, flags, options = self.parse(message.content) - except exceptions.ParseMessageException: - return - else: - command = command.lower() - nick = self.room.mentionable(nick).lower() + match = re.fullmatch(self.GENERIC_RE, content) + if not match: + return None - if not self.commands.exists(command): - return + command = match.group(1) + argstr = match.group(2) + args = self.parse_args(argstr) - if not nick == self.mentionable().lower(): - return + return command, args + + def parse_args(self, text): + """ + Use single- and double-quotes bash-style to include whitespace in arguments. + A backslash always escapes the next character. + Any non-escaped whitespace separates arguments. - if bot_id is not None: # id specified - if self.manager.get(bot_id) == self: - self.commands.call(command, message, arguments, flags, options) + 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: - return - - else: # no id specified - bots = self.manager.get_similar(self.roomname(), nick) - if self.manager.get_id(self) == min(bots): # only one bot should act - # either the bot is unique or the command is not bot-specific - if not command in self.bot_specific_commands or len(bots) == 1: - self.commands.call(command, message, arguments, flags, options) + arg += character - else: # user has to select a bot - msg = ("There are multiple bots with that nick in this room. To select one,\n" - "please specify its id (from the list below) as follows:\n" - "!{} @{} [your arguments...]\n").format(command, nick) - - for bot_id in sorted(bots): - bot = bots[bot_id] - msg += "\n{} - @{} ({})".format(bot_id, bot.mentionable(), bot.creation_info()) - - self.room.send_message(msg, parent=message.id) - - def roomname(self): - """ - roomname() -> roomname + #if escape or quote: + #return None # syntax error - The room the bot is connected to. - """ - - return self.room.room - - def password(self): - """ - password() -> password - - The current room's password. - """ - - return self.room.password - - def nick(self): - """ - nick() -> nick - - The bot's full nick. - """ - - return self.room.nick - - def mentionable(self): - """ - mentionable() -> nick - - The bot's nick in a mentionable format. - """ - - return self.room.mentionable() - - def creation_info(self): - """ - creation_info() -> str - - Formatted info about the bot's creation - """ - - info = "created {}".format(self.format_date()) - - if self.created_by: - info += " by @{}".format(self.room.mentionable(self.created_by)) - - if self.created_in: - info += " in &{}".format(self.created_in) - - return info - - def format_date(self, seconds=None): - """ - format_date(seconds) -> str - - Format a time in epoch format to the format specified in self.date_format. - Defaults to self.start_time. - """ - - if seconds is None: - seconds = self.start_time - - return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(seconds)) - - def format_delta(self, delta=None): - """ - format_delta(delta) -> str - - Format a difference in seconds to the following format: - [- ][d ][h ][m ]s - Defaults to the current uptime if no delta is specified. - """ - - if not delta: - delta = time.time() - self.start_time - - delta = int(delta) - uptime = "" - - if delta < 0: - uptime += "- " - delta = -delta - - if delta >= 24*60*60: - uptime +="{}d ".format(delta//(24*60*60)) - delta %= 24*60*60 + if len(arg) > 0: + args.append(arg) - if delta >= 60*60: - uptime += "{}h ".format(delta//(60*60)) - delta %= 60*60 - - if delta >= 60: - uptime += "{}m ".format(delta//60) - delta %= 60 - - uptime += "{}s".format(delta) - - return uptime + return args - def parse_command(self, message): - """ - parse_command(message_content) -> command, bot_id, nick, argpart - - Parse the "!command[ bot_id] @botname[ argpart]" part of a command. - """ - - # command name (!command) - split = message.split(maxsplit=1) - - if len(split) < 2: - raise exceptions.ParseMessageException("Not enough arguments") - elif split[0][:1] != "!": - raise exceptions.ParseMessageException("Not a command") - - command = split[0][1:] - message = split[1] - split = message.split(maxsplit=1) - - # bot id - try: - bot_id = int(split[0]) - except ValueError: - bot_id = None - else: - if len(split) <= 1: - raise exceptions.ParseMessageException("No bot nick") - - message = split[1] - split = message.split(maxsplit=1) - - # bot nick (@mention) - if split[0][:1] != "@": - raise exceptions.ParseMessageException("No bot nick") - - nick = split[0][1:] - - # arguments to the command - if len(split) > 1: - argpart = split[1] - else: - argpart = None - - return command, bot_id, nick, argpart - - def parse_arguments(self, argstr): - """ - parse_arguments(argstr) -> arguments, flags, options - - Parse the argument part of a command. - """ - - argstr += " " # so the last argument will also be captured - - escaping = False - quot_marks = None - type_signs = 0 - option = None - word = "" - - arguments = [] + def parse_flags(self, arglist): flags = "" - options = {} + args = [] + kwargs = {} - for char in argstr: - - # backslash-escaping - if escaping: - word += char - escaping = False - elif char == "\\": - escaping = True - - # quotation mark escaped strings - elif quot_marks: - if char == quot_marks: - quot_marks = None + 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: - word += char - elif char in ['"', "'"]: - quot_marks = char - - # type signs - elif char == "-": - if type_signs < 2 and not word: - type_signs += 1 - else: - word += char - - # "=" in options - elif char == "=" and type_signs == 2 and word and not option: - option = word - word = "" - - # space - evaluate information collected so far - elif char == " ": - if word: - if type_signs == 0: # argument - arguments.append(word) - elif type_signs == 1: # flag(s) - for flag in word: - if not flag in flags: - flags += flag - elif type_signs == 2: # option - if option: - options[option] = word - else: - options[word] = True - - # reset all state variables - escaping = False - quot_marks = None - type_signs = 0 - option = None - word = "" - - # all other chars and situations + kwargs[arg] = None + # flags (-x, -rw) + elif arg[:1] == "-": + arg = arg[1:] + flags += arg + # args (normal arguments) else: - word += char + args.append(arg) - return arguments, flags, options + return flags, args, kwargs - def parse(self, message): - """ - parse(message_content) -> bool - - Parse a message. - """ - - command, bot_id, nick, argpart = self.parse_command(message) - - if argpart: - arguments, flags, options = self.parse_arguments(argpart) - else: - arguments = [] - flags = "" - options = {} - - return command, bot_id, nick, arguments, flags, options - # ----- HANDLING OF EVENTS ----- - def on_message(self, message): - """ - on_message(message) -> None - - Gets called when a message is received (see __init__). - If you want to add a command to your bot, consider using add_command instead of overwriting - this function. - """ - - self.call_command(message) + # BOTRULEZ COMMANDS - # ----- 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) + # TODO: maybe !restart command - def clone_command(self, message, arguments, flags, options): - """ - clone_command(message, *arguments, flags, options) -> None - - Create a new bot. - """ - - if not arguments: - room = self.roomname() - password = self.room.password - else: - room = arguments[0] - - if room[:1] == "&": - room = room[1:] - - if "pw" in options and options["pw"] is not True: - password = options["pw"] - else: - password = None - - try: - bot = self.manager.create(room, password=password, created_in=self.roomname(), - created_by=message.sender.name) - except exceptions.CreateBotException: - self.room.send_message("Bot could not be cloned.", parent=message.id) - else: - self.room.send_message("Cloned @{} to &{}.".format(bot.mentionable(), room), - parent=message.id) + async def command_ping(self, message, args): + await self.room.send("Pong!", message.message_id) - def help_command(self, message, arguments, flags, options): - """ - help_command(message, *arguments, flags, options) -> None - - Show help about the bot. - """ - - if arguments: # detailed help for one command - command = arguments[0] - if command[:1] == "!": - command = command[1:] - - if command in self.detailed_helptexts: - msg = "Detailed help for !{}:\n".format(command) - msg += self.detailed_helptexts[command] - else: - msg = "No detailed help text found for !{}.".format(command) - if command in self.helptexts: - msg += "\n\n" + self.helptexts[command] - - elif "s" in flags: # detailed syntax help - msg = ("SYNTAX HELP PLACEHOLDER") - - else: # just list all commands - msg = "" - - if not "c" in flags: - msg += self.bot_description + "\n\n" - - msg += "This bot supports the following commands:" - for command in sorted(self.helptexts): - helptext = self.helptexts[command] - msg += "\n!{} - {}".format(command, helptext) - - if not "c" in flags: - msg += ("\n\nFor help on the command syntax, try: !help @{0} -s\n" - "For detailed help on a command, try: !help @{0} \n" - "(Hint: Most commands have extra functionality, which is listed in their detailed help.)") - msg = msg.format(self.mentionable()) - - self.room.send_message(msg, parent=message.id) + async def command_help(self, message, args): + await self.room.send("", message.message_id) - def kill_command(self, message, arguments, flags, options): - """ - kill_command(message, *arguments, flags, options) -> None - - stop the bot. - """ - - if "r" in flags: - bot = self.manager.create(self.roomname()) - bot.created_by = self.created_by - bot.created_in = self.created_in - - self.room.send_message("/me exits.", message.id) - - self.manager.remove(self.manager.get_id(self)) + async def command_help_general(self, message, args): + if self.general_help is not None: + await self.room.send(self.general_help, message.message_id) - def ping_command(self, message, arguments, flags, options): - """ - ping_command(message, *arguments, flags, options) -> None - - Send a "Pong!" reply on a !ping command. - """ - - self.room.send_message("Pong!", parent=message.id) + async def command_uptime(self, message, args): + now = time.time() + startformat = format_time(self.start_time) + deltaformat = format_time_delta(now - self.start_time) + text = f"/me has been up since {startformat} ({deltaformat})" + await self.room.send(text, message.message_id) - def restart_command(self, message, arguments, flags, options): - """ - restart_command(message, *arguments, flags, options) -> None + async def command_kill(self, message, args): + logging.warn(f"Kill attempt in &{self.room.roomname}: {message.content!r}") - Restart the bot (shorthand for !kill @bot -r). - """ + if self.kill_message is not None: + await self.room.send(self.kill_message, message.message_id) - self.commands.call("kill", message, [], "r", {}) - - def send_command(self, message, arguments, flags, options): - """ - _command(message, *arguments, flags, options) -> None - - Send this bot to another room. - """ - - if not arguments: - return - else: - room = arguments[0] - - if room[:1] == "&": - room = room[1:] - - if "pw" in options and options["pw"] is not True: - password = options["pw"] - else: - password = None - - self.room.send_message("/me moves to &{}.".format(room), parent=message.id) - - self.room.change(room, password=password) - self.room.launch() - - def show_command(self, message, arguments, flags, options): - """ - show_command(message, arguments, flags, options) -> None - - Show arguments, flags and options. - """ - - msg = "arguments: {}\nflags: {}\noptions: {}".format(arguments, repr(flags), options) - self.room.send_message(msg, parent=message.id) - - def uptime_command(self, message, arguments, flags, options): - """ - uptime_command(message, arguments, flags, options) -> None - - Show uptime and other info. - """ - - stime = self.format_date() - utime = self.format_delta() - - if "i" in flags: - msg = "uptime: {} ({})".format(stime, utime) - msg += "\nid: {}".format(self.manager.get_id(self)) - msg += "\n{}".format(self.creation_info()) - - else: - msg = "/me is up since {} ({}).".format(stime, utime) - - self.room.send_message(msg, message.id) + if self.killable: + await self.stop() diff --git a/yaboli/botmanager.py b/yaboli/botmanager.py deleted file mode 100644 index 1a91b91..0000000 --- a/yaboli/botmanager.py +++ /dev/null @@ -1,149 +0,0 @@ -import json - -from . import bot -from . import exceptions - -class BotManager(): - """ - Keep track of multiple bots in different rooms. - """ - - def __init__(self, bot_class, default_nick="yaboli", max_bots=100, - bots_file="bots.json", data_file="data.json"): - """ - bot_class - class to create instances of - default_nick - default nick for all bots to assume when no nick is specified - max_bots - maximum number of bots allowed to exist simultaneously - None or 0 - no limit - bots_file - file the bot backups are saved to - None - no bot backups - data_file - file the bot data is saved to - - None - bot data isn't saved - """ - - self.bot_class = bot_class - self.max_bots = max_bots - self.default_nick = default_nick - - self.bots_file = bots_file - self.data_file = data_file - - self._bots = {} - self._bot_id = 0 - self._bot_data = {} - - self._load_bots() - - def create(self, room, password=None, nick=None, created_in=None, created_by=None): - """ - create(room, password, nick) -> bot - - Create a new bot in room. - """ - - if nick is None: - nick = self.default_nick - - if self.max_bots and len(self._bots) >= self.max_bots: - raise exceptions.CreateBotException("max_bots limit hit") - else: - bot = self.bot_class(room, nick=nick, password=password, manager=self, - created_in=created_in, created_by=created_by) - self._bots[self._bot_id] = bot - self._bot_id += 1 - - self._save_bots() - - return bot - - def remove(self, bot_id): - """ - remove(bot_id) -> None - - Kill a bot and remove it from the list of bots. - """ - - if bot_id in self._bots: - self._bots[bot_id].stop() - self._bots.pop(bot_id) - - self._save_bots() - - def get(self, bot_id): - """ - get(self, bot_id) -> bot - - Return bot with that id, if found. - """ - - if bot_id in self._bots: - return self._bots[bot_id] - - def get_id(self, bot): - """ - get_id(bot) -> bot_id - - Return the bot id, if the bot is known. - """ - - for bot_id, own_bot in self._bots.items(): - if bot == own_bot: - return bot_id - - def get_similar(self, room, nick): - """ - get_by_room(room, nick) -> dict - - Collect all bots that are connected to the room and have that nick. - """ - - return {bot_id: bot for bot_id, bot in self._bots.items() - if bot.roomname() == room and bot.mentionable().lower() == nick.lower()} - - def _load_bots(self): - """ - _load_bots() -> None - - Load and create bots from self.bots_file. - """ - - if not self.bots_file: - return - - try: - with open(self.bots_file) as f: - bots = json.load(f) - except FileNotFoundError: - pass - else: - for bot_info in bots: - bot = self.create(bot_info["room"], password=bot_info["password"], - nick=bot_info["nick"]) - bot.created_in = bot_info["created_in"] - bot.created_by = bot_info["created_by"] - - def _save_bots(self): - """ - _save_bots() -> None - - Save all current bots to self.bots_file. - """ - - if not self.bots_file: - return - - bots = [] - - for bot_id, bot in self._bots.items(): - bot_info = {} - - bot_info["room"] = bot.roomname() - bot_info["password"] = bot.password() - bot_info["nick"] = bot.nick() - bot_info["created_in"] = bot.created_in - bot_info["created_by"] = bot.created_by - - bots.append(bot_info) - - with open(self.bots_file, "w") as f: - json.dump(bots, f) diff --git a/yaboli/callbacks.py b/yaboli/callbacks.py index 04c36b8..71902d5 100644 --- a/yaboli/callbacks.py +++ b/yaboli/callbacks.py @@ -1,30 +1,27 @@ +import asyncio + +__all__ = ["Callbacks"] + + + class Callbacks(): """ - Manage callbacks + Manage callbacks asynchronously """ def __init__(self): self._callbacks = {} - def add(self, event, callback, *args, **kwargs): + def add(self, event, callback): """ - add(event, callback, *args, **kwargs) -> None + add(event, callback) -> None Add a function to be called on event. - The function will be called with *args and **kwargs. - Certain arguments might be added, depending on the event. """ if not event in self._callbacks: self._callbacks[event] = [] - - callback_info = { - "callback": callback, - "args": args, - "kwargs": kwargs - } - - self._callbacks[event].append(callback_info) + self._callbacks[event].append(callback) def remove(self, event): """ @@ -36,21 +33,18 @@ class Callbacks(): if event in self._callbacks: del self._callbacks[event] - def call(self, event, *args): + async def call(self, event, *args, **kwargs): """ - call(event) -> None + await call(event) -> None - Call all callbacks subscribed to the event with *args and the arguments specified when the - callback was added. + Call all callbacks subscribed to the event with *args and **kwargs". """ - if event in self._callbacks: - for c_info in self._callbacks[event]: - c = c_info["callback"] - args = c_info["args"] + args - kwargs = c_info["kwargs"] - - c(*args, **kwargs) + tasks = [asyncio.ensure_future(callback(*args, **kwargs)) + for callback in self._callbacks.get(event, [])] + + for task in tasks: + await task def exists(self, event): """ diff --git a/yaboli/connection.py b/yaboli/connection.py index 57731b8..b0a108d 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -1,229 +1,158 @@ +import logging +logger = logging.getLogger(__name__) + +import asyncio +asyncio.get_event_loop().set_debug(True) + import json -import time -import threading -import websocket -from websocket import WebSocketException as WSException +import websockets +#from websockets import ConnectionClosed -from . import callbacks +__all__ = ["Connection"] -class Connection(): - """ - Stays connected to a room in its own thread. - Callback functions are called when a packet is received. - - Callbacks: - - all the message types from api.euphoria.io - These pass the packet data as argument to the called functions. - The other callbacks don't pass any special arguments. - - "connect" - - "disconnect" - - "stop" - """ - - ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" - - def __init__(self, room, url_format=None): - """ - room - name of the room to connect to - - """ - - self.room = room - - if not url_format: - url_format = self.ROOM_FORMAT - self._url = url_format.format(self.room) - - self._stopping = False + + +class Connection: + def __init__(self, url, packet_hook, cookie=None): + self.url = url + self.cookie = cookie + self.packet_hook = packet_hook self._ws = None - self._thread = None - self._send_id = 0 - self._callbacks = callbacks.Callbacks() - self._id_callbacks = callbacks.Callbacks() + self._pid = 0 # successive packet ids + self._spawned_tasks = set() + self._pending_responses = {} + #self._stopping = False + self._runtask = None - def _connect(self, tries=-1, delay=10): + async def connect(self, max_tries=10, delay=60): """ - _connect(tries, delay) -> bool + success = await connect(max_tries=10, delay=60) - tries - maximum number of retries - -1 -> retry indefinitely - - Returns True on success, False on failure. - - Connect to the room. + Attempt to connect to a room. + Returns the task listening for packets, or None if the attempt failed. """ - while tries != 0: + logger.debug(f"Attempting to connect, max_tries={max_tries}") + + await self.stop() + + tries_left = max_tries + while tries_left > 0: + tries_left -= 1 try: - self._ws = websocket.create_connection( - self._url, - enable_multithread=True - ) - - self._callbacks.call("connect") - - return True - except WSException: - if tries > 0: - tries -= 1 - if tries != 0: - time.sleep(delay) - return False + self._ws = await websockets.connect(self.url, max_size=None) + except (websockets.InvalidURI, websockets.InvalidHandshake): + self._ws = None + if tries_left > 0: + await asyncio.sleep(delay) + else: + self._runtask = asyncio.ensure_future(self._run()) + return self._runtask - def disconnect(self): + async def _run(self): """ - disconnect() -> None - - Reconnect to the room. - WARNING: To completely disconnect, use stop(). + Listen for packets and deal with them accordingly. """ - if self._ws: - self._ws.close() + try: + while True: + await self._handle_next_message() + except websockets.ConnectionClosed: + pass + finally: + self._clean_up_futures() + self._clean_up_tasks() + + await self._ws.close() # just to make sure self._ws = None - - self._callbacks.call("disconnect") - def launch(self): + async def stop(self): """ - launch() -> Thread - - Connect to the room and spawn a new thread running run. - """ - - if self._connect(tries=1): - self._thread = threading.Thread(target=self._run, - name="{}-{}".format(self.room, int(time.time()))) - self._thread.start() - return self._thread - else: - self.stop() - - def _run(self): - """ - _run() -> None - - Receive messages. - """ - - while not self._stopping: - try: - self._handle_json(self._ws.recv()) - except (WSException, ConnectionResetError): - if not self._stopping: - self.disconnect() - self._connect() - - def stop(self): - """ - stop() -> None - - Close the connection to the room. - Joins the thread launched by self.launch(). - """ - - self._stopping = True - self.disconnect() - - self._callbacks.call("stop") - - if self._thread and self._thread != threading.current_thread(): - self._thread.join() - - def next_id(self): - """ - next_id() -> id - - Returns the id that will be used for the next package. - """ - - return str(self._send_id) - - def add_callback(self, ptype, callback, *args, **kwargs): - """ - add_callback(ptype, callback, *args, **kwargs) -> None - - Add a function to be called when a packet of type ptype is received. - """ - - self._callbacks.add(ptype, callback, *args, **kwargs) - - def add_id_callback(self, pid, callback, *args, **kwargs): - """ - add_id_callback(pid, callback, *args, **kwargs) -> None - - Add a function to be called when a packet with id pid is received. - """ - - self._id_callbacks.add(pid, callback, *args, **kwargs) - - def add_next_callback(self, callback, *args, **kwargs): - """ - add_next_callback(callback, *args, **kwargs) -> None - - Add a function to be called when the answer to the next message sent is received. - """ - - self._id_callbacks.add(self.next_id(), callback, *args, **kwargs) - - def _handle_json(self, data): - """ - handle_json(data) -> None - - Handle incoming 'raw' data. - """ - - packet = json.loads(data) - self._handle_packet(packet) - - def _handle_packet(self, packet): - """ - _handle_packet(ptype, data) -> None - - Handle incoming packets - """ - - if "data" in packet: - data = packet["data"] - else: - data = None - - if "error" in packet: - error = packet["error"] - else: - error = None - - self._callbacks.call(packet["type"], data, error) - - if "id" in packet: - self._id_callbacks.call(packet["id"], data, error) - self._id_callbacks.remove(packet["id"]) - - def _send_json(self, data): - """ - _send_json(data) -> None - - Send 'raw' json. + Close websocket connection and wait for running task to stop. """ if self._ws: - try: - self._ws.send(json.dumps(data)) - except WSException: - self.disconnect() + await self._ws.close() + + if self._runtask: + await self._runtask - def send_packet(self, ptype, **kwargs): - """ - send_packet(ptype, **kwargs) -> None - - Send a formatted packet. - """ + async def send(self, ptype, data=None, await_response=True): + if not self._ws: + raise asyncio.CancelledError + pid = str(self._new_pid()) packet = { "type": ptype, - "data": kwargs or None, - "id": str(self._send_id) + "id": pid } - self._send_id += 1 - self._send_json(packet) + 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() + + 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) + + # Pass packet onto room + await self.packet_hook(packet) + + def _track_task(self, task): + self._spawned_tasks.add(task) + + # only keep running tasks + #tasks = set() + #for task in self._spawned_tasks: + #if not task.done(): + #logger.debug(f"Keeping task: {task}") + #tasks.add(task) + #else: + #logger.debug(f"Deleting task: {task}") + #self._spawned_tasks = tasks + self._spawned_tasks = {task for task in self._spawned_tasks if not task.done()} # TODO: Reenable + + def _wait_for_response(self, pid): + future = asyncio.Future() + self._pending_responses[pid] = future + + return future diff --git a/yaboli/controller.py b/yaboli/controller.py new file mode 100644 index 0000000..062c7a0 --- /dev/null +++ b/yaboli/controller.py @@ -0,0 +1,205 @@ +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): + """ + roomname - name of room to connect to + human - whether the human flag should be set on connections + cookie - cookie to use in HTTP request, if any + """ + self.nick = nick + self.human = human + self.cookie = cookie + + self.roomname = "test" + self.password = None + + self.room = None + 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 + "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 + # TODO: add a timeout + await self._connect_result + result = self._connect_result.result() + logger.debug(f"&{roomname}._connect_result: {result!r}") + + # deal with result + if result == "success": + logger.info(f"Successfully connected to &{roomname}.") + return task, result + else: # not successful for some reason + logger.warn(f"Could not join &{roomname}: {result!r}") + await self.stop() + return None, result + + async def stop(self): + if self.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) + + 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, listing, log, 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/exceptions.py b/yaboli/exceptions.py deleted file mode 100644 index f48993d..0000000 --- a/yaboli/exceptions.py +++ /dev/null @@ -1,41 +0,0 @@ -class YaboliException(Exception): - """ - Generic yaboli exception class. - """ - - pass - -class BotManagerException(YaboliException): - """ - Generic BotManager exception class. - """ - - pass - -class CreateBotException(BotManagerException): - """ - This exception will be raised when BotManager could not create a bot. - """ - - pass - -class BotNotFoundException(BotManagerException): - """ - This exception will be raised when BotManager could not find a bot. - """ - - pass - -class BotException(YaboliException): - """ - Generic Bot exception class. - """ - - pass - -class ParseMessageException(BotException): - """ - This exception will be raised when a failure parsing a message occurs. - """ - - pass diff --git a/yaboli/message.py b/yaboli/message.py deleted file mode 100644 index 7e64bc5..0000000 --- a/yaboli/message.py +++ /dev/null @@ -1,99 +0,0 @@ -import time - -from . import session - -class Message(): - """ - This class keeps track of message details. - """ - - def __init__(self, id, time, sender, content, parent=None, edited=None, deleted=None, - truncated=None): - """ - id - message id - time - time the message was sent (epoch) - sender - session of the sender - content - content of the message - parent - id of the parent message, or None - edited - time of last edit (epoch) - deleted - time of deletion (epoch) - truncated - message was truncated - """ - - self.id = id - self.time = time - self.sender = sender - self.content = content - self.parent = parent - self.edited = edited - self.deleted = deleted - self.truncated = truncated - - @classmethod - def from_data(self, data): - """ - Creates and returns a message created from the data. - NOTE: This also creates a session object using the data in "sender". - - data - a euphoria message: http://api.euphoria.io/#message - """ - - sender = session.Session.from_data(data["sender"]) - parent = data["parent"] if "parent" in data else None - edited = data["edited"] if "edited" in data else None - deleted = data["deleted"] if "deleted" in data else None - truncated = data["truncated"] if "truncated" in data else None - - return self( - data["id"], - data["time"], - sender, - data["content"], - parent=parent, - edited=edited, - deleted=deleted, - truncated=truncated - ) - - def time_formatted(self, date=False): - """ - time_formatted(date=False) -> str - - date - include date in format - - Time in a readable format: - With date: YYYY-MM-DD HH:MM:SS - Without date: HH:MM:SS - """ - - if date: - return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(self.time)) - else: - return time.strftime("%H:%M:%S", time.gmtime(self.time)) - - def is_edited(self): - """ - is_edited() -> bool - - Has this message been edited? - """ - - return True if self.edited is not None else False - - def is_deleted(self): - """ - is_deleted() -> bool - - Has this message been deleted? - """ - - return True if self.deleted is not None else False - - def is_truncated(self): - """ - is_truncated() -> bool - - Has this message been truncated? - """ - - return True if self.truncated is not None else False diff --git a/yaboli/messages.py b/yaboli/messages.py deleted file mode 100644 index 8382a69..0000000 --- a/yaboli/messages.py +++ /dev/null @@ -1,154 +0,0 @@ -import operator - -from . import message - -class Messages(): - """ - Message storage class which preserves thread hierarchy. - """ - - def __init__(self, message_limit=500): - """ - message_limit - maximum amount of messages that will be stored at a time - None - no limit - """ - - self.message_limit = message_limit - - self._by_id = {} - self._by_parent = {} - - def _sort(self, msgs): - """ - _sort(messages) -> None - - Sorts a list of messages by their id, in place. - """ - - msgs.sort(key=operator.attrgetter("id")) - - def add_from_data(self, data): - """ - add_from_data(data) -> None - - Create a message from "raw" data and add it. - """ - - mes = message.Message.from_data(data) - - self.add(mes) - - def add(self, mes): - """ - add(message) -> None - - Add a message to the structure. - """ - - self.remove(mes.id) - - self._by_id[mes.id] = mes - - if mes.parent: - if not mes.parent in self._by_parent: - self._by_parent[mes.parent] = [] - self._by_parent[mes.parent].append(mes) - - if self.message_limit and len(self._by_id) > self.message_limit: - self.remove(self.get_oldest().id) - - def remove(self, mid): - """ - remove(message_id) -> None - - Remove a message from the structure. - """ - - mes = self.get(mid) - if mes: - if mes.id in self._by_id: - self._by_id.pop(mes.id) - - parent = self.get_parent(mes.id) - if parent and mes in self.get_children(parent.id): - self._by_parent[mes.parent].remove(mes) - - def remove_all(self): - """ - remove_all() -> None - - Removes all messages. - """ - - self._by_id = {} - self._by_parent = {} - - def get(self, mid): - """ - get(message_id) -> Message - - Returns the message with the given id, if found. - """ - - if mid in self._by_id: - return self._by_id[mid] - - def get_oldest(self): - """ - get_oldest() -> Message - - Returns the oldest message, if found. - """ - - oldest = None - for mid in self._by_id: - if oldest is None or mid < oldest: - oldest = mid - return self.get(oldest) - - def get_youngest(self): - """ - get_youngest() -> Message - - Returns the youngest message, if found. - """ - - youngest = None - for mid in self._by_id: - if youngest is None or mid > youngest: - youngest = mid - return self.get(youngest) - - def get_parent(self, mid): - """ - get_parent(message_id) -> str - - Returns the message's parent, if found. - """ - - mes = self.get(mid) - if mes: - return self.get(mes.parent) - - def get_children(self, mid): - """ - get_children(message_id) -> list - - Returns a sorted list of children of the given message, if found. - """ - - if mid in self._by_parent: - children = self._by_parent[mid][:] - self._sort(children) - return children - - def get_top_level(self): - """ - get_top_level() -> list - - Returns a sorted list of top-level messages. - """ - - msgs = [self.get(mid) for mid in self._by_id if not self.get(mid).parent] - self._sort(msgs) - return msgs diff --git a/yaboli/room.py b/yaboli/room.py index 360e0e4..3902110 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,617 +1,545 @@ -import time +import asyncio +import logging +from .callbacks import * +from .connection import * +from .utils import * -from . import connection -from . import message -from . import messages -from . import session -from . import sessions -from . import callbacks +logger = logging.getLogger(__name__) +__all__ = ["Room"] -class Room(): - """ - Connects to and provides more abstract access to a room on euphoria. + + +class Room: + ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" + HUMAN_FORMAT = f"{ROOM_FORMAT}?h=1" - callback (values passed) - description - ---------------------------------------------------------------------------------- - delete (message) - message has been deleted - edit (message) - message has been edited - identity - own session or nick has changed - join (session) - user has joined the room - message (message) - message has been sent - messages - message data has changed - nick (session, old, new) - user has changed their nick - part (session) - user has left the room - ping - ping event has happened - room - room info has changed - sessions - session data has changed - change - room has been changed - """ - - def __init__(self, room=None, nick=None, password=None, message_limit=500): - """ - room - name of the room to connect to - nick - nick to assume, None -> no nick - password - room password (in case the room is private) - message_limit - maximum amount of messages that will be stored at a time - None - no limit - """ + def __init__(self, roomname, controller, human=False, cookie=None): + self.roomname = roomname + self.controller = controller + self.human = human + self.cookie = cookie - self.room = room - self.password = password - self.room_is_private = None - self.pm_with_nick = None - self.pm_with_user = None - - self.nick = nick + # Keeps track of sessions, but not messages, since they might be dealt + # with differently by different controllers. + # If you need to keep track of messages, use utils.Log. self.session = None - self.message_limit = message_limit + self.account = None + self.listing = Listing() - self.ping_last = 0 - self.ping_next = 0 - self.ping_offset = 0 # difference between server and local time - - self._messages = None - self._sessions = None - - self._callbacks = callbacks.Callbacks() - - self._con = None - - if self.room: - self.change(self.room, password=self.password) - - def launch(self): - """ - launch() -> Thread - - Open connection in a new thread (see connection.Connection.launch). - """ - - return self._con.launch() - - def stop(self): - """ - stop() -> None - - Close connection to room. - """ - - self._con.stop() - - def change(self, room, password=None): - """ - change(room) -> None - - Leave current room (if already connected) and join new room. - Clears all messages and sessions. - A call to launch() is necessary to start a new thread again. - """ - - if self._con: - self._con.stop() - - self.room = room - self.password = password + # Various room information + self.account_has_access = None + self.account_email_verified = None self.room_is_private = None + self.version = None # the version of the code being run and served by the server self.pm_with_nick = None - self.pm_with_user = None + self.pm_with_user_id = None - self.session = None + self._callbacks = Callbacks() + self._add_callbacks() - self.ping_last = 0 - self.ping_next = 0 - self.ping_offset = 0 # difference between server and local time + self._stopping = False + self._runtask = None - self._messages = messages.Messages(message_limit=self.message_limit) - self._sessions = sessions.Sessions() - - self._con = connection.Connection(self.room) - - self._con.add_callback("bounce-event", self._handle_bounce_event) - self._con.add_callback("disconnect-event", self._handle_disconnect_event) - self._con.add_callback("hello-event", self._handle_hello_event) - self._con.add_callback("join-event", self._handle_join_event) - self._con.add_callback("network-event", self._handle_network_event) - self._con.add_callback("nick-event", self._handle_nick_event) - self._con.add_callback("edit-message-event", self._handle_edit_message_event) - self._con.add_callback("part-event", self._handle_part_event) - self._con.add_callback("ping-event", self._handle_ping_event) - self._con.add_callback("send-event", self._handle_send_event) - self._con.add_callback("snapshot-event", self._handle_snapshot_event) - - self._callbacks.call("change") - - def add_callback(self, event, callback, *args, **kwargs): - """ - add_callback(ptype, callback, *args, **kwargs) -> None - - Add a function to be called when a certain event happens. - """ - - self._callbacks.add(event, callback, *args, **kwargs) - - def get_msg(self, mid): - """ - get_msg(message_id) -> Message - - Returns the message with the given id, if found. - """ - - return self._messages.get(mid) - - def get_msg_parent(self, mid): - """ - get_msg_parent(message_id) -> Message - - Returns the message's parent, if found. - """ - - return self._messages.get_parent(mid) - - def get_msg_children(self, mid): - """ - get_msg_children(message_id) -> list - - Returns a sorted list of children of the given message, if found. - """ - - return self._messages.get_children(mid) - - def get_msg_top_level(self): - """ - get_msg_top_level() -> list - - Returns a sorted list of top-level messages. - """ - - return self._messages.get_top_level() - - def get_msg_oldest(self): - """ - get_msg_oldest() -> Message - - Returns the oldest message, if found. - """ - - return self._messages.get_oldest() - - def get_msg_youngest(self): - """ - get_msg_youngest() -> Message - - Returns the youngest message, if found. - """ - - return self._messages.get_youngest() - - def get_session(self, sid): - """ - get_session(session_id) -> Session - - Returns the session with that id. - """ - - return self._sessions.get(sid) - - def get_sessions(self): - """ - get_sessions() -> list - - Returns the full list of sessions. - """ - - return self._sessions.get_all() - - def get_people(self): - """ - get_people() -> list - - Returns a list of all non-bot and non-lurker sessions. - """ - - return self._sessions.get_people() - - def get_accounts(self): - """ - get_accounts() -> list - - Returns a list of all logged-in sessions. - """ - - return self._sessions.get_accounts() - - def get_agents(self): - """ - get_agents() -> list - - Returns a list of all sessions who are not signed into an account and not bots or lurkers. - """ - - return self._sessions.get_agents() - - def get_bots(self): - """ - get_bots() -> list - - Returns a list of all bot sessions. - """ - - return self._sessions.get_bots() - - def get_lurkers(self): - """ - get_lurkers() -> list - - Returns a list of all lurker sessions. - """ - - return self._sessions.get_lurkers() - - def set_nick(self, nick): - """ - set_nick(nick) -> None - - Change your nick. - """ - - self.nick = nick - - if not self.session or self.session.name != self.nick: - self._con.add_next_callback(self._handle_nick_reply) - self._con.send_packet("nick", name=nick) - - def mentionable(self, nick=None): - """ - mentionable() - - A mentionable version of the nick. - The nick defaults to the bot's nick. - """ - - if nick is None: - nick = self.nick - - return "".join(c for c in nick if not c in ".!?;&<'\"" and not c.isspace()) - - def send_message(self, content, parent=None): - """ - send_message(content, parent) -> None - - Send a message. - """ - - self._con.add_next_callback(self._handle_send_reply) - self._con.send_packet("send", content=content, parent=parent) - - def authenticate(self, password=None): - """ - authenticate(passsword) -> None - - Try to authenticate so you can enter the room. - """ - - self.password = password - - self._con.add_next_callback(self._handle_auth_reply) - self._con.send_packet("auth", type="passcode", passcode=self.password) - - def update_sessions(self): - """ - update_sessions() -> None - - Resets and then updates the list of sessions. - """ - - self._con.add_next_callback(self._handle_who_reply) - self._con.send_packet("who") - - def load_msgs(self, number=50): - """ - load_msgs(number) -> None - - Request a certain number of older messages from the server. - """ - - self._con.add_next_callback(self._handle_log_reply) - self._con.send_packet("log", n=number, before=self.get_msg_oldest().id) - - def load_msg(self, mid): - """ - load_msg(message_id) -> None - - Request an untruncated version of the message with that id. - """ - - self._con.add_next_callback(self._handle_get_message_reply) - self._con.send_packet("get-message", id=mid) - - # ----- HANDLING OF EVENTS ----- - - def _handle_connect(self): - """ - TODO - """ - - self._callbacks.call("connect") - - def _handle_disconnect(self): - """ - TODO - """ - - self._callbacks.call("disconnect") - - def _handle_stop(self): - """ - TODO - """ - - self._callbacks.call("stop") - - def _handle_bounce_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "bounce-event", error) - self.stop() - return - - if self.password is not None: - self.authenticate(self.password) + if human: + url = self.HUMAN_FORMAT.format(self.roomname) else: - self.stop() + url = self.ROOM_FORMAT.format(self.roomname) + self._conn = Connection(url, self._handle_packet, self.cookie) - def _handle_disconnect_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "disconnect-event", error) - self.stop() - return - - self._con.disconnect() + async def connect(self, max_tries=10, delay=60): + task = await self._conn.connect(max_tries=1) + if task: + self._runtask = asyncio.ensure_future(self._run(task, max_tries=max_tries, delay=delay)) + return self._runtask - def _handle_hello_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "hello-event", error) - self.stop() - return - - self.session = session.Session.from_data(data["session"]) - self._sessions.add(self.session) - self._callbacks.call("identity") - self._callbacks.call("sessions") - - self.room_is_private = data["room_is_private"] - self._callbacks.call("room") - - def _handle_join_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "join-event", error) - self.update_sessions() - return - - ses = session.Session.from_data(data) - self._sessions.add(ses) - self._callbacks.call("join", ses) - self._callbacks.call("sessions") - - def _handle_network_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "network-event", error) - return - - if data["type"] == "partition": - self._sessions.remove_on_network_partition(data["server_id"], data["server_era"]) - self._callbacks.call("sessions") - - def _handle_nick_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "nick-event", error) - self.update_sessions() - return - - ses = self.get_session(data["session_id"]) - if ses: - ses.name = data["to"] - self._callbacks.call("nick", ses, data["from"], data["to"]) - self._callbacks.call("sessions") - - def _handle_edit_message_event(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "edit-message-event", error) - return - - msg = message.Message.from_data(data) - if msg: - self._messages.add(msg) + async def _run(self, task, max_tries=10, delay=60): + while not self._stopping: + if task.done(): + task = await self._conn.connect(max_tries=max_tries, delay=delay) + if not task: + return - if msg.deleted: - self._callbacks.call("delete", msg) - elif msg.edited: - self._callbacks.call("edit", msg) + await task + await self.controller.on_disconnected() + + self.stopping = False + + async def stop(self): + 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 - self._callbacks.call("messages") + response = await self._send_packet("auth", data) + rdata = response.get("data") + + success = rdata.get("success") + reason = rdata.get("reason", None) + return success, reason - def _handle_part_event(self, data, error): + async def ping_reply(self, time): """ - TODO + 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. """ - if error: - self._callbacks.call("error", "part-event", error) - self.update_sessions() - return + 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) - ses = session.Session.from_data(data) - if ses: - self._sessions.remove(ses.session_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 - self._callbacks.call("part", ses) - self._callbacks.call("sessions") + 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 - def _handle_ping_event(self, data, error): + async def nick(self, name): """ - TODO + 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). """ - if error: - self._callbacks.call("error", "ping-event", error) - return + data = {"name": name} - self.ping_last = data["time"] - self.ping_next = data["next"] - self.ping_offset = self.ping_last - time.time() + response = await self._send_packet("nick", data) + rdata = response.get("data") - self._con.send_packet("ping-reply", time=self.ping_last) - self._callbacks.call("ping") + 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 - def _handle_send_event(self, data, error): + async def pm_initiate(self, user_id): """ - TODO + pm_id, to_nick = await pm_initiate(user_id) + + From api.euphoria.io: + The pm-initiate command constructs a virtual room for private messaging + between the client and the given UserID. + + The pm-initiate-reply provides the PMID for the requested private + messaging room. """ - if error: - self._callbacks.call("error", "send-event", error) - return + data = {"user_id": user_id} - msg = message.Message.from_data(data) - self._callbacks.call("message", msg) + response = await self._send_packet("pm-initiate", data) + rdata = response.get("data") - self._messages.add(msg) - self._callbacks.call("messages") + pm_id = rdata.get("pm_id") + to_nick = rdata.get("to_nick") + return pm_id, to_nick - def _handle_snapshot_event(self, data, error): + async def send(self, content, parent=None): """ - TODO + 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. """ - if error: - self._callbacks.call("error", "snapshot-event", error) - self.stop() - return + data = {"content": content} + if parent: + data["parent"] = parent - self.set_nick(self.nick) + response = await self._send_packet("send", data) + rdata = response.get("data") - if "pm_with_nick" in data or "pm_with_user_id" in data: - if "pm_with_nick" in data: - self.pm_with_nick = data["pm_with_nick"] - if "pm_with_user_id" in data: - self.pm_with_user_id = data["pm_with_user_id"] - self._callbacks.call("room") - - self._sessions.remove_all() - for sesdata in data["listing"]: - self._sessions.add_from_data(sesdata) - self._callbacks.call("sessions") - - self._messages.remove_all() - for msgdata in data["log"]: - self._messages.add_from_data(msgdata) - self._callbacks.call("messages") + message = Message.from_dict(rdata) + return message - # ----- HANDLING OF REPLIES ----- + 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: + self.listing.add(session) + + return sessions - def _handle_auth_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "auth-reply", error) - self.stop() - return - - if not data["success"]: - self._con.stop() + # CATEGORY: ACCOUNT COMMANDS + # NYI, and probably never will - def _handle_get_message_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "get-message-reply", error) - return - - self._messages.add_from_data(data) - self._callbacks.call("messages") + # CATEGORY: ROOM HOST COMMANDS + # NYI, and probably never will - def _handle_log_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "log-reply", error) - return - - for msgdata in data["log"]: - self._messages.add_from_data(msgdata) - self._callbacks.call("messages") + # CATEGORY: STAFF COMMANDS + # NYI, and probably never will - def _handle_nick_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "nick-reply", error) - return - - if "to" in data: - self.session.name = self.nick - self._callbacks.call("identity") - - if data["to"] != self.nick: - self.set_nick(self.nick) - def _handle_send_reply(self, data, error): - """ - TODO - """ - - if error: - self._callbacks.call("error", "send-reply", error) - return - - self._messages.add_from_data(data) - self._callbacks.call("messages") - def _handle_who_reply(self, data, error): + # All the private functions for dealing with stuff + + def _add_callbacks(self): + self._callbacks.add("bounce-event", self._handle_bounce) + self._callbacks.add("disconnect-event", self._handle_disconnect) + self._callbacks.add("hello-event", self._handle_hello) + self._callbacks.add("join-event", self._handle_join) + self._callbacks.add("login-event", self._handle_login) + self._callbacks.add("logout-event", self._handle_logout) + self._callbacks.add("network-event", self._handle_network) + self._callbacks.add("nick-event", self._handle_nick) + self._callbacks.add("edit-message-event", self._handle_edit_message) + self._callbacks.add("part-event", self._handle_part) + self._callbacks.add("ping-event", self._handle_ping) + self._callbacks.add("pm-initiate-event", self._handle_pm_initiate) + self._callbacks.add("send-event", self._handle_send) + self._callbacks.add("snapshot-event", self._handle_snapshot) + + async def _send_packet(self, *args, **kwargs): + response = await self._conn.send(*args, **kwargs) + self._check_for_errors(response) + + return response + + async def _handle_packet(self, packet): + 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.") + + def _check_for_errors(self, packet): + if packet.get("throttled", False): + logger.warn(f"&{self.roomname}: Throttled for reason: {packet.get('throttled_reason', 'no reason')!r}") + + if "error" in packet: + raise ResponseError(response.get("error")) + + async def _handle_bounce(self, packet): """ - TODO + From api.euphoria.io: + A bounce-event indicates that access to a room is denied. """ - if error: - self._callbacks.call("error", "who-reply", error) - return + data = packet.get("data") - self._sessions.remove_all() - for sesdata in data["listing"]: - self._sessions.add_from_data(sesdata) - self._callbacks.call("sessions") + 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(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 + ) diff --git a/yaboli/session.py b/yaboli/session.py deleted file mode 100644 index e649655..0000000 --- a/yaboli/session.py +++ /dev/null @@ -1,71 +0,0 @@ -class Session(): - """ - This class keeps track of session details. - """ - - def __init__(self, id, name, server_id, server_era, session_id, is_staff=None, is_manager=None): - """ - id - agent/account id - name - name of the client when the SessionView was captured - server_id - id of the server - server_era - era of the server - session_id - session id (unique across euphoria) - is_staff - client is staff - is_manager - client is manager - """ - - self.id = id - self.name = name - self.server_id = server_id - self.server_era = server_era - self.session_id = session_id - self.staff = is_staff - self.manager = is_manager - - @classmethod - def from_data(self, data): - """ - Creates and returns a session created from the data. - - data - a euphoria SessionView: http://api.euphoria.io/#sessionview - """ - - is_staff = data["is_staff"] if "is_staff" in data else None - is_manager = data["is_manager"] if "is_manager" in data else None - - return self( - data["id"], - data["name"], - data["server_id"], - data["server_era"], - data["session_id"], - is_staff, - is_manager - ) - - def session_type(self): - """ - session_type() -> str - - The session's type (bot, account, agent). - """ - - return self.id.split(":")[0] - - def is_staff(self): - """ - is_staff() -> bool - - Is a user staff? - """ - - return self.staff and True or False - - def is_manager(self): - """ - is_manager() -> bool - - Is a user manager? - """ - - return self.staff and True or False diff --git a/yaboli/sessions.py b/yaboli/sessions.py deleted file mode 100644 index b00cf75..0000000 --- a/yaboli/sessions.py +++ /dev/null @@ -1,146 +0,0 @@ -from . import session - -class Sessions(): - """ - Keeps track of sessions. - """ - - def __init__(self): - """ - TODO - """ - self._sessions = {} - - def add_from_data(self, data): - """ - add_raw(data) -> None - - Create a session from "raw" data and add it. - """ - - ses = session.Session.from_data(data) - - self._sessions[ses.session_id] = ses - - def add(self, ses): - """ - add(session) -> None - - Add a session. - """ - - self._sessions[ses.session_id] = ses - - def get(self, sid): - """ - get(session_id) -> Session - - Returns the session with that id. - """ - - if sid in self._sessions: - return self._sessions[sid] - - def remove(self, sid): - """ - remove(session) -> None - - Remove a session. - """ - - if sid in self._sessions: - self._sessions.pop(sid) - - def remove_on_network_partition(self, server_id, server_era): - """ - remove_on_network_partition(server_id, server_era) -> None - - Removes all sessions matching the server_id/server_era combo. - http://api.euphoria.io/#network-event - """ - - sesnew = {} - for sid in self._sessions: - ses = self.get(sid) - if not (ses.server_id == server_id and ses.server_era == server_era): - sesnew[sid] = ses - self._sessions = sesnew - - def remove_all(self): - """ - remove_all() -> None - - Removes all sessions. - """ - - self._sessions = {} - - def get_all(self): - """ - get_all() -> list - - Returns the full list of sessions. - """ - - return [ses for sid, ses in self._sessions.items()] - - def get_people(self): - """ - get_people() -> list - - Returns a list of all non-bot and non-lurker sessions. - """ - - # not a list comprehension because that would span several lines too - people = [] - for sid in self._sessions: - ses = self.get(sid) - if ses.session_type() in ["agent", "account"] and ses.name: - people.append(ses) - return people - - def _get_by_type(self, tp): - """ - _get_by_type(session_type) -> list - - Returns a list of all non-lurker sessions with that type. - """ - - return [ses for sid, ses in self._sessions.items() - if ses.session_type() == tp and ses.name] - - def get_accounts(self): - """ - get_accounts() -> list - - Returns a list of all logged-in sessions. - """ - - return self._get_by_type("account") - - def get_agents(self): - """ - get_agents() -> list - - Returns a list of all sessions who are not signed into an account and not bots or lurkers. - """ - - return self._get_by_type("agent") - - def get_bots(self): - """ - get_bots() -> list - - Returns a list of all bot sessions. - """ - - return self._get_by_type("bot") - - def get_lurkers(self): - """ - get_lurkers() -> list - - Returns a list of all lurker sessions. - """ - - return [ses for sid, ses in self._sessions.items() if not ses.name] diff --git a/yaboli/utils.py b/yaboli/utils.py new file mode 100644 index 0000000..0f619ed --- /dev/null +++ b/yaboli/utils.py @@ -0,0 +1,175 @@ +import asyncio +import time + +__all__ = [ + "run_controller", + "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 + + 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()) + +def mention_reduced(nick): + return mention(nick).lower() + +def similar(nick1, nick2): + return mention_reduced(nick1) == mention_reduced(nick2) + +def format_time(timestamp): + return time.strftime( + "%Y-%m-%d %H:%M:%S UTC", + time.gmtime(timestamp) + ) + +def format_time_delta(delta): + if delta < 0: + result = "-" + else: + result = "" + + delta = int(delta) + + second = 1 + minute = second*60 + hour = minute*60 + day = hour*24 + + if delta >= day: + result += f"{delta//day}d " + delta = delta%day + + if delta >= hour: + result += f"{delta//hour}h " + delta = delta%day + + if delta >= minute: + result += f"{delta//minute}m " + delta = delta%minute + + result += f"{delta}s" + + return result + +class Session: + def __init__(self, user_id, nick, server_id, server_era, session_id, is_staff=None, + is_manager=None, client_address=None, real_address=None): + 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_address = real_address + + @classmethod + def from_dict(cls, d): + return cls( + d.get("id"), + d.get("name"), + d.get("server_id"), + d.get("server_era"), + d.get("session_id"), + d.get("is_staff", None), + d.get("is_manager", None), + d.get("client_address", None), + d.get("real_address", None) + ) + + @property + def client_type(self): + # account, agent or bot + return self.user_id.split(":")[0] + +class Listing: + def __init__(self): + self._sessions = {} + + def __len__(self): + return len(self._sessions) + + def add(self, session): + self._sessions[session.session_id] = session + + def remove(self, session_id): + self._sessions.pop(session_id) + + def remove_combo(self, server_id, server_era): + self._sessions = {i: ses for i, ses in self._sessions.items + if ses.server_id != server_id and ses.server_era != server_era} + + def by_sid(self, session_id): + return self._sessions.get(session_id); + + def by_uid(self, user_id): + return [ses for ses in self._sessions if ses.user_id == user_id] + + def get_people(self): + return {uid: ses for uid, ses in self._sessions.items() + if ses.client_type in ["agent", "account"]} + + def get_accounts(self): + return {uid: ses for uid, ses in self._sessions.items() + if ses.client_type is "account"} + + def get_agents(self): + return {uid: ses for uid, ses in self._sessions.items() + if ses.client_type is "agent"} + + def get_bots(self): + return {uid: ses for uid, ses in self._sessions.items() + if ses.client_type is "bot"} + +class Message(): + def __init__(self, message_id, time, sender, content, parent=None, previous_edit_id=None, + encryption_key=None, edited=None, deleted=None, truncated=None): + self.message_id = message_id + self.time = time + self.sender = sender + self.content = content + self.parent = parent + self.previous_edit_id = previous_edit_id + self.encryption_key = encryption_key + self.edited = edited + self.deleted = deleted + self.truncated = truncated + + @classmethod + def from_dict(cls, d): + return cls( + d.get("id"), + d.get("time"), + Session.from_dict(d.get("sender")), + d.get("content"), + d.get("parent", None), + d.get("previous_edit_id", None), + d.get("encryption_key", None), + d.get("edited", None), + d.get("deleted", None), + d.get("truncated", None) + ) + +class Log: + pass # TODO + +class ResponseError(Exception): + pass