diff --git a/.gitignore b/.gitignore index 66427ba..7ce48d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -yaboli/__pycache__/ -*.db +__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/ExampleBot.py b/ExampleBot.py deleted file mode 100644 index 0a60d0b..0000000 --- a/ExampleBot.py +++ /dev/null @@ -1,21 +0,0 @@ -import asyncio -import yaboli - -class ExampleBot(yaboli.Bot): - async def send(self, room, message): - ping = "ExamplePong!" - short_help = "Example bot for the yaboli bot library" - long_help = "I'm an example bot for the yaboli bot library, which can be found at https://github.com/Garmelon/yaboli" - - await self.botrulez_ping_general(room, message, ping_text=ping) - await self.botrulez_ping_specific(room, message, ping_text=ping) - await self.botrulez_help_general(room, message, help_text=short_help) - await self.botrulez_help_specific(room, message, help_text=long_help) - await self.botrulez_uptime(room, message) - await self.botrulez_kill(room, message) - await self.botrulez_restart(room, message) - - forward = send # should work without modifications for most bots - -bot = ExampleBot("ExampleBot", "examplebot_cookies", rooms=["test", "welcome"]) -asyncio.get_event_loop().run_forever() diff --git a/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/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 d91ce01..97385cb 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,176 +1,377 @@ +import configparser +import datetime import logging -import re -import time - -from .cookiejar import * -from .room 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", "command"] +__all__ = ["Bot", "BotConstructor"] -# Some command stuff +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. -SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)") -GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)") + The config file is loaded as a ConfigParser by the run() or run_modulebot() + functions and has the following structure: -def command(commandname, specific=True, noargs=False): - def decorator(func): - async def wrapper(self, room, message, *args, **kwargs): - if specific: - result = self._parse_command(message.content, specific=room.session.nick) - else: - result = self._parse_command(message.content) - if result is None: return - cmd, argstr = result - if cmd != commandname: return - if noargs: - if argstr: return - return await func(self, room, message, *args, **kwargs) - else: - return await func(self, room, message, argstr, *args, **kwargs) - return wrapper - return decorator + 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". -# And now comes the real bot... + 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") + """ -class Bot(Inhabitant): - def __init__(self, nick, cookiefile=None): - self.target_nick = nick - self.rooms = {} - self.cookiejar = CookieJar(cookiefile) + ALIASES: List[str] = [] - # ROOM MANAGEMENT + 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" - def join_room(self, roomname, password=None): - if roomname in self.rooms: - return + GENERAL_SECTION = "general" + ROOMS_SECTION = "rooms" - self.rooms[roomname] = Room(self, roomname, self.target_nick, password=password, cookiejar=self.cookiejar) + def __init__(self, + config: configparser.ConfigParser, + config_file: str, + ) -> None: + self.config = config + self.config_file = config_file - async def part_room(self, roomname): - room = self.rooms.pop(roomname, None) - if room: - await room.exit() + 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 = "" - # BOTRULEZ + 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.")) - @command("ping", specific=False, noargs=True) - async def botrulez_ping_general(self, room, message, ping_text="Pong!"): - await room.send(ping_text, message.mid) + super().__init__(nick, cookie_file=cookie_file) - @command("ping", specific=True, noargs=True) - async def botrulez_ping_specific(self, room, message, ping_text="Pong!"): - await room.send(ping_text, message.mid) + self._commands: List[Command] = [] - @command("help", specific=False, noargs=True) - async def botrulez_help_general(self, room, message, help_text="Placeholder help text"): - await room.send(help_text, message.mid) + self.start_time = datetime.datetime.now() - @command("help", specific=True, noargs=True) - async def botrulez_help_specific(self, room, message, help_text="Placeholder help text"): - await room.send(help_text, message.mid) + def save_config(self) -> None: + """ + Save the current state of self.config to the file passed in __init__ as + the config_file parameter. - @command("uptime", specific=True, noargs=True) - async def botrulez_uptime(self, room, message): - now = time.time() - startformat = format_time(room.start_time) - deltaformat = format_time_delta(now - room.start_time) - text = f"/me has been up since {startformat} ({deltaformat})" - await room.send(text, message.mid) + Usually, this is the file that self.config was loaded from (if you use + run or run_modulebot). + """ - @command("kill", specific=True, noargs=True) - async def botrulez_kill(self, room, message, kill_text="/me dies"): - await room.send(kill_text, message.mid) - await self.part_room(room.roomname) + with open(self.config_file, "w") as f: + self.config.write(f) - @command("restart", specific=True, noargs=True) - async def botrulez_restart(self, room, message, restart_text="/me restarts"): - await room.send(restart_text, message.mid) - await self.part_room(room.roomname) - self.join_room(room.roomname, password=room.password) + 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. - # COMMAND PARSING + If you need to overwrite this function but want to keep the auto-join + functionality, make sure to await super().started(). + """ - @staticmethod - def parse_args(text): - """ - Use bash-style single- and double-quotes to include whitespace in arguments. - A backslash always escapes the next character. - Any non-escaped whitespace separates arguments. + 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) - Returns a list of arguments. - Deals with unclosed quotes and backslashes without crashing. - """ + # Registering commands - escape = False - quote = None - args = [] - arg = "" + def register(self, command: Command) -> None: + """ + Register a Command (from the yaboli.command submodule). - 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 + Usually, you don't have to call this function yourself. + """ - #if escape or quote: - #return None # syntax error + self._commands.append(command) - if len(arg) > 0: - args.append(arg) + 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. - return args + name - the name of the command (If you want your command to be !hello, + the name is "hello".) - @staticmethod - def parse_flags(arglist): - flags = "" - args = [] - kwargs = {} + cmdfunc - the function that is called with the Room, LiveMessage and + ArgumentData when the bot encounters a matching command - 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) + 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.) + """ - return flags, args, kwargs + command = GeneralCommand(name, cmdfunc, args) + self.register(command) - @staticmethod - def _parse_command(content, specific=None): - if specific is not None: - match = SPECIFIC_RE.fullmatch(content) - if match and similar(match.group(2), specific): - return match.group(1), match.group(3) - else: - match = GENERAL_RE.fullmatch(content) - if match: - return match.group(1), match.group(2) + 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/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 0258ae3..fcc27fe 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -2,208 +2,570 @@ import asyncio import json import logging import socket +from typing import Any, Awaitable, Callable, Dict, Optional + import websockets +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_callback, disconnect_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10): - self.url = url - self.packet_callback = packet_callback - self.disconnect_callback = disconnect_callback - self.cookiejar = cookiejar - self.ping_timeout = ping_timeout # how long to wait for websocket ping reply - self.ping_delay = ping_delay # how long to wait between pings - self.reconnect_attempts = reconnect_attempts + """ + The Connection handles the lower-level stuff required when connecting to + euphoria, such as: - self._ws = None - self._pid = 0 # successive packet ids - #self._spawned_tasks = set() - self._pending_responses = {} + - 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) - self._stopped = False - self._pingtask = None - self._runtask = asyncio.ensure_future(self._run()) - # ... aaand the connection is started. + It doesn't respond to any events other than the ping-event and is otherwise + "dumb". - async def send(self, ptype, data=None, await_response=True): - if not self._ws: - raise ConnectionClosed - #raise asyncio.CancelledError - pid = str(self._new_pid()) - packet = { - "type": ptype, - "id": pid - } - if data: - packet["data"] = data - if await_response: - wait_for = self._wait_for_response(pid) + Life cycle of a Connection: - logging.debug(f"Currently used websocket at self._ws: {self._ws}") - await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size + 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. - if await_response: - await wait_for - return wait_for.result() - async def stop(self): - """ - Close websocket connection and wait for running task to stop. + IN PHASE 1, parameters such as the url the Connection should connect to are + set. Usually, event callbacks are also registered in this phase. - No connection function are to be called after calling stop(). - This means that stop() can only be called once. - """ - self._stopped = True - await self.reconnect() # _run() does the cleaning up now. - await self._runtask + IN PHASE 2, the Connection attempts to connect to the url set in phase 1. + If successfully connected, it fires a "connected" event. - async def reconnect(self): - """ - Reconnect to the url. - """ - if self._ws: - await self._ws.close() + IN PHASE 3, the Connection listenes for packets from the server and fires + the corresponding events. Packets can be sent using the Connection. - async def _connect(self, tries): - """ - Attempt to connect to a room. - If the Connection is already connected, it attempts to reconnect. + 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. - Returns True on success, False on failure. - If tries is None, connect retries infinitely. - The delay between connection attempts doubles every attempt (starts with 1s). - """ + 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. - # Assumes _disconnect() has already been called in _run() - delay = 1 # seconds - while True: - try: - if self.cookiejar: - cookies = [("Cookie", cookie) for cookie in self.cookiejar.sniff()] - self._ws = await websockets.connect(self.url, max_size=None, extra_headers=cookies) - else: - self._ws = await websockets.connect(self.url, max_size=None) - except (websockets.InvalidHandshake, socket.gaierror): # not websockets.InvalidURI - self._ws = None - if tries is not None: - tries -= 1 - if tries <= 0: - return False + Events: - await asyncio.sleep(delay) - delay *= 2 - else: - if self.cookiejar: - for set_cookie in self._ws.response_headers.get_all("Set-Cookie"): - self.cookiejar.bake(set_cookie) - self.cookiejar.save() + - "connected" : No arguments + - "reconnecting" : No arguments + - "reconnected" : No arguments + - "disconnecting" : No arguments + - "": the packet, parsed as JSON - self._pingtask = asyncio.ensure_future(self._ping()) + 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. - return True + Examples for the last category of events include "message-event", + "part-event" and "ping". + """ - async def _disconnect(self): - """ - Disconnect and clean up all "residue", such as: - - close existing websocket connection - - cancel all pending response futures with a ConnectionClosed exception - - reset package ID counter - - make sure the ping task has finished - """ + # Timeout for waiting for the ws connection to be established + CONNECT_TIMEOUT = 10 # seconds - asyncio.ensure_future(self.disconnect_callback()) + # Maximum duration between euphoria's ping messages. Euphoria usually sends + # ping messages every 20 to 30 seconds. + PING_TIMEOUT = 40 # seconds - # stop ping task - if self._pingtask: - self._pingtask.cancel() - await self._pingtask - self._pingtask = None + # The delay between reconnect attempts. + RECONNECT_DELAY = 40 # seconds - if self._ws: - await self._ws.close() - self._ws = None + # States the Connection may be in + _NOT_RUNNING = "not running" + _CONNECTING = "connecting" + _RUNNING = "running" + _RECONNECTING = "reconnecting" + _DISCONNECTING = "disconnecting" - self._pid = 0 + # Initialising - # clean up pending response futures - for _, future in self._pending_responses.items(): - logger.debug(f"Cancelling future with ConnectionClosed: {future}") - future.set_exception(ConnectionClosed("No server response")) - self._pending_responses = {} + def __init__(self, url: str, cookie_file: Optional[str] = None) -> None: + self._url = url + self._cookie_jar = CookieJar(cookie_file) - async def _run(self): - """ - Listen for packets and deal with them accordingly. - """ + self._events = Events() + self._packet_id = 0 - while not self._stopped: - await self._connect(self.reconnect_attempts) + # 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() - try: - while True: - await self._handle_next_message() - except websockets.ConnectionClosed: - pass - finally: - await self._disconnect() # disconnect and clean up + self._event_loop: Optional[asyncio.Task[None]] = None - async def _ping(self): - """ - Periodically ping the server to detect a timeout. - """ + # 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 - try: - while True: - logger.debug("Pinging...") - wait_for_reply = await self._ws.ping() - await asyncio.wait_for(wait_for_reply, self.ping_timeout) - logger.debug("Pinged!") - await asyncio.sleep(self.ping_delay) - except asyncio.TimeoutError: - logger.warning("Ping timed out.") - await self.reconnect() - except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): - pass + self.register_event("ping-event", self._ping_pong) - def _new_pid(self): - self._pid += 1 - return self._pid + def register_event(self, + event: str, + callback: Callable[..., Awaitable[None]] + ) -> None: + """ + Register an event callback. - async def _handle_next_message(self): - response = await self._ws.recv() - packet = json.loads(response) + For an overview of the possible events, see the Connection docstring. + """ - ptype = packet.get("type") - data = packet.get("data", None) - error = packet.get("error", None) - if packet.get("throttled", False): - throttled = packet.get("throttled_reason") - else: - throttled = None + self._events.register(event, callback) - # Deal with pending responses - pid = packet.get("id", None) - future = self._pending_responses.pop(pid, None) - if future: - future.set_result((ptype, data, error, throttled)) + # Connecting and disconnecting - # Pass packet onto room - asyncio.ensure_future(self.packet_callback(ptype, data, error, throttled)) + async def _disconnect(self) -> None: + """ + Disconnect _ws and clean up _ws, _awaiting_replies and _ping_check. - def _wait_for_response(self, pid): - future = asyncio.Future() - self._pending_responses[pid] = future - return future + 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/cookiejar.py b/yaboli/cookiejar.py index ac4f3bf..833dbcb 100644 --- a/yaboli/cookiejar.py +++ b/yaboli/cookiejar.py @@ -1,73 +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. - """ + """ + Keeps your cookies in a file. - def __init__(self, filename=None): - self._filename = filename - self._cookies = cookies.SimpleCookie() + CookieJar doesn't attempt to discard old cookies, but that doesn't appear + to be necessary for keeping euphoria session cookies. + """ - if not self._filename: - logger.info("Could not load cookies, no filename given.") - return + def __init__(self, filename: Optional[str] = None) -> None: + self._filename = filename + self._cookies = cookies.SimpleCookie() - with contextlib.suppress(FileNotFoundError): - with open(self._filename, "r") as f: - for line in f: - self._cookies.load(line) + if not self._filename: + logger.warning("Could not load cookies, no filename given.") + return - def sniff(self): - """ - Returns a list of Cookie headers containing all current cookies. - """ + 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) - return [morsel.OutputString(attrs=[]) for morsel in self._cookies.values()] + def get_cookies(self) -> List[str]: + return [morsel.OutputString(attrs=[]) + for morsel in self._cookies.values()] - def bake(self, cookie_string): - """ - Parse cookie and add it to the jar. - Does not automatically save to the cookie file. + 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". + """ - Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; HttpOnly; Secure" - """ + return [("Cookie", cookie) for cookie in self.get_cookies()] - logger.debug(f"Baking cookie: {cookie_string!r}") + def add_cookie(self, cookie: str) -> None: + """ + Parse cookie and add it to the jar. - self._cookies.load(cookie_string) + Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; + HttpOnly; Secure" + """ - def save(self): - """ - Saves all current cookies to the cookie jar file. - """ + logger.debug(f"Adding cookie {cookie!r}") + self._cookies.load(cookie) - if not self._filename: - logger.info("Could not save cookies, no filename given.") - return + def save(self) -> None: + """ + Saves all current cookies to the cookie jar file. + """ - logger.debug(f"Saving cookies to {self._filename!r}") + if not self._filename: + logger.warning("Could not save cookies, no filename given.") + return - with open(self._filename, "w") as f: - for morsel in self._cookies.values(): - cookie_string = morsel.OutputString() - #f.write(f"{cookie_string}\n") - f.write(cookie_string) - f.write("\n") + logger.info(f"Saving cookies to {self._filename!r}") - def monster(self): - """ - Removes all cookies from the cookie jar. - Does not automatically save to the cookie file. - """ + with open(self._filename, "w") as f: + for morsel in self._cookies.values(): + cookie_string = morsel.OutputString() + f.write(f"{cookie_string}\n") - logger.debug("OMNOMNOM, cookies are all gone!") + def clear(self) -> None: + """ + Removes all cookies from the cookie jar. + """ - self._cookies = cookies.SimpleCookie() + logger.debug("OMNOMNOM, cookies are all gone!") + self._cookies = cookies.SimpleCookie() diff --git a/yaboli/database.py b/yaboli/database.py index 1d6e359..84af548 100644 --- a/yaboli/database.py +++ b/yaboli/database.py @@ -1,87 +1,40 @@ import asyncio -from functools import wraps +import logging import sqlite3 -import threading +from typing import Any, Awaitable, Callable, TypeVar -__all__ = ["Database"] +from .util import asyncify +logger = logging.getLogger(__name__) +__all__ = ["Database", "operation"] -def shielded(afunc): - #@wraps(afunc) - async def wrapper(*args, **kwargs): - return await asyncio.shield(afunc(*args, **kwargs)) - return wrapper +T = TypeVar('T') -class PooledConnection: - def __init__(self, pool): - self._pool = pool - - self.connection = None - - async def open(self): - self.connection = await self._pool._request() - - async def close(self): - conn = self.connection - self.connection = None - await self._pool._return(conn) - - async def __aenter__(self): - await self.open() - return self - - async def __aexit__(self, exc_type, exc, tb): - await self.close() - -class Pool: - def __init__(self, filename, size=10): - self.filename = filename - self.size = size - - self._available_connections = asyncio.Queue() - - for i in range(size): - conn = sqlite3.connect(self.filename, check_same_thread=False) - self._available_connections.put_nowait(conn) - - def connection(self): - return PooledConnection(self) - - async def _request(self): - return await self._available_connections.get() - - async def _return(self, conn): - await self._available_connections.put(conn) +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, filename, pool_size=10, event_loop=None): - self._filename = filename - self._pool = Pool(filename, size=pool_size) - self._loop = event_loop or asyncio.get_event_loop() - - def operation(func): - @wraps(func) - @shielded - async def wrapper(self, *args, **kwargs): - async with self._pool.connection() as conn: - return await self._run_in_thread(func, conn.connection, *args, **kwargs) - return wrapper - - @staticmethod - def _target_function(loop, future, func, *args, **kwargs): - result = None - try: - result = func(*args, **kwargs) - finally: - loop.call_soon_threadsafe(future.set_result, result) - - async def _run_in_thread(self, func, *args, **kwargs): - finished = asyncio.Future() - target_args = (self._loop, finished, func, *args) - - thread = threading.Thread(target=self._target_function, args=target_args, kwargs=kwargs) - thread.start() - - await finished - return finished.result() + 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 index f9cce45..034aaad 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -1,13 +1,67 @@ -__all__ = ["ConnectionClosed"] +__all__ = [ + "EuphException", + # Connection exceptions + "IncorrectStateException", + "ConnectionClosedException", + # Joining a room + "JoinException", + "CouldNotConnectException", + "CouldNotAuthenticateException", + # Doing stuff in a room + "RoomNotConnectedException", + "EuphError", +] -class ConnectionClosed(Exception): - pass +class EuphException(Exception): + pass -class RoomException(Exception): - pass +# Connection exceptions -class AuthenticationRequired(RoomException): - pass +class IncorrectStateException(EuphException): + """ + A Connection function was called while the Connection was in the incorrect + state. + """ + pass -class RoomClosed(RoomException): - 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 cf83e00..d1304ee 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,352 +1,562 @@ import asyncio import logging -import time +from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar -from .connection import * +from .connection import Connection +from .events import Events from .exceptions import * -from .utils import * - +from .message import LiveMessage +from .session import Account, LiveSession, LiveSessionListing +from .util import atmention logger = logging.getLogger(__name__) -__all__ = ["Room", "Inhabitant"] +__all__ = ["Room"] + +T = TypeVar("T") class Room: - """ - TODO - """ - - CONNECTED = 1 - DISCONNECTED = 2 - CLOSED = 3 - - def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None): - # TODO: Connect to room etc. - # TODO: Deal with room/connection states of: - # disconnected connecting, fast-forwarding, connected - - # Room info (all fields readonly!) - self.target_nick = nick - self.roomname = roomname - self.password = password - self.human = human - - self.session = None - self.account = None - self.listing = Listing() - - self.start_time = time.time() - - self.account_has_access = None - self.account_email_verified = None - self.room_is_private = None - self.version = None # the version of the code being run and served by the server - self.pm_with_nick = None - self.pm_with_user_id = None - - self._inhabitant = inhabitant - self._status = Room.DISCONNECTED - self._connected_future = asyncio.Future() - - # TODO: Allow for all parameters of Connection() to be specified in Room(). - self._connection = Connection( - self.format_room_url(self.roomname, human=self.human), - self._receive_packet, - self._disconnected, - cookiejar - ) - - async def exit(self): - self._status = Room.CLOSED - await self._connection.stop() - -# ROOM COMMANDS -# These always return a response from the server. -# If the connection is lost while one of these commands is called, -# the command will retry once the bot has reconnected. - - async def get_message(self, mid): - if self._status == Room.CLOSED: - raise RoomClosed() - - ptype, data, error, throttled = await self._send_while_connected( - "get-message", - id=mid - ) - - return Message.from_dict(data) - - async def log(self, n, before_mid=None): - if self._status == Room.CLOSED: - raise RoomClosed() - - if before_mid: - ptype, data, error, throttled = await self._send_while_connected( - "log", - n=n, - before=before_mid - ) - else: - ptype, data, error, throttled = await self._send_while_connected( - "log", - n=n - ) - - return [Message.from_dict(d) for d in data.get("log")] - - async def nick(self, nick): - if self._status == Room.CLOSED: - raise RoomClosed() - - self.target_nick = nick - ptype, data, error, throttled = await self._send_while_connected( - "nick", - name=nick - ) - - sid = data.get("session_id") - uid = data.get("id") - from_nick = data.get("from") - to_nick = data.get("to") - - self.session.nick = to_nick - return sid, uid, from_nick, to_nick - - async def pm(self, uid): - if self._status == Room.CLOSED: - raise RoomClosed() - - ptype, data, error, throttled = await self._send_while_connected( - "pm-initiate", - user_id=uid - ) - - # Just ignoring non-authenticated errors - pm_id = data.get("pm_id") - to_nick = data.get("to_nick") - return pm_id, to_nick - - async def send(self, content, parent_mid=None): - if parent_mid: - ptype, data, error, throttled = await self._send_while_connected( - "send", - content=content, - parent=parent_mid - ) - else: - ptype, data, error, throttled = await self._send_while_connected( - "send", - content=content - ) - - return Message.from_dict(data) - - async def who(self): - ptype, data, error, throttled = await self._send_while_connected("who") - self.listing = Listing.from_dict(data.get("listing")) - -# COMMUNICATION WITH CONNECTION - - async def _disconnected(self): - # While disconnected, keep the last known session info, listing etc. - # All of this is instead reset when the hello/snapshot events are received. - self.status = Room.DISCONNECTED - self._connected_future = asyncio.Future() - - await self._inhabitant.disconnected(self) - - async def _receive_packet(self, ptype, data, error, throttled): - # Ignoring errors and throttling for now - functions = { - "bounce-event": self._event_bounce, - #"disconnect-event": self._event_disconnect, # Not important, can ignore - "hello-event": self._event_hello, - "join-event": self._event_join, - #"login-event": self._event_login, - #"logout-event": self._event_logout, - "network-event": self._event_network, - "nick-event": self._event_nick, - #"edit-message-event": self._event_edit_message, - "part-event": self._event_part, - "ping-event": self._event_ping, - "pm-initiate-event": self._event_pm_initiate, - "send-event": self._event_send, - "snapshot-event": self._event_snapshot, - } - - function = functions.get(ptype) - if function: - await function(data) - - async def _event_bounce(self, data): - if self.password is not None: - try: - data = {"type": passcode, "passcode": self.password} - response = await self._connection.send("auth", data=data) - rdata = response.get("data") - success = rdata.get("success") - if not success: - reason = rdata.get("reason") - raise AuthenticationRequired(f"Could not join &{self.roomname}: {reason}") - except ConnectionClosed: - pass - else: - raise AuthenticationRequired(f"&{self.roomname} is password locked but no password was given") - - async def _event_hello(self, data): - self.session = Session.from_dict(data.get("session")) - self.room_is_private = data.get("room_is_private") - self.version = data.get("version") - self.account = data.get("account", None) - self.account_has_access = data.get("account_has_access", None) - self.account_email_verified = data.get("account_email_verified", None) - - async def _event_join(self, data): - session = Session.from_dict(data) - self.listing.add(session) - await self._inhabitant.join(self, session) - - async def _event_network(self, data): - server_id = data.get("server_id") - server_era = data.get("server_era") - - sessions = self.listing.remove_combo(server_id, server_era) - for session in sessions: - await self._inhabitant.part(self, session) - - async def _event_nick(self, data): - sid = data.get("session_id") - uid = data.get("user_id") - from_nick = data.get("from") - to_nick = data.get("to") - - session = self.listing.by_sid(sid) - if session: - session.nick = to_nick - - await self._inhabitant.nick(self, sid, uid, from_nick, to_nick) - - async def _event_part(self, data): - session = Session.from_dict(data) - self.listing.remove(session.sid) - await self._inhabitant.part(self, session) - - async def _event_ping(self, data): - try: - new_data = {"time": data.get("time")} - await self._connection.send( "ping-reply", data=new_data, await_response=False) - except ConnectionClosed: - pass - - async def _event_pm_initiate(self, data): - from_uid = data.get("from") - from_nick = data.get("from_nick") - from_room = data.get("from_room") - pm_id = data.get("pm_id") - - await self._inhabitant.pm(self, from_uid, from_nick, from_room, pm_id) - - async def _event_send(self, data): - message = Message.from_dict(data) - - await self._inhabitant.send(self, message) - - # TODO: Figure out a way to bring fast-forwarding into this - - async def _event_snapshot(self, data): - # Update listing - self.listing = Listing() - sessions = [Session.from_dict(d) for d in data.get("listing")] - for session in sessions: - self.listing.add(session) - - # Update room info - self.pm_with_nick = data.get("pm_with_nick", None), - self.pm_with_user_id = data.get("pm_with_user_id", None) - self.session.nick = data.get("nick", None) - - # Make sure a room is not CONNECTED without a nick - if self.target_nick and self.target_nick != self.session.nick: - try: - _, nick_data, _, _ = await self._connection.send("nick", data={"name": self.target_nick}) - self.session.nick = nick_data.get("to") - except ConnectionClosed: - return # Aww, we've lost connection again - - # Now, we're finally connected again! - self.status = Room.CONNECTED - if not self._connected_future.done(): # Should never be done already, I think - self._connected_future.set_result(None) - - # Let's let the inhabitant know. - logger.debug("Letting inhabitant know") - log = [Message.from_dict(m) for m in data.get("log")] - await self._inhabitant.connected(self, log) - - # TODO: Figure out a way to bring fast-forwarding into this - # Should probably happen where this comment is - -# SOME USEFUL PUBLIC METHODS + """ + 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) - @staticmethod - def format_room_url(roomname, private=False, human=False): - if private: - roomname = f"pm:{roomname}" + # Send "snapshot" event + messages = [LiveMessage.from_data(self, msg_data) + for msg_data in data["log"]] + self._events.fire("snapshot", messages) - url = f"wss://euphoria.io/room/{roomname}/ws" + self._snapshot_received = True + await self._try_set_connected() - if human: - url = f"{url}?h=1" + async def _on_bounce_event(self, packet: Any) -> None: + data = packet["data"] - return url + # 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 - async def connected(self): - await self._connected_future + # If so, do we have a password? + if self._password is None: + self._set_connected_failed() + return -# REST OF THE IMPLEMENTATION + reply = await self._connection.send( + "auth", + {"type": "passcode", "passcode": self._password} + ) - async def _send_while_connected(self, *args, **kwargs): - while True: - if self._status == Room.CLOSED: - raise RoomClosed() + if not reply["data"]["success"]: + self._set_connected_failed() - try: - await self.connected() - return await self._connection.send(*args, data=kwargs) - except ConnectionClosed: - pass # just try again + 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. + """ -class Inhabitant: - """ - TODO - """ + if not await self._connection.connect(): + return False -# ROOM EVENTS -# These functions are called by the room when something happens. -# They're launched via asyncio.ensure_future(), so they don't block execution of the room. -# Just overwrite the events you need (make sure to keep the arguments the same though). + await self._connected.wait() + if not self._connected_successfully: + return False - async def disconnected(self, room): - pass + self._events.fire("connected") + return True - async def connected(self, room, log): - pass + async def disconnect(self) -> None: + """ + Disconnect from the room and stop the Room. - async def join(self, room, session): - pass + 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(). + """ - async def part(self, room, session): - pass + self._set_connected_reset() + await self._connection.disconnect() - async def nick(self, room, sid, uid, from_nick, to_nick): - pass + # Other events - async def send(self, room, message): - pass + async def _on_disconnect_event(self, packet: Any) -> None: + reason = packet["data"]["reason"] - async def fast_forward(self, room, message): - pass + if reason == "authentication changed": + await self._connection.reconnect() - async def pm(self, room, from_uid, from_nick, from_room, pm_id): - pass + 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 e810998..0000000 --- a/yaboli/utils.py +++ /dev/null @@ -1,203 +0,0 @@ -import asyncio -import logging -import time - -logger = logging.getLogger(__name__) -__all__ = [ - "parallel", - "mention", "mention_reduced", "similar", - "format_time", "format_time_delta", - "Session", "Listing", "Message", -] - - -# alias for parallel message sending -parallel = asyncio.ensure_future - -def mention(nick): - return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace()) - -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%hour - - if delta >= minute: - result += f"{delta//minute}m " - delta = delta%minute - - result += f"{delta}s" - - return result - -class Session: - def __init__(self, user_id, nick, server_id, server_era, session_id, is_staff=None, - is_manager=None, client_address=None, real_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 - - @property - def uid(self): - return self.user_id - - @uid.setter - def uid(self, new_uid): - self.user_id = new_uid - - @property - def sid(self): - return self.session_id - - @sid.setter - def sid(self, new_sid): - self.session_id = new_sid - - @classmethod - def from_dict(cls, d): - return cls( - d.get("id"), - d.get("name"), - d.get("server_id"), - d.get("server_era"), - d.get("session_id"), - d.get("is_staff", None), - d.get("is_manager", None), - d.get("client_address", None), - d.get("real_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): - removed = [ses for ses in self._sessions.items() - if ses.server_id == server_id and ses.server_era == server_era] - - self._sessions = {i: ses for i, ses in self._sessions.items() - if ses.server_id != server_id and ses.server_era != server_era} - - return removed - - def by_sid(self, session_id): - return self._sessions.get(session_id); - - def by_uid(self, user_id): - return [ses for ses in self._sessions if ses.user_id == user_id] - - def get(self, types=["agent", "account", "bot"], lurker=None): - sessions = [] - for uid, ses in self._sessions.items(): - if ses.client_type not in types: - continue - - is_lurker = not ses.nick # "" or None - if lurker is None or lurker == is_lurker: - sessions.append(ses) - - return sessions - - @classmethod - def from_dict(cls, d): - listing = cls() - for session in d: - listing.add(Session.from_dict(session)) - return listing - - #def get_people(self): - #return self.get(types=["agent", "account"]) - - #def get_accounts(self): - #return self.get(types=["account"]) - - #def get_agents(self): - #return self.get(types=["agent"]) - - #def get_bots(self): - #return self.get(types=["bot"]) - -class Message(): - def __init__(self, message_id, time, sender, content, parent=None, previous_edit_id=None, - encryption_key=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 - - @property - def mid(self): - return self.message_id - - @mid.setter - def mid(self, new_mid): - self.message_id = new_mid - - @classmethod - def from_dict(cls, d): - return cls( - d.get("id"), - d.get("time"), - Session.from_dict(d.get("sender")), - d.get("content"), - d.get("parent", None), - d.get("previous_edit_id", None), - d.get("encryption_key", None), - d.get("edited", None), - d.get("deleted", None), - d.get("truncated", None) - )