diff --git a/.gitignore b/.gitignore index 7ce48d0..66427ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ -__pycache__/ -*.egg-info/ -/.mypy_cache/ -/.venv/ +yaboli/__pycache__/ +*.db diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e0f1801..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,60 +0,0 @@ -# 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 new file mode 100644 index 0000000..0a60d0b --- /dev/null +++ b/ExampleBot.py @@ -0,0 +1,21 @@ +import asyncio +import yaboli + +class ExampleBot(yaboli.Bot): + async def send(self, room, message): + ping = "ExamplePong!" + short_help = "Example bot for the yaboli bot library" + long_help = "I'm an example bot for the yaboli bot library, which can be found at https://github.com/Garmelon/yaboli" + + await self.botrulez_ping_general(room, message, ping_text=ping) + await self.botrulez_ping_specific(room, message, ping_text=ping) + await self.botrulez_help_general(room, message, help_text=short_help) + await self.botrulez_help_specific(room, message, help_text=long_help) + await self.botrulez_uptime(room, message) + await self.botrulez_kill(room, message) + await self.botrulez_restart(room, message) + + forward = send # should work without modifications for most bots + +bot = ExampleBot("ExampleBot", "examplebot_cookies", rooms=["test", "welcome"]) +asyncio.get_event_loop().run_forever() diff --git a/LICENSE b/LICENSE deleted file mode 100644 index f2fd14f..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 2cd4eb1..0000000 --- a/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# 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 deleted file mode 100644 index cf6722d..0000000 --- a/docs/bot_setup.md +++ /dev/null @@ -1,13 +0,0 @@ -# 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 deleted file mode 100644 index 9f4835f..0000000 --- a/docs/index.md +++ /dev/null @@ -1,89 +0,0 @@ -# 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 deleted file mode 100644 index da78a19..0000000 --- a/examples/echo/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 940e8e4..0000000 --- a/examples/echo/bot.conf.default +++ /dev/null @@ -1,6 +0,0 @@ -[general] -nick = EchoBot -cookie_file = bot.cookie - -[rooms] -test diff --git a/examples/echo/echobot.py b/examples/echo/echobot.py deleted file mode 100644 index e404f3c..0000000 --- a/examples/echo/echobot.py +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index f69b963..0000000 --- a/examples/gitignore_with_venv +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index 6fd0e6a..0000000 --- a/mypy.ini +++ /dev/null @@ -1,4 +0,0 @@ -[mypy] -disallow_untyped_defs = True -disallow_incomplete_defs = True -no_implicit_optional = True diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 79ad530..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,30 +0,0 @@ -[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 97385cb..d91ce01 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,377 +1,176 @@ -import configparser -import datetime import logging -from typing import Callable, List, Optional +import re +import time + +from .cookiejar import * +from .room import * +from .utils import * -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", "BotConstructor"] - -class Bot(Client): - """ - A Bot is a Client that responds to commands and uses a config file to - automatically set its nick and join rooms. - - The config file is loaded as a ConfigParser by the run() or run_modulebot() - functions and has the following structure: - - A "general" section which contains: - - nick - the default nick of the bot (set to the empty string if you don't - want to set a nick) - - cookie_file (optional) - the file the cookie should be saved in - - A "rooms" section which contains a list of rooms that the bot should - automatically join. This section is optional if you overwrite started(). - The room list should have the format "roomname" or "roomname = password". - - A bot has the following attributes: - - ALIASES - list of alternate nicks the bot responds to (see - process_commands()) - - PING_REPLY - used by cmd_ping() - - HELP_GENERAL - used by cmd_help_general() - - HELP_SPECIFIC - used by cmd_help_specific() - - KILL_REPLY - used by cmd_kill() - - RESTART_REPLY - used by cmd_restart() - - GENERAL_SECTION - the name of the "general" section in the config file - (see above) (default: "general") - - ROOMS_SECTION - the name of the "rooms" section in the config file (see - above) (default: "rooms") - """ - - ALIASES: List[str] = [] - - PING_REPLY: str = "Pong!" - HELP_GENERAL: Optional[str] = None - HELP_SPECIFIC: Optional[List[str]] = None - KILL_REPLY: Optional[str] = "/me dies" - RESTART_REPLY: Optional[str] = "/me restarts" - - GENERAL_SECTION = "general" - ROOMS_SECTION = "rooms" - - def __init__(self, - config: configparser.ConfigParser, - config_file: str, - ) -> None: - self.config = config - self.config_file = config_file - - nick = self.config[self.GENERAL_SECTION].get("nick") - if nick is None: - logger.warn(("'nick' not set in config file. Defaulting to empty" - " nick")) - nick = "" - - cookie_file = self.config[self.GENERAL_SECTION].get("cookie_file") - if cookie_file is None: - logger.warn(("'cookie_file' not set in config file. Using no cookie" - " file.")) - - super().__init__(nick, cookie_file=cookie_file) - - self._commands: List[Command] = [] - - self.start_time = datetime.datetime.now() - - def save_config(self) -> None: - """ - Save the current state of self.config to the file passed in __init__ as - the config_file parameter. - - Usually, this is the file that self.config was loaded from (if you use - run or run_modulebot). - """ - - with open(self.config_file, "w") as f: - self.config.write(f) - - async def started(self) -> None: - """ - This Client function is overwritten in order to join all the rooms - listed in the "rooms" section of self.config. - - If you need to overwrite this function but want to keep the auto-join - functionality, make sure to await super().started(). - """ - - for room, password in self.config[self.ROOMS_SECTION].items(): - if password is None: - await self.join(room) - else: - await self.join(room, password=password) - - # Registering commands - - def register(self, command: Command) -> None: - """ - Register a Command (from the yaboli.command submodule). - - Usually, you don't have to call this function yourself. - """ - - self._commands.append(command) - - def register_general(self, - name: str, - cmdfunc: GeneralCommandFunction, - args: bool = True - ) -> None: - """ - Register a function as general bot command (i. e. no @mention of the - bot nick after the !command). This function will be called by - process_commands() when the bot encounters a matching command. - - name - the name of the command (If you want your command to be !hello, - the name is "hello".) - - cmdfunc - the function that is called with the Room, LiveMessage and - ArgumentData when the bot encounters a matching command - - args - whether the command may have arguments (If set to False, the - ArgumentData's has_args() function must also return False for the - command function to be called. If set to True, all ArgumentData is - valid.) - """ - - command = GeneralCommand(name, cmdfunc, args) - self.register(command) - - def register_specific(self, - name: str, - cmdfunc: SpecificCommandFunction, - args: bool = True - ) -> None: - """ - Register a function as specific bot command (i. e. @mention of the bot - nick after the !command is required). This function will be called by - process_commands() when the bot encounters a matching command. - - name - the name of the command (see register_general() for an - explanation) - - cmdfunc - the function that is called with the Room, LiveMessage and - SpecificArgumentData when the bot encounters a matching command - - args - whether the command may have arguments (see register_general() - for an explanation) - """ - - command = SpecificCommand(name, cmdfunc, args) - self.register(command) - - # Processing commands - - async def process_commands(self, - room: Room, - message: LiveMessage, - aliases: List[str] = [] - ) -> None: - """ - If the message contains a command, call all matching command functions - that were previously registered. - - This function is usually called by the overwritten on_send() function. - """ - - nicks = [room.session.nick] + aliases - data = CommandData.from_string(message.content) - - if data is not None: - logger.debug(f"Processing command from {message.content!r}") - for command in self._commands: - await command.run(room, message, nicks, data) - - async def on_send(self, room: Room, message: LiveMessage) -> None: - """ - This Client function is overwritten in order to automatically call - process_commands() with self.ALIASES. - - If you need to overwrite this function, make sure to await - process_commands() with self.ALIASES somewhere in your function, or - await super().on_send(). - """ - - await self.process_commands(room, message, aliases=self.ALIASES) - - # Help util - - def format_help(self, room: Room, lines: List[str]) -> str: - """ - Format a list of strings into a string, replacing certain placeholders - with the actual values. - - This function uses the str.format() function to replace the following: - - - {nick} - the bot's current nick - - {mention} - the bot's current nick, run through mention() - - {atmention} - the bot's current nick, run through atmention() - """ - - text = "\n".join(lines) - params = { - "nick": room.session.nick, - "mention": room.session.mention, - "atmention": room.session.atmention, - } - return text.format(**params) - - # Botrulez - - def register_botrulez(self, - ping: bool = True, - help_: bool = True, - uptime: bool = True, - kill: bool = False, - restart: bool = False, - ) -> None: - """ - Register the commands necessary for the bot to conform to the botrulez - (https://github.com/jedevc/botrulez). Also includes a few optional - botrulez commands that are disabled by default. - - - ping - register general and specific cmd_ping() - - help_ - register cmd_help_general() and cmd_help_specific() - - uptime - register specific cmd_uptime - - kill - register specific cmd_kill (disabled by default) - - uptime - register specific cmd_uptime (disabled by default) - - All commands are registered with args=False. - - If you want to implement your own versions of these commands, it is - recommended that you set the respective argument to False in your call - to register_botrulez(), overwrite the existing command functions or - create your own, and then register them manually. - - For help, that might look something like this, if you've written a - custom specific help that takes extra arguments but are using the - botrulez general help: - - self.register_botrulez(help_=False) - self.register_general("help", self.cmd_help_general, args=False) - self.register_specific("help", self.cmd_help_custom) - - In case you're asking, the help_ parameter has an underscore at the end - so it doesn't overlap the help() function. - """ - - if ping: - self.register_general("ping", self.cmd_ping, args=False) - self.register_specific("ping", self.cmd_ping, args=False) - - if help_: - if self.HELP_GENERAL is None and self.HELP_SPECIFIC is None: - logger.warn(("HELP_GENERAL and HELP_SPECIFIC are None, but the" - " help command is enabled")) - self.register_general("help", self.cmd_help_general, args=False) - self.register_specific("help", self.cmd_help_specific, args=False) - - if uptime: - self.register_specific("uptime", self.cmd_uptime, args=False) - - if kill: - self.register_specific("kill", self.cmd_kill, args=False) - - if restart: - self.register_specific("restart", self.cmd_restart, args=False) - - async def cmd_ping(self, - room: Room, - message: LiveMessage, - args: ArgumentData - ) -> None: - """ - Reply with self.PING_REPLY. - """ - - await message.reply(self.PING_REPLY) - - async def cmd_help_general(self, - room: Room, - message: LiveMessage, - args: ArgumentData - ) -> None: - """ - Reply with self.HELP_GENERAL, if it is not None. Uses format_help(). - """ - - if self.HELP_GENERAL is not None: - await message.reply(self.format_help(room, [self.HELP_GENERAL])) - - async def cmd_help_specific(self, - room: Room, - message: LiveMessage, - args: SpecificArgumentData - ) -> None: - """ - Reply with self.HELP_SPECIFIC, if it is not None. Uses format_help(). - """ - - if self.HELP_SPECIFIC is not None: - await message.reply(self.format_help(room, self.HELP_SPECIFIC)) - - async def cmd_uptime(self, - room: Room, - message: LiveMessage, - args: SpecificArgumentData - ) -> None: - """ - Reply with the bot's uptime in the format specified by the botrulez. - - This uses the time that the Bot was first started, not the time the - respective Room was created. A !restart (see register_botrulez()) will - reset the bot uptime, but leaving and re-joining a room or losing - connection won't. - """ - - time = format_time(self.start_time) - delta = format_delta(datetime.datetime.now() - self.start_time) - text = f"/me has been up since {time} UTC ({delta})" - await message.reply(text) - - async def cmd_kill(self, - room: Room, - message: LiveMessage, - args: SpecificArgumentData - ) -> None: - """ - Remove the bot from this room. - - If self.KILL_REPLY is not None, replies with that before leaving the - room. - """ - - logger.info(f"Killed in &{room.name} by {message.sender.atmention}") - - if self.KILL_REPLY is not None: - await message.reply(self.KILL_REPLY) - - await self.part(room) - - async def cmd_restart(self, - room: Room, - message: LiveMessage, - args: SpecificArgumentData - ) -> None: - """ - Restart the whole Bot. - - This is done by stopping the Bot, since the run() or run_modulebot() - functions start the Bot in a while True loop. - - If self.RESTART_REPLY is not None, replies with that before restarting. - """ - - logger.info(f"Restarted in &{room.name} by {message.sender.atmention}") - - if self.RESTART_REPLY is not None: - await message.reply(self.RESTART_REPLY) - - await self.stop() - -BotConstructor = Callable[[configparser.ConfigParser, str], Bot] +__all__ = ["Bot", "command"] + + +# Some command stuff + +SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)") +GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)") + +def command(commandname, specific=True, noargs=False): + def decorator(func): + async def wrapper(self, room, message, *args, **kwargs): + 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 + + +# And now comes the real bot... + +class Bot(Inhabitant): + def __init__(self, nick, cookiefile=None): + self.target_nick = nick + self.rooms = {} + self.cookiejar = CookieJar(cookiefile) + + # ROOM MANAGEMENT + + def join_room(self, roomname, password=None): + if roomname in self.rooms: + return + + self.rooms[roomname] = Room(self, roomname, self.target_nick, password=password, cookiejar=self.cookiejar) + + async def part_room(self, roomname): + room = self.rooms.pop(roomname, None) + if room: + await room.exit() + + # BOTRULEZ + + @command("ping", specific=False, noargs=True) + async def botrulez_ping_general(self, room, message, ping_text="Pong!"): + await room.send(ping_text, message.mid) + + @command("ping", specific=True, noargs=True) + async def botrulez_ping_specific(self, room, message, ping_text="Pong!"): + await room.send(ping_text, message.mid) + + @command("help", specific=False, noargs=True) + async def botrulez_help_general(self, room, message, help_text="Placeholder help text"): + await room.send(help_text, message.mid) + + @command("help", specific=True, noargs=True) + async def botrulez_help_specific(self, room, message, help_text="Placeholder help text"): + await room.send(help_text, message.mid) + + @command("uptime", specific=True, noargs=True) + async def botrulez_uptime(self, room, message): + now = time.time() + startformat = format_time(room.start_time) + deltaformat = format_time_delta(now - room.start_time) + text = f"/me has been up since {startformat} ({deltaformat})" + await room.send(text, message.mid) + + @command("kill", specific=True, noargs=True) + async def botrulez_kill(self, room, message, kill_text="/me dies"): + await room.send(kill_text, message.mid) + await self.part_room(room.roomname) + + @command("restart", specific=True, noargs=True) + async def botrulez_restart(self, room, message, restart_text="/me restarts"): + await room.send(restart_text, message.mid) + await self.part_room(room.roomname) + self.join_room(room.roomname, password=room.password) + + # COMMAND PARSING + + @staticmethod + def parse_args(text): + """ + Use bash-style single- and double-quotes to include whitespace in arguments. + A backslash always escapes the next character. + Any non-escaped whitespace separates arguments. + + Returns a list of arguments. + Deals with unclosed quotes and backslashes without crashing. + """ + + escape = False + quote = None + args = [] + arg = "" + + for character in text: + if escape: + arg += character + escape = False + elif character == "\\": + escape = True + elif quote: + if character == quote: + quote = None + else: + arg += character + elif character in "'\"": + quote = character + elif character.isspace(): + if len(arg) > 0: + args.append(arg) + arg = "" + else: + arg += character + + #if escape or quote: + #return None # syntax error + + if len(arg) > 0: + args.append(arg) + + return args + + @staticmethod + def parse_flags(arglist): + flags = "" + args = [] + kwargs = {} + + for arg in arglist: + # kwargs (--abc, --foo=bar) + if arg[:2] == "--": + arg = arg[2:] + if "=" in arg: + s = arg.split("=", maxsplit=1) + kwargs[s[0]] = s[1] + else: + kwargs[arg] = None + # flags (-x, -rw) + elif arg[:1] == "-": + arg = arg[1:] + flags += arg + # args (normal arguments) + else: + args.append(arg) + + return flags, args, kwargs + + @staticmethod + def _parse_command(content, specific=None): + if specific 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) diff --git a/yaboli/client.py b/yaboli/client.py deleted file mode 100644 index 75806fb..0000000 --- a/yaboli/client.py +++ /dev/null @@ -1,171 +0,0 @@ -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 deleted file mode 100644 index 08ac3f7..0000000 --- a/yaboli/command.py +++ /dev/null @@ -1,384 +0,0 @@ -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 fcc27fe..0258ae3 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -2,570 +2,208 @@ 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__) +logger = logging.getLogger(__name__) __all__ = ["Connection"] -# This class could probably be cleaned up by introducing one or two well-placed -# Locks – something for the next rewrite :P class Connection: - """ - The Connection handles the lower-level stuff required when connecting to - euphoria, such as: - - - Creating a websocket connection - - Encoding and decoding packets (json) - - Waiting for the server's asynchronous replies to packets - - Keeping the connection alive (ping, ping-reply packets) - - Reconnecting (timeout while connecting, no pings received in some time) - - It doesn't respond to any events other than the ping-event and is otherwise - "dumb". - - - - Life cycle of a Connection: - - 1. create connection and register event callbacks - 2. call connect() - 3. send and receive packets, reconnecting automatically when connection is - lost - 4. call disconnect(), then go to 2. - - - IN PHASE 1, parameters such as the url the Connection should connect to are - set. Usually, event callbacks are also registered in this phase. - - - IN PHASE 2, the Connection attempts to connect to the url set in phase 1. - If successfully connected, it fires a "connected" event. - - - IN PHASE 3, the Connection listenes for packets from the server and fires - the corresponding events. Packets can be sent using the Connection. - - If the Connection has to reconnect for some reason, it first fires a - "reconnecting" event. Then it tries to reconnect until it has established a - connection to euphoria again. After the connection is reestablished, it - fires a "reconnected" event. - - - IN PHASE 4, the Connection fires a "disconnecting" event and then closes - the connection to euphoria. This event is the last event that is fired - until connect() is called again. - - - - Events: - - - "connected" : No arguments - - "reconnecting" : No arguments - - "reconnected" : No arguments - - "disconnecting" : No arguments - - "": the packet, parsed as JSON - - Events ending with "-ing" ("reconnecting", "disconnecting") are fired at - the beginning of the process they represent. Events ending with "-ed" - ("connected", "reconnected") are fired after the process they represent has - finished. - - Examples for the last category of events include "message-event", - "part-event" and "ping". - """ - - # Timeout for waiting for the ws connection to be established - CONNECT_TIMEOUT = 10 # seconds - - # Maximum duration between euphoria's ping messages. Euphoria usually sends - # ping messages every 20 to 30 seconds. - PING_TIMEOUT = 40 # seconds - - # The delay between reconnect attempts. - RECONNECT_DELAY = 40 # seconds - - # States the Connection may be in - _NOT_RUNNING = "not running" - _CONNECTING = "connecting" - _RUNNING = "running" - _RECONNECTING = "reconnecting" - _DISCONNECTING = "disconnecting" - - # Initialising - - def __init__(self, url: str, cookie_file: Optional[str] = None) -> None: - self._url = url - self._cookie_jar = CookieJar(cookie_file) - - self._events = Events() - self._packet_id = 0 - - # This is the current status of the connection. It can be set to one of - # _NOT_RUNNING, _CONNECTING, _RUNNING, _RECONNECTING, or - # _DISCONNECTING. - # - # Always be careful to set any state-dependent variables. - self._state = self._NOT_RUNNING - self._connected_condition = asyncio.Condition() - self._disconnected_condition = asyncio.Condition() - - self._event_loop: Optional[asyncio.Task[None]] = None - - # These must always be (re)set together. If one of them is None, all - # must be None. - self._ws = None - self._awaiting_replies: Optional[Dict[str, asyncio.Future[Any]]] = None - self._ping_check: Optional[asyncio.Task[None]] = None - - self.register_event("ping-event", self._ping_pong) - - def register_event(self, - event: str, - callback: Callable[..., Awaitable[None]] - ) -> None: - """ - Register an event callback. - - For an overview of the possible events, see the Connection docstring. - """ - - self._events.register(event, callback) - - # Connecting and disconnecting - - async def _disconnect(self) -> None: - """ - Disconnect _ws and clean up _ws, _awaiting_replies and _ping_check. - - Important: The caller must ensure that this function is called in valid - circumstances and not called twice at the same time. _disconnect() does - not check or manipulate _state. - """ - - if self._ws is not None: - logger.debug("Closing ws connection") - await self._ws.close() - - # Checking self._ws again since during the above await, another - # disconnect call could have finished cleaning up. - if self._ws is None: - # This indicates that _ws, _awaiting_replies and _ping_check are - # cleaned up - logger.debug("Ws connection already cleaned up") - return - - logger.debug("Cancelling futures waiting for replies") - for future in self._awaiting_replies.values(): - future.set_exception(ConnectionClosedException()) - - logger.debug("Cancelling ping check task") - self._ping_check.cancel() - - logger.debug("Cleaning up variables") - self._ws = None - self._awaiting_replies = None - self._ping_check = None - - async def _connect(self) -> bool: - """ - Attempts once to create a ws connection. - - Important: The caller must ensure that this function is called in valid - circumstances and not called twice at the same time. _connect() does - not check or manipulate _state, nor does it perform cleanup on - _awaiting_replies or _ping_check. - """ - - try: - logger.debug(f"Creating ws connection to {self._url!r}") - ws = await asyncio.wait_for( - websockets.connect(self._url, - extra_headers=self._cookie_jar.get_cookies_as_headers()), - self.CONNECT_TIMEOUT - ) - logger.debug(f"Established ws connection to {self._url!r}") - - self._ws = ws - self._awaiting_replies = {} - logger.debug("Starting ping check") - self._ping_check = asyncio.create_task( - self._disconnect_in(self.PING_TIMEOUT)) - - # Put received cookies into cookie jar - for set_cookie in ws.response_headers.get_all("Set-Cookie"): - self._cookie_jar.add_cookie(set_cookie) - self._cookie_jar.save() - - return True - - except (websockets.InvalidHandshake, websockets.InvalidStatusCode, - OSError, asyncio.TimeoutError): - logger.debug("Connection failed") - return False - - async def _disconnect_in(self, delay: int) -> None: - await asyncio.sleep(delay) - logger.debug(f"Disconnect timeout of {delay}s elapsed, disconnecting...") - # Starting the _disconnect function in another task because otherwise, - # its own CancelledError would inhibit _disconnect() from completing - # the disconnect. - # - # We don't need to check the state because _disconnect_in only runs - # while the _state is _RUNNING. - asyncio.create_task(self._disconnect()) - - async def _reconnect(self) -> bool: - """ - This function should only be called from the event loop while the - _state is _RUNNING. - """ - - if self._state != self._RUNNING: - raise IncorrectStateException("This should never happen") - - logger.debug("Reconnecting...") - self._events.fire("reconnecting") - self._state = self._RECONNECTING - - await self._disconnect() - success = await self._connect() - - self._state = self._RUNNING - self._events.fire("reconnected") - - logger.debug("Sending connected notification") - async with self._connected_condition: - self._connected_condition.notify_all() - - logger.debug("Reconnected" if success else "Reconnection failed") - return success - - async def connect(self) -> bool: - """ - Attempt to create a connection to the Connection's url. - - Returns True if the Connection could connect to the url and is now - running. Returns False if the Connection could not connect to the url - and is not running. - - Exceptions: - - This function must be called while the connection is not running, - otherwise an IncorrectStateException will be thrown. To stop a - Connection, use disconnect(). - """ - - # Special exception message for _CONNECTING. - if self._state == self._CONNECTING: - raise IncorrectStateException(("connect() may not be called" - " multiple times.")) - - if self._state != self._NOT_RUNNING: - raise IncorrectStateException(("disconnect() must complete before" - " connect() may be called again.")) - - logger.debug("Connecting...") - - # Now we're sure we're in the _NOT_RUNNING state, we can set our state. - # Important: No await-ing has occurred between checking the state and - # setting it. - self._state = self._CONNECTING - - success = await self._connect() - - if success: - logger.debug("Starting event loop") - self._event_loop = asyncio.create_task(self._run()) - self._state = self._RUNNING - self._events.fire("connected") - else: - self._state = self._NOT_RUNNING - - logger.debug("Sending connected notification") - async with self._connected_condition: - self._connected_condition.notify_all() - - logger.debug("Connected" if success else "Connection failed") - return success - - async def disconnect(self) -> None: - """ - Close and stop the Connection, if it is currently (re-)connecting or - running. Does nothing if the Connection is not running. - - This function returns once the Connection has stopped running. - """ - - # Possible states left: _NOT_RUNNING, _CONNECTING, _RUNNING, - # _RECONNECTING, _DISCONNECTING - - # Waiting until the current connection attempt is finished. Using a - # while loop since the event loop might have started to reconnect again - # while the await is still waiting. - while self._state in [self._CONNECTING, self._RECONNECTING]: - # After _CONNECTING, the state can either be _NOT_RUNNING or - # _RUNNING. After _RECONNECTING, the state must be _RUNNING. - async with self._connected_condition: - await self._connected_condition.wait() - - # Possible states left: _NOT_RUNNING, _RUNNING, _DISCONNECTING - - if self._state == self._NOT_RUNNING: - # No need to do anything since we're already disconnected - logger.debug("Already disconnected") - return - - # Possible states left: _RUNNING, _DISCONNECTING - - if self._state == self._DISCONNECTING: - # Wait until the disconnecting currently going on is complete. This - # is to prevent the disconnect() function from ever returning - # without the disconnecting process being finished. - logger.debug("Already disconnecting, waiting for it to finish...") - async with self._disconnected_condition: - await self._disconnected_condition.wait() - - logger.debug("Disconnected, finished waiting") - return - - # Possible states left: _RUNNING - - # By principle of exclusion, the only state left is _RUNNING. Doing an - # explicit check though, just to make sure. - if self._state != self._RUNNING: - raise IncorrectStateException("This should never happen.") - - - logger.debug("Disconnecting...") - self._events.fire("disconnecting") - - # Now we're sure we're in the _RUNNING state, we can set our state. - # Important: No await-ing has occurred between checking the state and - # setting it. - self._state = self._DISCONNECTING - - await self._disconnect() - - # We know that _event_loop is not None, but this is to keep mypy happy. - logger.debug("Waiting for event loop") - if self._event_loop is not None: - await self._event_loop - self._event_loop = None - - self._state = self._NOT_RUNNING - - # Notify all other disconnect()s waiting - logger.debug("Sending disconnected notification") - async with self._disconnected_condition: - self._disconnected_condition.notify_all() - - logger.debug("Disconnected") - - async def reconnect(self) -> None: - """ - Forces the Connection to reconnect. - - This function may return before the reconnect process is finished. - - Exceptions: - - This function must be called while the connection is (re-)connecting or - running, otherwise an IncorrectStateException will be thrown. - """ - - if self._state in [self._CONNECTING, self._RECONNECTING]: - logger.debug("Already (re-)connecting, waiting for it to finish...") - async with self._connected_condition: - await self._connected_condition.wait() - - logger.debug("(Re-)connected, finished waiting") - return - - if self._state != self._RUNNING: - raise IncorrectStateException(("reconnect() may not be called while" - " the connection is not running.")) - - # Disconnecting via task because otherwise, the _connected_condition - # might fire before we start waiting for it. - # - # The event loop will reconenct after the ws connection has been - # disconnected. - logger.debug("Disconnecting and letting the event loop reconnect") - await self._disconnect() - - # Running - - async def _run(self) -> None: - """ - The main loop that runs during phase 3 - """ - - while True: - # The "Exiting event loop" checks are a bit ugly. They're in place - # so that the event loop exits on its own at predefined positions - # instead of randomly getting thrown a CancelledError. - # - # Now that I think about it, the whole function looks kinda ugly. - # Maybe one day (yeah, right), I'll clean this up. I want to get it - # working first though. - - if self._state != self._RUNNING: - logger.debug("Exiting event loop") - return - - if self._ws is not None: - try: - logger.debug("Receiving ws packets") - async for packet in self._ws: - logger.debug(f"Received packet {packet}") - packet_data = json.loads(packet) - self._process_packet(packet_data) - except websockets.ConnectionClosed: - logger.debug("Stopped receiving ws packets") - else: - logger.debug("No ws connection found") - - if self._state != self._RUNNING: - logger.debug("Exiting event loop") - return - - logger.debug("Attempting to reconnect") - while not await self._reconnect(): - logger.debug("Reconnect attempt not successful") - - if self._state != self._RUNNING: - logger.debug("Exiting event loop") - return - - logger.debug(f"Sleeping for {self.RECONNECT_DELAY}s and retrying") - await asyncio.sleep(self.RECONNECT_DELAY) - - def _process_packet(self, packet: Any) -> None: - # This function assumes that the packet is formed correctly according - # to http://api.euphoria.io/#packets. - - # First, notify whoever's waiting for this packet - packet_id = packet.get("id") - if packet_id is not None and self._awaiting_replies is not None: - future = self._awaiting_replies.get(packet_id) - if future is not None: - del self._awaiting_replies[packet_id] - future.set_result(packet) - - # Then, send the corresponding event - packet_type = packet["type"] - self._events.fire(packet_type, packet) - - # Finally, reset the ping check - if packet_type == "ping-event": - logger.debug("Resetting ping check") - if self._ping_check is not None: - self._ping_check.cancel() - self._ping_check = asyncio.create_task( - self._disconnect_in(self.PING_TIMEOUT)) - - async def _do_if_possible(self, coroutine: Awaitable[None]) -> None: - """ - Try to run a coroutine, ignoring any IncorrectStateExceptions. - """ - try: - await coroutine - except IncorrectStateException: - pass - - async def _send_if_possible(self, packet_type: str, data: Any,) -> None: - """ - This function tries to send a packet without awaiting the reply. - - It ignores IncorrectStateExceptions, meaning that if it is called while - in the wrong state, nothing will happen. - """ - - try: - await self.send(packet_type, data, await_reply=False) - except IncorrectStateException: - logger.debug("Could not send (disconnecting or already disconnected)") - - async def _ping_pong(self, packet: Any) -> None: - """ - Implements http://api.euphoria.io/#ping and is called as "ping-event" - callback. - """ - logger.debug("Pong!") - await self._do_if_possible(self.send( - "ping-reply", - {"time": packet["data"]["time"]}, - await_reply=False - )) - - async def send(self, - packet_type: str, - data: Any, - await_reply: bool = True - ) -> Any: - """ - Send a packet of type packet_type to the server. - - The object passed as data will make up the packet's "data" section and - must be json-serializable. - - This function will return the complete json-deserialized reply package, - unless await_reply is set to False, in which case it will immediately - return None. - - Exceptions: - - This function must be called while the Connection is (re-)connecting or - running, otherwise an IncorrectStateException will be thrown. - - If the connection closes unexpectedly while sending the packet or - waiting for the reply, a ConnectionClosedException will be thrown. - """ - - while self._state in [self._CONNECTING, self._RECONNECTING]: - async with self._connected_condition: - await self._connected_condition.wait() - - if self._state != self._RUNNING: - raise IncorrectStateException(("send() must be called while the" - " Connection is running")) - - # We're now definitely in the _RUNNING state - - # Since we're in the _RUNNING state, _ws and _awaiting_replies are not - # None. This check is to satisfy mypy. - if self._ws is None or self._awaiting_replies is None: - raise IncorrectStateException("This should never happen") - - packet_id = str(self._packet_id) - self._packet_id += 1 - - # Doing this before the await below since we know that - # _awaiting_replies is not None while the _state is _RUNNING. - if await_reply: - response: asyncio.Future[Any] = asyncio.Future() - self._awaiting_replies[packet_id] = response - - text = json.dumps({"id": packet_id, "type": packet_type, "data": data}) - logger.debug(f"Sending packet {text}") - try: - await self._ws.send(text) - except websockets.ConnectionClosed: - raise ConnectionClosedException() # as promised in the docstring - - if await_reply: - await response - # If the response Future was completed with a - # ConnectionClosedException via set_exception(), response.result() - # will re-raise that exception. - return response.result() - else: - return None + 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 + + self._ws = None + self._pid = 0 # successive packet ids + #self._spawned_tasks = set() + self._pending_responses = {} + + self._stopped = False + self._pingtask = None + self._runtask = asyncio.ensure_future(self._run()) + # ... aaand the connection is started. + + async def send(self, ptype, data=None, await_response=True): + if not self._ws: + raise ConnectionClosed + #raise asyncio.CancelledError + + pid = str(self._new_pid()) + packet = { + "type": ptype, + "id": pid + } + if data: + packet["data"] = data + + if await_response: + wait_for = self._wait_for_response(pid) + + logging.debug(f"Currently used websocket at self._ws: {self._ws}") + await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size + + if await_response: + await wait_for + return wait_for.result() + + async def stop(self): + """ + Close websocket connection and wait for running task to stop. + + No connection function are to be called after calling stop(). + This means that stop() can only be called once. + """ + + self._stopped = True + await self.reconnect() # _run() does the cleaning up now. + await self._runtask + + async def reconnect(self): + """ + Reconnect to the url. + """ + + if self._ws: + await self._ws.close() + + async def _connect(self, tries): + """ + Attempt to connect to a room. + If the Connection is already connected, it attempts to reconnect. + + Returns True on success, False on failure. + + If tries is None, connect retries infinitely. + The delay between connection attempts doubles every attempt (starts with 1s). + """ + + # Assumes _disconnect() has already been called in _run() + + delay = 1 # seconds + while True: + try: + if self.cookiejar: + cookies = [("Cookie", cookie) for cookie in self.cookiejar.sniff()] + self._ws = await websockets.connect(self.url, max_size=None, extra_headers=cookies) + else: + self._ws = await websockets.connect(self.url, max_size=None) + except (websockets.InvalidHandshake, socket.gaierror): # not websockets.InvalidURI + self._ws = None + + if tries is not None: + tries -= 1 + if tries <= 0: + return False + + await asyncio.sleep(delay) + delay *= 2 + else: + if self.cookiejar: + for set_cookie in self._ws.response_headers.get_all("Set-Cookie"): + self.cookiejar.bake(set_cookie) + self.cookiejar.save() + + self._pingtask = asyncio.ensure_future(self._ping()) + + return True + + async def _disconnect(self): + """ + Disconnect and clean up all "residue", such as: + - close existing websocket connection + - cancel all pending response futures with a ConnectionClosed exception + - reset package ID counter + - make sure the ping task has finished + """ + + asyncio.ensure_future(self.disconnect_callback()) + + # stop ping task + if self._pingtask: + self._pingtask.cancel() + await self._pingtask + self._pingtask = None + + if self._ws: + await self._ws.close() + self._ws = None + + self._pid = 0 + + # clean up pending response futures + for _, future in self._pending_responses.items(): + logger.debug(f"Cancelling future with ConnectionClosed: {future}") + future.set_exception(ConnectionClosed("No server response")) + self._pending_responses = {} + + async def _run(self): + """ + Listen for packets and deal with them accordingly. + """ + + while not self._stopped: + await self._connect(self.reconnect_attempts) + + try: + while True: + await self._handle_next_message() + except websockets.ConnectionClosed: + pass + finally: + await self._disconnect() # disconnect and clean up + + async def _ping(self): + """ + Periodically ping the server to detect a timeout. + """ + + try: + while True: + logger.debug("Pinging...") + wait_for_reply = await self._ws.ping() + await asyncio.wait_for(wait_for_reply, self.ping_timeout) + logger.debug("Pinged!") + await asyncio.sleep(self.ping_delay) + except asyncio.TimeoutError: + logger.warning("Ping timed out.") + await self.reconnect() + except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): + pass + + def _new_pid(self): + self._pid += 1 + return self._pid + + async def _handle_next_message(self): + response = await self._ws.recv() + packet = json.loads(response) + + ptype = packet.get("type") + data = packet.get("data", None) + error = packet.get("error", None) + if packet.get("throttled", False): + throttled = packet.get("throttled_reason") + else: + throttled = None + + # Deal with pending responses + pid = packet.get("id", None) + future = self._pending_responses.pop(pid, None) + if future: + future.set_result((ptype, data, error, throttled)) + + # Pass packet onto room + asyncio.ensure_future(self.packet_callback(ptype, data, error, throttled)) + + def _wait_for_response(self, pid): + future = asyncio.Future() + self._pending_responses[pid] = future + return future diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py index 833dbcb..ac4f3bf 100644 --- a/yaboli/cookiejar.py +++ b/yaboli/cookiejar.py @@ -1,77 +1,73 @@ 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. + """ - CookieJar doesn't attempt to discard old cookies, but that doesn't appear - to be necessary for keeping euphoria session cookies. - """ + def __init__(self, filename=None): + self._filename = filename + self._cookies = cookies.SimpleCookie() - def __init__(self, filename: Optional[str] = None) -> None: - self._filename = filename - self._cookies = cookies.SimpleCookie() + if not self._filename: + logger.info("Could not load cookies, no filename given.") + return - if not self._filename: - logger.warning("Could not load cookies, no filename given.") - return + with contextlib.suppress(FileNotFoundError): + with open(self._filename, "r") as f: + for line in f: + self._cookies.load(line) - with contextlib.suppress(FileNotFoundError): - logger.info(f"Loading cookies from {self._filename!r}") - with open(self._filename, "r") as f: - for line in f: - self._cookies.load(line) + def sniff(self): + """ + Returns a list of Cookie headers containing all current cookies. + """ - def get_cookies(self) -> List[str]: - return [morsel.OutputString(attrs=[]) - for morsel in self._cookies.values()] + return [morsel.OutputString(attrs=[]) for morsel in self._cookies.values()] - def get_cookies_as_headers(self) -> List[Tuple[str, str]]: - """ - Return all stored cookies as tuples in a list. The first tuple entry is - always "Cookie". - """ + def bake(self, cookie_string): + """ + Parse cookie and add it to the jar. + Does not automatically save to the cookie file. - return [("Cookie", cookie) for cookie in self.get_cookies()] + Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; HttpOnly; Secure" + """ - def add_cookie(self, cookie: str) -> None: - """ - Parse cookie and add it to the jar. + logger.debug(f"Baking cookie: {cookie_string!r}") - Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; - HttpOnly; Secure" - """ + self._cookies.load(cookie_string) - logger.debug(f"Adding cookie {cookie!r}") - self._cookies.load(cookie) + def save(self): + """ + Saves all current cookies to the cookie jar file. + """ - def save(self) -> None: - """ - Saves all current cookies to the cookie jar file. - """ + if not self._filename: + logger.info("Could not save cookies, no filename given.") + return - if not self._filename: - logger.warning("Could not save cookies, no filename given.") - return + logger.debug(f"Saving cookies to {self._filename!r}") - logger.info(f"Saving cookies to {self._filename!r}") + with open(self._filename, "w") as f: + for morsel in self._cookies.values(): + cookie_string = morsel.OutputString() + #f.write(f"{cookie_string}\n") + f.write(cookie_string) + f.write("\n") - with open(self._filename, "w") as f: - for morsel in self._cookies.values(): - cookie_string = morsel.OutputString() - f.write(f"{cookie_string}\n") + def monster(self): + """ + Removes all cookies from the cookie jar. + Does not automatically save to the cookie file. + """ - def clear(self) -> None: - """ - Removes all cookies from the cookie jar. - """ + logger.debug("OMNOMNOM, cookies are all gone!") - logger.debug("OMNOMNOM, cookies are all gone!") - self._cookies = cookies.SimpleCookie() + self._cookies = cookies.SimpleCookie() diff --git a/yaboli/database.py b/yaboli/database.py index 84af548..1d6e359 100644 --- a/yaboli/database.py +++ b/yaboli/database.py @@ -1,40 +1,87 @@ import asyncio -import logging +from functools import wraps import sqlite3 -from typing import Any, Awaitable, Callable, TypeVar +import threading -from .util import asyncify +__all__ = ["Database"] -logger = logging.getLogger(__name__) -__all__ = ["Database", "operation"] -T = TypeVar('T') +def shielded(afunc): + #@wraps(afunc) + async def wrapper(*args, **kwargs): + return await asyncio.shield(afunc(*args, **kwargs)) + return wrapper -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 PooledConnection: + def __init__(self, pool): + self._pool = pool + + self.connection = None + + async def open(self): + self.connection = await self._pool._request() + + async def close(self): + conn = self.connection + self.connection = None + await self._pool._return(conn) + + async def __aenter__(self): + await self.open() + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.close() + +class Pool: + def __init__(self, filename, size=10): + self.filename = filename + self.size = size + + self._available_connections = asyncio.Queue() + + for i in range(size): + conn = sqlite3.connect(self.filename, check_same_thread=False) + self._available_connections.put_nowait(conn) + + def connection(self): + return PooledConnection(self) + + async def _request(self): + return await self._available_connections.get() + + async def _return(self, conn): + await self._available_connections.put(conn) class Database: - def __init__(self, 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) + 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() diff --git a/yaboli/events.py b/yaboli/events.py deleted file mode 100644 index 7829ccb..0000000 --- a/yaboli/events.py +++ /dev/null @@ -1,25 +0,0 @@ -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 034aaad..f9cce45 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -1,67 +1,13 @@ -__all__ = [ - "EuphException", - # Connection exceptions - "IncorrectStateException", - "ConnectionClosedException", - # Joining a room - "JoinException", - "CouldNotConnectException", - "CouldNotAuthenticateException", - # Doing stuff in a room - "RoomNotConnectedException", - "EuphError", -] +__all__ = ["ConnectionClosed"] -class EuphException(Exception): - pass +class ConnectionClosed(Exception): + pass -# Connection exceptions +class RoomException(Exception): + pass -class IncorrectStateException(EuphException): - """ - A Connection function was called while the Connection was in the incorrect - state. - """ - pass +class AuthenticationRequired(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 +class RoomClosed(RoomException): + pass diff --git a/yaboli/message.py b/yaboli/message.py deleted file mode 100644 index ebad87c..0000000 --- a/yaboli/message.py +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 index ac750bf..0000000 --- a/yaboli/module.py +++ /dev/null @@ -1,214 +0,0 @@ -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 d1304ee..cf83e00 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,562 +1,352 @@ import asyncio import logging -from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar +import time -from .connection import Connection -from .events import Events +from .connection import * from .exceptions import * -from .message import LiveMessage -from .session import Account, LiveSession, LiveSessionListing -from .util import atmention +from .utils import * + logger = logging.getLogger(__name__) +__all__ = ["Room", "Inhabitant"] -__all__ = ["Room"] - -T = TypeVar("T") class Room: - """ - Events and parameters: - - "connected" - fired after the Room has authenticated, joined and set its - nick, meaning that now, messages can be sent - no parameters - - "snapshot" - snapshot of the room's messages at the time of joining - messages: List[LiveMessage] - - "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) + """ + 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 - # Send "snapshot" event - messages = [LiveMessage.from_data(self, msg_data) - for msg_data in data["log"]] - self._events.fire("snapshot", messages) + @staticmethod + def format_room_url(roomname, private=False, human=False): + if private: + roomname = f"pm:{roomname}" - self._snapshot_received = True - await self._try_set_connected() + url = f"wss://euphoria.io/room/{roomname}/ws" - async def _on_bounce_event(self, packet: Any) -> None: - data = packet["data"] + if human: + url = f"{url}?h=1" - # 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 + return url - # If so, do we have a password? - if self._password is None: - self._set_connected_failed() - return + async def connected(self): + await self._connected_future - reply = await self._connection.send( - "auth", - {"type": "passcode", "passcode": self._password} - ) +# REST OF THE IMPLEMENTATION - if not reply["data"]["success"]: - self._set_connected_failed() + async def _send_while_connected(self, *args, **kwargs): + while True: + if self._status == Room.CLOSED: + raise RoomClosed() - async def connect(self) -> bool: - """ - Attempt to connect to the room and start handling events. + try: + await self.connected() + return await self._connection.send(*args, data=kwargs) + except ConnectionClosed: + pass # just try again - This function returns once the Room is fully connected, i. e. - authenticated, using the correct nick and able to post messages. - """ - if not await self._connection.connect(): - return False +class Inhabitant: + """ + TODO + """ - await self._connected.wait() - if not self._connected_successfully: - 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). - self._events.fire("connected") - return True + async def disconnected(self, room): + pass - async def disconnect(self) -> None: - """ - Disconnect from the room and stop the Room. + async def connected(self, room, log): + 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 join(self, room, session): + pass - self._set_connected_reset() - await self._connection.disconnect() + async def part(self, room, session): + pass - # Other events + async def nick(self, room, sid, uid, from_nick, to_nick): + pass - async def _on_disconnect_event(self, packet: Any) -> None: - reason = packet["data"]["reason"] + async def send(self, room, message): + pass - if reason == "authentication changed": - await self._connection.reconnect() + async def fast_forward(self, room, message): + 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 + async def pm(self, room, from_uid, from_nick, from_room, pm_id): + pass diff --git a/yaboli/session.py b/yaboli/session.py deleted file mode 100644 index e59c81a..0000000 --- a/yaboli/session.py +++ /dev/null @@ -1,324 +0,0 @@ -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 deleted file mode 100644 index e8395d9..0000000 --- a/yaboli/util.py +++ /dev/null @@ -1,73 +0,0 @@ -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 new file mode 100644 index 0000000..e810998 --- /dev/null +++ b/yaboli/utils.py @@ -0,0 +1,203 @@ +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) + )