diff --git a/.gitignore b/.gitignore index 5b4d23f..7ce48d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -yaboli/__pycache__/ +__pycache__/ +*.egg-info/ +/.mypy_cache/ +/.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e0f1801 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +## Next version + +## 1.2.0 (2022-08-21) + +- update websockets dependency +- switch to pyproject.toml style setuptools config + +## 1.1.5 (2020-01-26) + +- more stability (I think) + +## 1.1.4 (2019-06-21) + +- 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 + +## 1.1.3 (2019-04-19) + +- add timeout for creating ws connections +- fix config file not reloading when restarting bots + +## 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) + +- add database class for easier sqlite3 access + +## 1.1.0 (2019-04-14) + +- 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 +- add login and logout command to room +- add pm command to room +- add cookie support +- add !restart to botrulez +- add Bot config file saving +- fix the Room not setting its nick correctly upon reconnecting + +## 0.2.0 (2019-04-12) + +- add `ALIASES` variable to `Bot` +- add `on_connected` function to `Client` +- change config file format + +## 0.1.0 (2019-04-12) + +- use setuptools diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f2fd14f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +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 +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cd4eb1 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Yaboli + +Yaboli (**Y**et **A**nother **Bo**t **Li**brary) is a Python library for +creating bots for [euphoria.io](https://euphoria.io). + +- [Documentation](docs/index.md) +- [Changelog](CHANGELOG.md) + +## Installation + +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.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 +[botrulez](https://github.com/jedevc/botrulez) can be written like so: + +```python +class EchoBot(yaboli.Bot): + HELP_GENERAL = "/me echoes back what you said" + HELP_SPECIFIC = [ + "This bot only has one command:", + "!echo – reply with exactly ", + ] + + def __init__(self, config_file): + super().__init__(config_file) + 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) +``` + +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. + +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, +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?) +- [ ] document new classes (docstrings, maybe comments) +- [ ] write examples +- [ ] make yaboli package play nice with mypy +- [x] implement !uptime for proper botrulez conformity +- [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 +- [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 +- [x] cookie support +- [x] fancy argument parsing diff --git a/TestBot.py b/TestBot.py deleted file mode 100644 index be41127..0000000 --- a/TestBot.py +++ /dev/null @@ -1,48 +0,0 @@ -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/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 new file mode 100644 index 0000000..9f4835f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,89 @@ +# Index for yaboli docs + + - [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 + +### 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 +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. + +### 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`. diff --git a/examples/echo/.gitignore b/examples/echo/.gitignore new file mode 100644 index 0000000..da78a19 --- /dev/null +++ b/examples/echo/.gitignore @@ -0,0 +1,5 @@ +# 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.default b/examples/echo/bot.conf.default new file mode 100644 index 0000000..940e8e4 --- /dev/null +++ b/examples/echo/bot.conf.default @@ -0,0 +1,6 @@ +[general] +nick = EchoBot +cookie_file = bot.cookie + +[rooms] +test diff --git a/examples/echo/echobot.py b/examples/echo/echobot.py new file mode 100644 index 0000000..e404f3c --- /dev/null +++ b/examples/echo/echobot.py @@ -0,0 +1,23 @@ +import yaboli + + +class EchoBot(yaboli.Bot): + HELP_GENERAL = "/me echoes back what you said" + HELP_SPECIFIC = [ + "This bot only has one command:", + "!echo – reply with exactly ", + ] + + 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): + text = args.raw.strip() # ignoring leading and trailing whitespace + await message.reply(text) + + +if __name__ == "__main__": + yaboli.enable_logging() + yaboli.run(EchoBot) diff --git a/examples/gitignore_with_venv b/examples/gitignore_with_venv new file mode 100644 index 0000000..f69b963 --- /dev/null +++ b/examples/gitignore_with_venv @@ -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/mypy.ini b/mypy.ini new file mode 100644 index 0000000..6fd0e6a --- /dev/null +++ b/mypy.ini @@ -0,0 +1,4 @@ +[mypy] +disallow_untyped_defs = True +disallow_incomplete_defs = True +no_implicit_optional = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..79ad530 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "yaboli" +version = "1.2.0" +dependencies = [ + "websockets >=10.3, <11" +] + +# When updating the version, also: +# - 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, None: + handler = logging.StreamHandler() + handler.setFormatter(FORMATTER) + + logger = logging.getLogger(name) + logger.setLevel(level) + logger.addHandler(handler) + +def run( + bot_constructor: BotConstructor, + config_file: str = "bot.conf", + ) -> None: + 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() + + asyncio.run(_run()) + +def run_modulebot( + modulebot_constructor: ModuleBotConstructor, + module_constructors: Dict[str, ModuleConstructor], + config_file: str = "bot.conf", + ) -> None: + 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() + + asyncio.run(_run()) diff --git a/yaboli/bot.py b/yaboli/bot.py index a274d77..97385cb 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,176 +1,377 @@ -import asyncio +import configparser +import datetime import logging -import re -import time -from .callbacks import * -from .controller import * -from .utils import * +from typing import Callable, List, Optional + +from .client import Client +from .command import * +from .message import LiveMessage, Message +from .room import Room +from .util import * logger = logging.getLogger(__name__) -__all__ = ["Bot"] +__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. -class Bot(Controller): - # ^ and $ not needed since we're doing a re.fullmatch - SPECIFIC_RE = r"!(\S+)\s+@(\S+)([\S\s]*)" - GENERIC_RE = r"!(\S+)([\S\s]*)" - - def __init__(self, nick): - super().__init__(nick) - - self.start_time = time.time() - - self._callbacks = Callbacks() - self.register_default_callbacks() - - # settings (modify in your bot's __init__) - self.general_help = None # None -> does not respond to general help - self.killable = True - self.kill_message = "/me *poof*" # how to respond to !kill, whether killable or not - - def register_callback(self, event, callback, specific=True): - self._callbacks.add((event, specific), callback) - - async def on_send(self, message): - parsed = self.parse_message(message.content) - if not parsed: - return - command, args = parsed - - # general callback (specific set to False) - general = asyncio.ensure_future( - self._callbacks.call((command, False), message, args) - ) - - if len(args) > 0: - mention = args[0] - args = args[1:] - if mention[:1] == "@" and similar(mention[1:], self.nick): - # specific callback (specific set to True) - await self._callbacks.call((command, True), message, args) - - await general - - def parse_message(self, content): - """ - (command, args) = parse_message(content) - - Returns None, not a (None, None) tuple, when message could not be parsed - """ - - match = re.fullmatch(self.GENERIC_RE, content) - if not match: - return None - - command = match.group(1) - argstr = match.group(2) - args = self.parse_args(argstr) - - return command, args - - def parse_args(self, text): - """ - Use single- and double-quotes bash-style to include whitespace in arguments. - A backslash always escapes the next character. - Any non-escaped whitespace separates arguments. - - Returns a list of arguments. - Deals with unclosed quotes and backslashes without crashing. - """ - - escape = False - quote = None - args = [] - arg = "" - - for character in text: - if escape: - arg += character - escape = False - elif character == "\\": - escape = True - elif quote: - if character == quote: - quote = None - else: - arg += character - elif character in "'\"": - quote = character - elif character.isspace(): - if len(arg) > 0: - args.append(arg) - arg = "" - else: - arg += character - - #if escape or quote: - #return None # syntax error - - if len(arg) > 0: - args.append(arg) - - return args - - def parse_flags(self, arglist): - flags = "" - args = [] - kwargs = {} - - for arg in arglist: - # kwargs (--abc, --foo=bar) - if arg[:2] == "--": - arg = arg[2:] - if "=" in arg: - s = arg.split("=", maxsplit=1) - kwargs[s[0]] = s[1] - else: - kwargs[arg] = None - # flags (-x, -rw) - elif arg[:1] == "-": - arg = arg[1:] - flags += arg - # args (normal arguments) - else: - args.append(arg) - - return flags, args, kwargs - - - - # BOTRULEZ COMMANDS - - def register_default_callbacks(self): - self.register_callback("ping", self.command_ping) - self.register_callback("ping", self.command_ping, specific=False) - self.register_callback("help", self.command_help) - self.register_callback("help", self.command_help_general, specific=False) - self.register_callback("uptime", self.command_uptime) - self.register_callback("kill", self.command_kill) - # TODO: maybe !restart command - - async def command_ping(self, message, args): - await self.room.send("Pong!", message.message_id) - - async def command_help(self, message, args): - await self.room.send("", message.message_id) - - async def command_help_general(self, message, args): - if self.general_help is not None: - await self.room.send(self.general_help, message.message_id) - - async def command_uptime(self, message, args): - now = time.time() - startformat = format_time(self.start_time) - deltaformat = format_time_delta(now - self.start_time) - text = f"/me has been up since {startformat} ({deltaformat})" - await self.room.send(text, message.message_id) - - async def command_kill(self, message, args): - logging.warn(f"Kill attempt in &{self.room.roomname}: {message.content!r}") - - if self.kill_message is not None: - await self.room.send(self.kill_message, message.message_id) - - if self.killable: - await self.stop() + 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!" + HELP_GENERAL: Optional[str] = None + HELP_SPECIFIC: Optional[List[str]] = None + KILL_REPLY: Optional[str] = "/me dies" + RESTART_REPLY: Optional[str] = "/me restarts" + + GENERAL_SECTION = "general" + ROOMS_SECTION = "rooms" + + def __init__(self, + config: configparser.ConfigParser, + config_file: str, + ) -> None: + self.config = config + self.config_file = 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" + " nick")) + 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] = [] + + 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) + else: + await self.join(room, password=password) + + # 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, + name: str, + 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) + + def register_specific(self, + name: str, + 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) + + # Processing commands + + async def process_commands(self, + room: Room, + 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) + + 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: + """ + 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, + "mention": room.session.mention, + "atmention": room.session.atmention, + } + return text.format(**params) + + # Botrulez + + def register_botrulez(self, + ping: bool = True, + help_: bool = True, + uptime: bool = True, + 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) + + 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) + + if uptime: + self.register_specific("uptime", self.cmd_uptime, args=False) + + 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, + args: ArgumentData + ) -> None: + """ + Reply with self.PING_REPLY. + """ + + await message.reply(self.PING_REPLY) + + async def cmd_help_general(self, + room: Room, + 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])) + + async def cmd_help_specific(self, + room: Room, + 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)) + + async def cmd_uptime(self, + room: Room, + 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})" + await message.reply(text) + + async def cmd_kill(self, + room: Room, + 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: + await message.reply(self.KILL_REPLY) + + await self.part(room) + + async def cmd_restart(self, + room: Room, + 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: + await message.reply(self.RESTART_REPLY) + + await self.stop() + +BotConstructor = Callable[[configparser.ConfigParser, str], Bot] diff --git a/yaboli/callbacks.py b/yaboli/callbacks.py deleted file mode 100644 index 71902d5..0000000 --- a/yaboli/callbacks.py +++ /dev/null @@ -1,56 +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 diff --git a/yaboli/client.py b/yaboli/client.py new file mode 100644 index 0000000..75806fb --- /dev/null +++ b/yaboli/client.py @@ -0,0 +1,171 @@ +import asyncio +import functools +import logging +from typing import Dict, List, Optional, Union + +from .message import LiveMessage +from .room import Room +from .session import LiveSession + +logger = logging.getLogger(__name__) + +__all__ = ["Client"] + +class Client: + 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() + + 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: 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 + + 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)) + 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 stopping(self) -> None: + pass + + # 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 + + 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, 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, + 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/command.py b/yaboli/command.py new file mode 100644 index 0000000..08ac3f7 --- /dev/null +++ b/yaboli/command.py @@ -0,0 +1,384 @@ +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): + """ + 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: + self._raw = raw + + 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]: + """ + 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] = [] + + backslash = False + quotes: Optional[str] = None + + for char in text: + if backslash: + backslash = False + word.append(char) + elif quotes is not None: + 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)) + 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: + 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: + 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._raw, escaped) + return self._basic_escaped + else: + if self._basic is None: + 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._raw, escaped) + self._fancy_escaped = self._parse_fancy(basic) + return self._fancy_escaped + else: + if self._fancy is None: + basic = self._split(self._raw, escaped) + 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, raw: str) -> None: + super().__init__(raw) + + 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.has_args(): + 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.has_args(): + return + + await self._cmdfunc(room, message, data.specific) diff --git a/yaboli/connection.py b/yaboli/connection.py index b0a108d..fcc27fe 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -1,158 +1,571 @@ -import logging -logger = logging.getLogger(__name__) - import asyncio -asyncio.get_event_loop().set_debug(True) - import json +import logging +import socket +from typing import Any, Awaitable, Callable, Dict, Optional + import websockets -#from websockets import ConnectionClosed + +from .cookiejar import CookieJar +from .events import Events +from .exceptions import * + +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: - def __init__(self, url, packet_hook, cookie=None): - self.url = url - self.cookie = cookie - self.packet_hook = packet_hook - - self._ws = None - self._pid = 0 # successive packet ids - self._spawned_tasks = set() - self._pending_responses = {} - #self._stopping = False - self._runtask = None - - async def connect(self, max_tries=10, delay=60): - """ - success = await connect(max_tries=10, delay=60) - - Attempt to connect to a room. - Returns the task listening for packets, or None if the attempt failed. - """ - - 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 = await websockets.connect(self.url, max_size=None) - except (websockets.InvalidURI, websockets.InvalidHandshake): - self._ws = None - if tries_left > 0: - await asyncio.sleep(delay) - else: - self._runtask = asyncio.ensure_future(self._run()) - return self._runtask - - async def _run(self): - """ - Listen for packets and deal with them accordingly. - """ - - try: - while True: - 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 - - async def stop(self): - """ - Close websocket connection and wait for running task to stop. - """ - - if self._ws: - await self._ws.close() - - if self._runtask: - await self._runtask - - async def send(self, ptype, data=None, await_response=True): - if not self._ws: - 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}") - 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 + """ + 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) + + It doesn't respond to any events other than the ping-event and is otherwise + "dumb". + + + + 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(), then go 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 + - "": 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 "message-event", + "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 + + # The delay between reconnect attempts. + RECONNECT_DELAY = 40 # seconds + + # States the Connection may be in + _NOT_RUNNING = "not running" + _CONNECTING = "connecting" + _RUNNING = "running" + _RECONNECTING = "reconnecting" + _DISCONNECTING = "disconnecting" + + # Initialising + + 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 + + # 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._state = self._NOT_RUNNING + self._connected_condition = asyncio.Condition() + self._disconnected_condition = asyncio.Condition() + + 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: 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]] + ) -> None: + """ + Register an event callback. + + For an overview of the possible events, see the Connection docstring. + """ + + 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 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") + return + + 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() + + logger.debug("Cleaning up variables") + 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: + logger.debug(f"Creating ws connection to {self._url!r}") + 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 = {} + logger.debug("Starting ping check") + 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, + OSError, asyncio.TimeoutError): + logger.debug("Connection failed") + return False + + async def _disconnect_in(self, delay: int) -> None: + await asyncio.sleep(delay) + 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 _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("This should never happen") + + 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: + """ + 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" + " multiple times.")) + + if self._state != self._NOT_RUNNING: + raise IncorrectStateException(("disconnect() must complete before" + " connect() may be called again.")) + + 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 + # setting it. + self._state = self._CONNECTING + + success = await self._connect() + + if success: + 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 + + logger.debug("Sending connected notification") + async with self._connected_condition: + self._connected_condition.notify_all() + + logger.debug("Connected" if success else "Connection failed") + return success + + async def disconnect(self) -> None: + """ + 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 + + # 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() + + # Possible states left: _NOT_RUNNING, _RUNNING, _DISCONNECTING + + 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 + + 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. + 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 + + # 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.") + + + 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 + # setting it. + self._state = self._DISCONNECTING + + 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 + + self._state = self._NOT_RUNNING + + # Notify all other disconnect()s waiting + logger.debug("Sending disconnected notification") + async with self._disconnected_condition: + self._disconnected_condition.notify_all() + + 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: + """ + The main loop that runs during phase 3 + """ + + 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 + + if self._ws is not None: + 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: + 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: Any) -> None: + # 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") + if packet_id is not None and self._awaiting_replies is not 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 + packet_type = packet["type"] + self._events.fire(packet_type, packet) + + # Finally, reset the ping check + 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: + """ + 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. + + 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 _ping_pong(self, packet: Any) -> None: + """ + Implements http://api.euphoria.io/#ping and is called as "ping-event" + callback. + """ + logger.debug("Pong!") + await self._do_if_possible(self.send( + "ping-reply", + {"time": packet["data"]["time"]}, + await_reply=False + )) + + async def send(self, + packet_type: str, + data: Any, + await_reply: bool = True + ) -> 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. + """ + + 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 diff --git a/yaboli/controller.py b/yaboli/controller.py deleted file mode 100644 index 062c7a0..0000000 --- a/yaboli/controller.py +++ /dev/null @@ -1,205 +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): - """ - 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/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/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/events.py b/yaboli/events.py new file mode 100644 index 0000000..7829ccb --- /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}") + + 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 new file mode 100644 index 0000000..034aaad --- /dev/null +++ b/yaboli/exceptions.py @@ -0,0 +1,67 @@ +__all__ = [ + "EuphException", + # Connection exceptions + "IncorrectStateException", + "ConnectionClosedException", + # Joining a room + "JoinException", + "CouldNotConnectException", + "CouldNotAuthenticateException", + # Doing stuff in a room + "RoomNotConnectedException", + "EuphError", +] + +class EuphException(Exception): + pass + +# Connection exceptions + +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): + """ + An exception that happened while joining a room. + """ + 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 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 diff --git a/yaboli/message.py b/yaboli/message.py new file mode 100644 index 0000000..ebad87c --- /dev/null +++ b/yaboli/message.py @@ -0,0 +1,173 @@ +import datetime +from typing import TYPE_CHECKING, Any, List, Optional + +from .session import LiveSession, Session + +if TYPE_CHECKING: + from .room import Room + +__all__ = ["Message", "LiveMessage"] + +class Message: + 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_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 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) diff --git a/yaboli/module.py b/yaboli/module.py new file mode 100644 index 0000000..ac750bf --- /dev/null +++ b/yaboli/module.py @@ -0,0 +1,214 @@ +import configparser +import logging +from typing import Callable, 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", "ModuleConstructor", "ModuleBot", "ModuleBotConstructor"] + +class Module(Bot): + DESCRIPTION: Optional[str] = None + + 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]] = [ + "", + "For module-specific help, try \"!help {atmention} \".", + ] + MODULE_HELP_LIMIT = 5 + + 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] = {} + + # 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 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: + modules_without_desc.append(module_name) + else: + line = f"\t{module_name} — {module.DESCRIPTION}" + lines.append(line) + + if 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) + + 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 + + async def cmd_modules_help(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_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) + + 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_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, + 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) + +ModuleBotConstructor = Callable[ + [configparser.ConfigParser, str, Dict[str, ModuleConstructor]], + Bot +] diff --git a/yaboli/room.py b/yaboli/room.py index 3902110..d1304ee 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,545 +1,562 @@ import asyncio import logging -from .callbacks import * -from .connection import * -from .utils import * +from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar + +from .connection import Connection +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__) + __all__ = ["Room"] - +T = TypeVar("T") class Room: - ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" - HUMAN_FORMAT = f"{ROOM_FORMAT}?h=1" - - def __init__(self, roomname, controller, human=False, cookie=None): - 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.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) - 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): - task = await self._conn.connect(max_tries=1) - if task: - self._runtask = asyncio.ensure_future(self._run(task, max_tries=max_tries, delay=delay)) - return self._runtask - - async def _run(self, task, max_tries=10, delay=60): - while not self._stopping: - 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): - 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 - - 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 - - # 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): - 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): - """ - 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(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 - ) + """ + 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] + + "send" - another room member has sent a message + message: LiveMessage + + "join" - somebody has joined the room + user: LiveSession + + "part" - somebody has left the room + user: LiveSession + + "nick" - another room member has changed their nick + user: LiveSession + from: str + to: str + + "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 + from_room: str - the room where the invitation was sent from + pm_id: str - the private chat can be accessed at /room/pm:PMID + + "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 + """ + + URL_FORMAT = "wss://euphoria.io/room/{}/ws" + + def __init__(self, + name: str, + password: Optional[str] = None, + target_nick: str = "", + url_format: str = URL_FORMAT, + cookie_file: Optional[str] = None, + ) -> None: + self._name = name + self._password = password + self._target_nick = target_nick + self._url_format = url_format + + 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 + + # Connected management + self._url = self._url_format.format(self._name) + self._connection = Connection(self._url, cookie_file=cookie_file) + self._events = Events() + + self._connected = asyncio.Event() + self._connected_successfully = False + self._hello_received = False + self._snapshot_received = False + + 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) + + 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) + + def register_event(self, + event: str, + callback: Callable[..., Awaitable[None]] + ) -> None: + """ + Register an event callback. + + For an overview of the possible events, see the Room docstring. + """ + + self._events.register(event, callback) + + # Connecting, reconnecting and disconnecting + + 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(): + 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(): + 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._private = data["room_is_private"] + self._version = data["version"] + + if "account" in data: + self._account = Account.from_data(data) + + self._hello_received = True + await self._try_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 "snapshot" event + messages = [LiveMessage.from_data(self, msg_data) + for msg_data in data["log"]] + self._events.fire("snapshot", messages) + + self._snapshot_received = True + await self._try_set_connected() + + async def _on_bounce_event(self, packet: Any) -> None: + data = packet["data"] + + # 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 + + # 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 + + self._events.fire("connected") + 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) + + logger.info(f"&{self.name}: {session.atmention} joined") + self._events.fire("join", session) + + async def _on_login_event(self, packet: Any) -> None: + """ + 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: + """ + 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"] + + 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) + logger.info(f"&{self.name}: {user.atmention} left") + 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 + + 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: + 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) + + logger.info(f"&{self.name}: {session.atmention} left") + 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: + return self._name + + @property + def password(self) -> Optional[str]: + return self._password + + @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 + + 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 + + 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 + + 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) + + 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 + + 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 new file mode 100644 index 0000000..e59c81a --- /dev/null +++ b/yaboli/session.py @@ -0,0 +1,324 @@ +import re +from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, + Optional, Tuple) + +from .util import mention, normalize + +if TYPE_CHECKING: + from .room import Room + +__all__ = ["Account", "Session", "LiveSession", "LiveSessionListing"] + +class Account: + """ + 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": + """ + 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: + _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.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, + 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: + return self._nick + + @property + def server_id(self) -> str: + return self._server_id + + @property + def server_era(self) -> str: + 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": + copy = self._copy() + copy._nick = nick + return copy + + # Attributes + + @property + def room(self) -> "Room": + return self._room + + # Live stuff + + 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: + 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]: + return self._sessions.values().__iter__() + + def _copy(self) -> "LiveSessionListing": + return LiveSessionListing(self.room, self) + + @classmethod + def from_data(cls, + room: "Room", + data: Any, + exclude_id: Optional[str] = None + ) -> "LiveSessionListing": + 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]: + return self._sessions.get(session_id) + + def with_join(self, session: LiveSession) -> "LiveSessionListing": + copy = self._copy() + copy._sessions[session.session_id] = session + return copy + + def with_part(self, session: LiveSession) -> "LiveSessionListing": + 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": + 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 new file mode 100644 index 0000000..e8395d9 --- /dev/null +++ b/yaboli/util.py @@ -0,0 +1,73 @@ +import asyncio +import datetime +import functools +import re +from typing import Any, Callable + +__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 + +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() + +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 + +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 diff --git a/yaboli/utils.py b/yaboli/utils.py deleted file mode 100644 index 0f619ed..0000000 --- a/yaboli/utils.py +++ /dev/null @@ -1,175 +0,0 @@ -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