diff --git a/.gitignore b/.gitignore index 7ce48d0..bf7ff1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ -__pycache__/ -*.egg-info/ -/.mypy_cache/ -/.venv/ +# python stuff +*/__pycache__/ + +# venv stuff +bin/ +include/ +lib/ +lib64 +pyvenv.cfg + +# mypy stuff +.mypy_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f1801..441303b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,60 +1,5 @@ # 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/README.md b/README.md index 2cd4eb1..27b0d91 100644 --- a/README.md +++ b/README.md @@ -3,19 +3,23 @@ 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) +Soon, markdown files containing documentation and troubleshooting info will be +available. + ## Installation -Ensure that you have at least Python 3.7 installed. +Ensure that you have at least Python 3.7 installed. The commands below assume +that `python` points this version of Python. -To install yaboli or update your installation to the latest version, run: -``` -$ pip install git+https://github.com/Garmelon/yaboli@v1.2.0 -``` +In your project directory, run: -The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. +``` +$ python -m venv . +$ . bin/activate +$ pip install git+https://github.com/Garmelon/yaboli +``` ## Example echo bot @@ -39,17 +43,7 @@ class EchoBot(yaboli.Bot): 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 bot's nick and default rooms are specified in a config file. The help command from the botrulez uses the `HELP_GENERAL` and `HELP_SPECIFIC` fields. @@ -62,15 +56,15 @@ 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 +- [ ] implement !restart - [ ] document yaboli (markdown files in a "docs" folder?) +- [ ] cookie support +- [ ] fancy argument parsing - [ ] document new classes (docstrings, maybe comments) +- [ ] write project readme - [ ] write examples -- [ ] make yaboli package play nice with mypy - [x] implement !uptime for proper botrulez conformity - [x] implement !kill - [x] untruncate LiveMessage-s @@ -79,7 +73,3 @@ examples](examples/echo/). - [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/example.py b/example.py new file mode 100644 index 0000000..97aff03 --- /dev/null +++ b/example.py @@ -0,0 +1,26 @@ +import yyb + +class MyClient(yyb.Client): + async def on_join(self, room): + await room.say("Hello!") + + async def on_message(self, message): + if message.content == "reply to me"): + reply = await message.reply("reply") + await reply.reply("reply to the reply") + await message.room.say("stuff going on") + + elif message.content == "hey, join &test!": + # returns room in phase 3, or throws JoinException + room = await self.join("test") + if room: + room.say("hey, I joined!") + else: + message.reply("didn't work :(") + + async def before_part(self, room): + await room.say("Goodbye!") + +# Something like this, I guess. It's still missing password fields though. +c = MyClient("my:bot:") +c.run("test", "bots") diff --git a/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 b/examples/echo/bot.conf new file mode 100644 index 0000000..87719ea --- /dev/null +++ b/examples/echo/bot.conf @@ -0,0 +1,5 @@ +[basic] +name = EchoBot + +[rooms] +test diff --git a/examples/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 index e404f3c..4804992 100644 --- a/examples/echo/echobot.py +++ b/examples/echo/echobot.py @@ -8,14 +8,13 @@ class EchoBot(yaboli.Bot): "!echo – reply with exactly ", ] - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + 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): - text = args.raw.strip() # ignoring leading and trailing whitespace - await message.reply(text) + await message.reply(args.raw) if __name__ == "__main__": 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/info.txt b/info.txt new file mode 100644 index 0000000..f33cfb7 --- /dev/null +++ b/info.txt @@ -0,0 +1,39 @@ +Signature of a normal function: + +def a(b: int, c: str) -> bool: + pass + +a # type: Callable[[int, str], bool] + +Signature of an async function: + +async def a(b: int, c: str) -> bool: + pass + +a # type: Callable[[int, str], Awaitable[bool]] + + + +Enable logging (from the websockets docs): + +import logging +logger = logging.getLogger('websockets') +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +Output format: See https://docs.python.org/3/library/logging.html#formatter-objects + +Example formatting: + +FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" +DATE_FORMAT = "%F %T" +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter( + fmt=FORMAT, + datefmt=DATE_FORMAT, + style="{" +)) + +logger = logging.getLogger('yaboli') +logger.setLevel(logging.DEBUG) +logger.addHandler(handler) diff --git a/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: logger.addHandler(handler) def run( - bot_constructor: BotConstructor, - config_file: str = "bot.conf", + client: Callable[[str], Client], + 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() + async def _run(): + client_ = client(config_file) + await client_.run() asyncio.run(_run()) diff --git a/yaboli/bot.py b/yaboli/bot.py index 97385cb..1777498 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,7 +1,7 @@ import configparser import datetime import logging -from typing import Callable, List, Optional +from typing import List, Optional from .client import Client from .command import * @@ -11,95 +11,28 @@ from .util import * logger = logging.getLogger(__name__) -__all__ = ["Bot", "BotConstructor"] +__all__ = ["Bot"] 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" + KILL_REPLY: str = "/me dies" - GENERAL_SECTION = "general" + BASIC_SECTION = "basic" ROOMS_SECTION = "rooms" - def __init__(self, - config: configparser.ConfigParser, - config_file: str, - ) -> None: - self.config = config - self.config_file = config_file + def __init__(self, config_file: str) -> None: + self.config = configparser.ConfigParser(allow_no_value=True) + self.config.read(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) + super().__init__(self.config[self.BASIC_SECTION].get("name", "")) 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) @@ -109,12 +42,6 @@ class Bot(Client): # Registering commands def register(self, command: Command) -> None: - """ - Register a Command (from the yaboli.command submodule). - - Usually, you don't have to call this function yourself. - """ - self._commands.append(command) def register_general(self, @@ -122,23 +49,6 @@ class Bot(Client): cmdfunc: GeneralCommandFunction, args: bool = True ) -> None: - """ - Register a function as general bot command (i. e. no @mention of the - bot nick after the !command). This function will be called by - process_commands() when the bot encounters a matching command. - - name - the name of the command (If you want your command to be !hello, - the name is "hello".) - - cmdfunc - the function that is called with the Room, LiveMessage and - ArgumentData when the bot encounters a matching command - - args - whether the command may have arguments (If set to False, the - ArgumentData's has_args() function must also return False for the - command function to be called. If set to True, all ArgumentData is - valid.) - """ - command = GeneralCommand(name, cmdfunc, args) self.register(command) @@ -147,21 +57,6 @@ class Bot(Client): cmdfunc: SpecificCommandFunction, args: bool = True ) -> None: - """ - Register a function as specific bot command (i. e. @mention of the bot - nick after the !command is required). This function will be called by - process_commands() when the bot encounters a matching command. - - name - the name of the command (see register_general() for an - explanation) - - cmdfunc - the function that is called with the Room, LiveMessage and - SpecificArgumentData when the bot encounters a matching command - - args - whether the command may have arguments (see register_general() - for an explanation) - """ - command = SpecificCommand(name, cmdfunc, args) self.register(command) @@ -172,13 +67,6 @@ class Bot(Client): message: LiveMessage, aliases: List[str] = [] ) -> None: - """ - If the message contains a command, call all matching command functions - that were previously registered. - - This function is usually called by the overwritten on_send() function. - """ - nicks = [room.session.nick] + aliases data = CommandData.from_string(message.content) @@ -188,31 +76,11 @@ class Bot(Client): await command.run(room, message, nicks, data) async def on_send(self, room: Room, message: LiveMessage) -> None: - """ - This Client function is overwritten in order to automatically call - process_commands() with self.ALIASES. - - If you need to overwrite this function, make sure to await - process_commands() with self.ALIASES somewhere in your function, or - await super().on_send(). - """ - - await self.process_commands(room, message, aliases=self.ALIASES) + await self.process_commands(room, message) # 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, @@ -228,38 +96,7 @@ class Bot(Client): 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) @@ -277,18 +114,11 @@ class Bot(Client): if kill: self.register_specific("kill", self.cmd_kill, args=False) - if restart: - self.register_specific("restart", self.cmd_restart, args=False) - async def cmd_ping(self, room: Room, message: LiveMessage, args: ArgumentData ) -> None: - """ - Reply with self.PING_REPLY. - """ - await message.reply(self.PING_REPLY) async def cmd_help_general(self, @@ -296,10 +126,6 @@ class Bot(Client): message: LiveMessage, args: ArgumentData ) -> None: - """ - Reply with self.HELP_GENERAL, if it is not None. Uses format_help(). - """ - if self.HELP_GENERAL is not None: await message.reply(self.format_help(room, [self.HELP_GENERAL])) @@ -308,10 +134,6 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: - """ - Reply with self.HELP_SPECIFIC, if it is not None. Uses format_help(). - """ - if self.HELP_SPECIFIC is not None: await message.reply(self.format_help(room, self.HELP_SPECIFIC)) @@ -320,15 +142,6 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: - """ - Reply with the bot's uptime in the format specified by the botrulez. - - This uses the time that the Bot was first started, not the time the - respective Room was created. A !restart (see register_botrulez()) will - reset the bot uptime, but leaving and re-joining a room or losing - connection won't. - """ - time = format_time(self.start_time) delta = format_delta(datetime.datetime.now() - self.start_time) text = f"/me has been up since {time} UTC ({delta})" @@ -339,39 +152,6 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: - """ - Remove the bot from this room. - - If self.KILL_REPLY is not None, replies with that before leaving the - room. - """ - logger.info(f"Killed in &{room.name} by {message.sender.atmention}") - - if self.KILL_REPLY is not None: - await message.reply(self.KILL_REPLY) - + await message.reply(self.KILL_REPLY) await self.part(room) - - async def cmd_restart(self, - room: Room, - message: LiveMessage, - args: SpecificArgumentData - ) -> None: - """ - Restart the whole Bot. - - This is done by stopping the Bot, since the run() or run_modulebot() - functions start the Bot in a while True loop. - - If self.RESTART_REPLY is not None, replies with that before restarting. - """ - - logger.info(f"Restarted in &{room.name} by {message.sender.atmention}") - - if self.RESTART_REPLY is not None: - await message.reply(self.RESTART_REPLY) - - await self.stop() - -BotConstructor = Callable[[configparser.ConfigParser, str], Bot] diff --git a/yaboli/client.py b/yaboli/client.py index 75806fb..e937a82 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -1,7 +1,7 @@ import asyncio import functools import logging -from typing import Dict, List, Optional, Union +from typing import Dict, List, Optional from .message import LiveMessage from .room import Room @@ -12,12 +12,8 @@ logger = logging.getLogger(__name__) __all__ = ["Client"] class Client: - def __init__(self, - default_nick: str, - cookie_file: Optional[str] = None, - ) -> None: + def __init__(self, default_nick: str) -> None: self._default_nick = default_nick - self._cookie_file = cookie_file self._rooms: Dict[str, List[Room]] = {} self._stop = asyncio.Event() @@ -52,34 +48,14 @@ class Client: async def join(self, room_name: str, password: Optional[str] = None, - nick: Optional[str] = None, - cookie_file: Union[str, bool] = True, + nick: Optional[str] = None ) -> Optional[Room]: - """ - cookie_file is the name of the file to store the cookies in. If it is - True, the client default is used. If it is False, no cookie file name - will be used. - """ - logger.info(f"Joining &{room_name}") if nick is None: nick = self._default_nick + room = Room(room_name, password=password, target_nick=nick) - this_cookie_file: Optional[str] - - if isinstance(cookie_file, str): # This way, mypy doesn't complain - this_cookie_file = cookie_file - elif cookie_file: - this_cookie_file = self._cookie_file - else: - this_cookie_file = None - - room = Room(room_name, password=password, target_nick=nick, - cookie_file=this_cookie_file) - - room.register_event("connected", - functools.partial(self.on_connected, room)) room.register_event("snapshot", functools.partial(self.on_snapshot, room)) room.register_event("send", @@ -126,9 +102,6 @@ class Client: # Event stuff - overwrite these functions - async def on_connected(self, room: Room) -> None: - pass - async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None: pass @@ -152,12 +125,6 @@ class Client: async def on_edit(self, room: Room, message: LiveMessage) -> None: pass - async def on_login(self, room: Room, account_id: str) -> None: - pass - - async def on_logout(self, room: Room) -> None: - pass - async def on_pm(self, room: Room, from_id: str, diff --git a/yaboli/command.py b/yaboli/command.py index 08ac3f7..e355bb0 100644 --- a/yaboli/command.py +++ b/yaboli/command.py @@ -23,70 +23,9 @@ __all__ = ["FancyArgs", "ArgumentData", "SpecificArgumentData", "CommandData", "SpecificCommandFunction", "SpecificCommand"] class FancyArgs(NamedTuple): - """ - The fancy argument parser supports arguments of the following formats: - - - FLAGS: - - These are one or more characters preceded by a single dash. Examples: - - -a, -fghf, -vv - - The fancy argument parser counts how often each character (also called - flag) appears. Each flag that appears once or more gets an entry in the - "flags" dict of the form: flags[flag] = amount - - Exception: A single dash ("-") is interpreted as a positional argument. - - - OPTIONAL: - - These are arguments of the form -- or --=, where - is the name of the optional argument and is its (optional) value. - - Due to this syntax, the may not include any "=" signs. - - The optional arguments are collected in a dict of the form: - - optional[name] = value or None - - If the optional argument included a "=" after the name, but no further - characters, its value is the empty string. If it didn't include a "=" after - the name, its value is None. - - If more than one optional argument appears with the same name, the last - argument's value is kept and all previous values discarded. - - - POSITIONAL: - - Positional arguments are all arguments that don't start with "-" or "--". - They are compiled in a list and ordered in the same order they appeared in - after the command. - - - RAW: - - At any time, a single "--" argument may be inserted. This separates the - positional and optional arguments and the flags from the raw arguments. All - arguments after the "--" are interpreted as raw arguments, even flags, - optional arguments and further "--"s. - - For example, consider the following arguments: - - ab -cd -c --ef=g --h i -- j --klm -nop -- qr - - positional: ["ab", "i"] - optional: {"ef": "g", "h": None} - flags: {"c": 2, "d": 1} - raw: ["j", "--klm", "-nop", "--", "qr"] - """ - positional: List[str] optional: Dict[str, Optional[str]] flags: Dict[str, int] - raw: List[str] class ArgumentData: def __init__(self, raw: str) -> None: @@ -155,38 +94,7 @@ class ArgumentData: 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) + raise NotImplementedError # TODO @property def raw(self) -> str: diff --git a/yaboli/connection.py b/yaboli/connection.py index fcc27fe..fbb354f 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -6,7 +6,6 @@ from typing import Any, Awaitable, Callable, Dict, Optional import websockets -from .cookiejar import CookieJar from .events import Events from .exceptions import * @@ -82,9 +81,6 @@ class Connection: "part-event" and "ping". """ - # Timeout for waiting for the ws connection to be established - CONNECT_TIMEOUT = 10 # seconds - # Maximum duration between euphoria's ping messages. Euphoria usually sends # ping messages every 20 to 30 seconds. PING_TIMEOUT = 40 # seconds @@ -101,9 +97,8 @@ class Connection: # Initialising - def __init__(self, url: str, cookie_file: Optional[str] = None) -> None: + def __init__(self, url: str) -> None: self._url = url - self._cookie_jar = CookieJar(cookie_file) self._events = Events() self._packet_id = 0 @@ -186,12 +181,7 @@ class Connection: 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}") + ws = await websockets.connect(self._url) self._ws = ws self._awaiting_replies = {} @@ -199,15 +189,10 @@ class Connection: self._ping_check = asyncio.create_task( self._disconnect_in(self.PING_TIMEOUT)) - # Put received cookies into cookie jar - for set_cookie in ws.response_headers.get_all("Set-Cookie"): - self._cookie_jar.add_cookie(set_cookie) - self._cookie_jar.save() - return True except (websockets.InvalidHandshake, websockets.InvalidStatusCode, - OSError, asyncio.TimeoutError): + socket.gaierror): logger.debug("Connection failed") return False diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py deleted file mode 100644 index 833dbcb..0000000 --- a/yaboli/cookiejar.py +++ /dev/null @@ -1,77 +0,0 @@ -import contextlib -import http.cookies as cookies -import logging -from typing import List, Optional, Tuple - -logger = logging.getLogger(__name__) - -__all__ = ["CookieJar"] - -class CookieJar: - """ - Keeps your cookies in a file. - - CookieJar doesn't attempt to discard old cookies, but that doesn't appear - to be necessary for keeping euphoria session cookies. - """ - - def __init__(self, filename: Optional[str] = None) -> None: - self._filename = filename - self._cookies = cookies.SimpleCookie() - - if not self._filename: - logger.warning("Could not load cookies, no filename given.") - return - - with contextlib.suppress(FileNotFoundError): - logger.info(f"Loading cookies from {self._filename!r}") - with open(self._filename, "r") as f: - for line in f: - self._cookies.load(line) - - def get_cookies(self) -> List[str]: - return [morsel.OutputString(attrs=[]) - for morsel in self._cookies.values()] - - def get_cookies_as_headers(self) -> List[Tuple[str, str]]: - """ - Return all stored cookies as tuples in a list. The first tuple entry is - always "Cookie". - """ - - return [("Cookie", cookie) for cookie in self.get_cookies()] - - def add_cookie(self, cookie: str) -> None: - """ - Parse cookie and add it to the jar. - - Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; - HttpOnly; Secure" - """ - - logger.debug(f"Adding cookie {cookie!r}") - self._cookies.load(cookie) - - def save(self) -> None: - """ - Saves all current cookies to the cookie jar file. - """ - - if not self._filename: - logger.warning("Could not save cookies, no filename given.") - return - - logger.info(f"Saving cookies to {self._filename!r}") - - with open(self._filename, "w") as f: - for morsel in self._cookies.values(): - cookie_string = morsel.OutputString() - f.write(f"{cookie_string}\n") - - def clear(self) -> None: - """ - Removes all cookies from the cookie jar. - """ - - logger.debug("OMNOMNOM, cookies are all gone!") - self._cookies = cookies.SimpleCookie() diff --git a/yaboli/database.py b/yaboli/database.py deleted file mode 100644 index 84af548..0000000 --- a/yaboli/database.py +++ /dev/null @@ -1,40 +0,0 @@ -import asyncio -import logging -import sqlite3 -from typing import Any, Awaitable, Callable, TypeVar - -from .util import asyncify - -logger = logging.getLogger(__name__) - -__all__ = ["Database", "operation"] - -T = TypeVar('T') - -def operation(func: Callable[..., T]) -> Callable[..., Awaitable[T]]: - async def wrapper(self: Any, *args: Any, **kwargs: Any) -> T: - async with self as db: - while True: - try: - return await asyncify(func, self, db, *args, **kwargs) - except sqlite3.OperationalError as e: - logger.warn(f"Operational error encountered: {e}") - await asyncio.sleep(5) - return wrapper - -class Database: - def __init__(self, database: str) -> None: - self._connection = sqlite3.connect(database, check_same_thread=False) - self._lock = asyncio.Lock() - - self.initialize(self._connection) - - def initialize(self, db: Any) -> None: - pass - - async def __aenter__(self) -> Any: - await self._lock.__aenter__() - return self._connection - - async def __aexit__(self, *args: Any, **kwargs: Any) -> Any: - return await self._lock.__aexit__(*args, **kwargs) diff --git a/yaboli/module.py b/yaboli/module.py index ac750bf..2dc9b0f 100644 --- a/yaboli/module.py +++ b/yaboli/module.py @@ -1,6 +1,5 @@ -import configparser import logging -from typing import Callable, Dict, List, Optional +from typing import Dict, List, Optional from .bot import Bot from .command import * @@ -11,77 +10,49 @@ from .util import * logger = logging.getLogger(__name__) -__all__ = ["Module", "ModuleConstructor", "ModuleBot", "ModuleBotConstructor"] +__all__ = ["Module", "ModuleBot"] 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) + def __init__(self, config_file: str, standalone: bool) -> None: + super().__init__(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} \".", + "" + "Use \"!help {atmention} \" to get more information on a" + " specific module." ] MODULE_HELP_LIMIT = 5 - MODULES_SECTION = "modules" + def __init__(self, config_file: str) -> None: + super().__init__(config_file) - 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) + self.register_botrulez(help_=False) + self.register_general("help", self.cmd_help_general, args=False) + self.register_specific("help", self.cmd_help_specific, args=True) - def load_module(self, name: str, module: Module) -> None: + def register_module(self, name: str, module: Module) -> None: if name in self.modules: logger.warn(f"Module {name!r} is already registered, overwriting...") self.modules[name] = module - def 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: @@ -91,10 +62,7 @@ class ModuleBot(Bot): lines.append(line) if modules_without_desc: - lines.append("\t" + ", ".join(modules_without_desc)) - - if not any_modules: - lines.append("No modules loaded.") + lines.append(", ".join(modules_without_desc)) if self.HELP_POST is not None: lines.extend(self.HELP_POST) @@ -111,7 +79,8 @@ class ModuleBot(Bot): return module.HELP_SPECIFIC - async def cmd_modules_help(self, + # Overwriting the botrulez help function + async def cmd_help_specific(self, room: Room, message: LiveMessage, args: SpecificArgumentData @@ -131,12 +100,6 @@ class ModuleBot(Bot): # Sending along all kinds of events - async def on_connected(self, room: Room) -> None: - await super().on_connected(room) - - for module in self.modules.values(): - await module.on_connected(room) - async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None: await super().on_snapshot(room, messages) @@ -178,18 +141,6 @@ class ModuleBot(Bot): for module in self.modules.values(): await module.on_edit(room, message) - async def on_login(self, room: Room, account_id: str) -> None: - await super().on_login(room, account_id) - - for module in self.modules.values(): - await module.on_login(room, account_id) - - async def on_logout(self, room: Room) -> None: - await super().on_logout(room) - - for module in self.modules.values(): - await module.on_logout(room) - async def on_pm(self, room: Room, from_id: str, @@ -207,8 +158,3 @@ class ModuleBot(Bot): 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..458cdea 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,6 +1,6 @@ import asyncio import logging -from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar +from typing import Any, Awaitable, Callable, List, Optional, TypeVar from .connection import Connection from .events import Events @@ -19,10 +19,6 @@ 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] @@ -43,12 +39,6 @@ class Room: "edit" - a message in the room has been modified or deleted message: LiveMessage - "login" - this session has been logged in from another session - account_id: str - - "logout" - this session has been logged out from another session - no parameters - "pm" - another session initiated a pm with you from: str - the id of the user inviting the client to chat privately from_nick: str - the nick of the inviting user @@ -66,8 +56,7 @@ class Room: name: str, password: Optional[str] = None, target_nick: str = "", - url_format: str = URL_FORMAT, - cookie_file: Optional[str] = None, + url_format: str = URL_FORMAT ) -> None: self._name = name self._password = password @@ -85,7 +74,7 @@ class Room: # Connected management self._url = self._url_format.format(self._name) - self._connection = Connection(self._url, cookie_file=cookie_file) + self._connection = Connection(self._url) self._events = Events() self._connected = asyncio.Event() @@ -123,22 +112,11 @@ class Room: # Connecting, reconnecting and disconnecting - async def _try_set_connected(self) -> None: + def _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() + self._connected_successfully = True + self._connected.set() def _set_connected_failed(self) -> None: if not self._connected.is_set(): @@ -165,7 +143,7 @@ class Room: self._account = Account.from_data(data) self._hello_received = True - await self._try_set_connected() + self._set_connected() async def _on_snapshot_event(self, packet: Any) -> None: data = packet["data"] @@ -180,22 +158,19 @@ class Room: if nick is not None and self._session is not None: self._session = self.session.with_nick(nick) - # Send "snapshot" event + # Send "session" event messages = [LiveMessage.from_data(self, msg_data) for msg_data in data["log"]] - self._events.fire("snapshot", messages) + self._events.fire("session", messages) self._snapshot_received = True - await self._try_set_connected() + self._set_connected() async def _on_bounce_event(self, packet: Any) -> None: data = packet["data"] - # Can we even authenticate? (Assuming that passcode authentication is - # available if no authentication options are given: Euphoria doesn't - # (always) send authentication options, even when passcode - # authentication works.) - if not "passcode" in data.get("auth_options", ["passcode"]): + # Can we even authenticate? + if not "passcode" in data.get("auth_options", []): self._set_connected_failed() return @@ -227,7 +202,11 @@ class Room: if not self._connected_successfully: return False - self._events.fire("connected") + nick_needs_updating = (self._session is None + or self._target_nick != self._session.nick) + if self._target_nick and nick_needs_updating: + await self._nick(self._target_nick) + return True async def disconnect(self) -> None: @@ -258,34 +237,14 @@ class Room: session = LiveSession.from_data(self, data) self._users = self.users.with_join(session) - logger.info(f"&{self.name}: {session.atmention} joined") + logger.info(f"{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() + pass # TODO implement once cookie support is here 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() + pass # TODO implement once cookie support is here async def _on_network_event(self, packet: Any) -> None: data = packet["data"] @@ -299,7 +258,7 @@ class Room: for user in self.users: if user.server_id == server_id and user.server_era == server_era: users = users.with_part(user) - logger.info(f"&{self.name}: {user.atmention} left") + logger.info(f"{user.atmention} left") self._events.fire("part", user) self._users = users @@ -316,7 +275,7 @@ class Room: else: await self.who() # recalibrating self._users - logger.info(f"&{self.name}: {atmention(nick_from)} is now called {atmention(nick_to)}") + logger.info(f"{atmention(nick_from)} is now called {atmention(nick_to)}") self._events.fire("nick", session, nick_from, nick_to) async def _on_edit_message_event(self, packet: Any) -> None: @@ -332,7 +291,7 @@ class Room: session = LiveSession.from_data(self, data) self._users = self.users.with_part(session) - logger.info(f"&{self.name}: {session.atmention} left") + logger.info(f"{session.atmention} left") self._events.fire("part", session) async def _on_pm_initiate_event(self, packet: Any) -> None: @@ -409,6 +368,10 @@ class Room: # Functionality + # These functions require cookie support and are thus not implemented yet: + # + # login, logout, pm + def _extract_data(self, packet: Any) -> Any: error = packet.get("error") if error is not None: @@ -508,55 +471,3 @@ class Room: self._users = users return self._users - - async def login(self, email: str, password: str) -> Tuple[bool, str]: - """ - Since euphoria appears to only support email authentication, this way - of logging in is hardcoded here. - - Returns whether the login was successful. If it was, the second - parameter is the account id. If it wasn't, the second parameter is the - reason why the login failed. - """ - - data: Any = { - "namespace": "email", - "id": email, - "password": password, - } - - reply = await self._connection.send("login", data) - data = self._extract_data(reply) - - success: bool = data["success"] - account_id_or_reason = data.get("account_id") or data["reason"] - - if success: - logger.info(f"&{self.name}: Logged in as {account_id_or_reason}") - else: - logger.info(f"&{self.name}: Failed to log in with {email} because {account_id_or_reason}") - - await self._connection.reconnect() - - return success, account_id_or_reason - - async def logout(self) -> None: - await self._connection.send("logout", {}) - - logger.info(f"&{self.name}: Logged out") - - await self._connection.reconnect() - - async def pm(self, user_id: str) -> Tuple[str, str]: - """ - Returns the pm_id of the pm and the nick of the person being pinged. - """ - - data = {"user_id": user_id} - - reply = await self._connection.send("pm-initiate", data) - data = self._extract_data(reply) - - pm_id = data["pm_id"] - to_nick = data["to_nick"] - return pm_id, to_nick diff --git a/yaboli/session.py b/yaboli/session.py index e59c81a..5adcbcb 100644 --- a/yaboli/session.py +++ b/yaboli/session.py @@ -1,6 +1,5 @@ import re -from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, - Optional, Tuple) +from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional from .util import mention, normalize @@ -239,12 +238,7 @@ class LiveSession(Session): # Live stuff - async def pm(self) -> Tuple[str, str]: - """ - See Room.pm - """ - - return await self.room.pm(self.user_id) + # TODO pm, once pm support is there. class LiveSessionListing: def __init__(self, room: "Room", sessions: Iterable[LiveSession]) -> None: diff --git a/yaboli/util.py b/yaboli/util.py index e8395d9..6439799 100644 --- a/yaboli/util.py +++ b/yaboli/util.py @@ -1,16 +1,8 @@ -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) +__all__ = ["mention", "atmention", "normalize", "similar", "plural", + "format_time", "format_delta"] # Name/nick related functions