diff --git a/.gitignore b/.gitignore index 001826c..7ce48d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -yaboli/__pycache__/ -*.txt +__pycache__/ +*.egg-info/ +/.mypy_cache/ +/.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e0f1801 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +## Next version + +## 1.2.0 (2022-08-21) + +- update websockets dependency +- switch to pyproject.toml style setuptools config + +## 1.1.5 (2020-01-26) + +- more stability (I think) + +## 1.1.4 (2019-06-21) + +- add docstrings to `Bot` +- change `KILL_REPLY` and `RESTART_REPLY` to be optional in `Bot` +- fix imports +- fix room firing incorrect event +- update echobot example to newest version +- update example gitignore to newest version + +## 1.1.3 (2019-04-19) + +- add timeout for creating ws connections +- fix config file not reloading when restarting bots + +## 1.1.2 (2019-04-14) + +- fix room authentication +- resolve to test yaboli more thoroughly before publishing a new version + +## 1.1.1 (2019-04-14) + +- add database class for easier sqlite3 access + +## 1.1.0 (2019-04-14) + +- change how config files are passed along +- change module system to support config file changes + +## 1.0.0 (2019-04-13) + +- add fancy argument parsing +- add login and logout command to room +- add pm command to room +- add cookie support +- add !restart to botrulez +- add Bot config file saving +- fix the Room not setting its nick correctly upon reconnecting + +## 0.2.0 (2019-04-12) + +- add `ALIASES` variable to `Bot` +- add `on_connected` function to `Client` +- change config file format + +## 0.1.0 (2019-04-12) + +- use setuptools diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f2fd14f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 - 2019 Garmelon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cd4eb1 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Yaboli + +Yaboli (**Y**et **A**nother **Bo**t **Li**brary) is a Python library for +creating bots for [euphoria.io](https://euphoria.io). + +- [Documentation](docs/index.md) +- [Changelog](CHANGELOG.md) + +## Installation + +Ensure that you have at least Python 3.7 installed. + +To install yaboli or update your installation to the latest version, run: +``` +$ pip install git+https://github.com/Garmelon/yaboli@v1.2.0 +``` + +The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. + +## Example echo bot + +A simple echo bot that conforms to the +[botrulez](https://github.com/jedevc/botrulez) can be written like so: + +```python +class EchoBot(yaboli.Bot): + HELP_GENERAL = "/me echoes back what you said" + HELP_SPECIFIC = [ + "This bot only has one command:", + "!echo – reply with exactly ", + ] + + def __init__(self, config_file): + super().__init__(config_file) + self.register_botrulez(kill=True) + self.register_general("echo", self.cmd_echo) + + async def cmd_echo(self, room, message, args): + await message.reply(args.raw) +``` + +The bot's nick, cookie file and default rooms are specified in a config file, +like so: + +```ini +[general] +nick = EchoBot +cookie_file = bot.cookie + +[rooms] +test +``` + +The help command from the botrulez uses the `HELP_GENERAL` and `HELP_SPECIFIC` +fields. + +In the `__init__` function, the bot's commands are registered. The required +botrulez commands (!ping, !help, !uptime) are enabled by default. Other +commands like !kill need to be enabled explicitly. + +In the `cmd_echo` function, the echo command is implemented. In this case, the +bot replies to the message containing the command with the raw argument string, +i. e. the text between the end of the "!echo" and the end of the whole message. + +The full version of this echobot can be found [in the +examples](examples/echo/). + +## TODOs + +- [ ] document yaboli (markdown files in a "docs" folder?) +- [ ] document new classes (docstrings, maybe comments) +- [ ] write examples +- [ ] make yaboli package play nice with mypy +- [x] implement !uptime for proper botrulez conformity +- [x] implement !kill +- [x] untruncate LiveMessage-s +- [x] config file support for bots, used by default +- [x] make it easier to enable log messages +- [x] make it easier to run bots +- [x] package in a distutils-compatible way (users should be able to install + yaboli using `pip install git+https://github.com/Garmelon/yaboli`) +- [x] implement !restart +- [x] write project readme +- [x] cookie support +- [x] fancy argument parsing diff --git a/docs/bot_setup.md b/docs/bot_setup.md new file mode 100644 index 0000000..cf6722d --- /dev/null +++ b/docs/bot_setup.md @@ -0,0 +1,13 @@ +# Setting up and running a bot + +## Installing yaboli + +TODO + +## Configuring the bot + +TODO + +## Running the bot + +TODO diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9f4835f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,89 @@ +# Index for yaboli docs + + - [Setting up and running a bot](bot_setup.md) + - Classes + - [Bot](bot.md) + +## Getting started + +First, read the [overview](#library-structure-overview) below. + +To set up your project, follow the [setup guide](bot_setup.md). + +To get a feel for how bots are structured, have a look at the example bots or +read through the docstrings in the `Bot` class. + +## Library structure overview + +### Message, Session + +A `Message` represents a single message. It contains all the fields [specified +in the API](http://api.euphoria.io/#message), in addition to a few utility +functions. + +Similar to a `Message`, a `Session` represents a [session +view](http://api.euphoria.io/#sessionview) and also contains almost all the +fields specified in the API, in addition to a few utility functions. + +`Message`s and `Session`s also both contain the name of the room they +originated from. + +### Room + +A `Room` represents a single connection to a room on euphoria. It tries to keep +connected and reconnects if it loses connection. When connecting and +reconnecting, it automatically authenticates and sets a nick. + +In addition, a `Room` also keeps track of its own session and the sessions of +all other people and bots connected to the room. It doesn't remember any +messages though, since no "correct" solution to do that exists and the method +depends on the design of the bot using the `Room` (keeping the last few +messages in memory, storing messages in a database etc.). + +### LiveMessage, LiveSession + +`LiveMessage`s and `LiveSession`s function the same as `Message`s and +`Session`s, with the difference that they contain the `Room` object they +originated from, instead of just a room name. This allows them to also include +a few convenience functions, like `Message.reply`. + +Usually, `Room`s and `Client`s (and thus `Bot`s) will pass `LiveMessage`s and +`LiveSession`s instead of their `Message` and `Session` counterparts. + +### Client + +A `Client` may be connected to a few rooms on euphoria and thus manages a few +`Room` objects. It has functions for joining and leaving rooms on euphoria, and +it can also be connected to the same room multiple times (resulting in multiple +`Room` objects). + +The `Client` has a few `on_` functions (e. g. `on_message`, `on_join`) +that are triggered by events in any of the `Room` objects it manages. This +allows a `Client` to react to various things happening in its `Room`s. + +### Bot + +A `Bot` is a client that: + +- is configured using a config file +- reacts to commands using a command system +- implements most commands specified in the + [botrulez](https://github.com/jedevc/botrulez) + +The config file includes the bot's default nick, initial rooms and bot-specific +configuration. Upon starting a `Bot`, it joins the rooms specified in the +config, setting its nick to the default nick. + +The command system can react to general and specific commands as specified in +the botrulez, and can parse command arguments with or without bash-style string +escaping, and with or without unix-like syntax (flags and optional arguments). + +### Module, ModuleBot + +A `Module` is a `Bot` that can also be used as a module in a `ModuleBot`. This +is like combining multiple bots into a single bot. + +The most notable differences are the new `DESCRIPTION` and `standalone` fields. +The `DESCRIPTION` field contains a short description of the module, whereas the +`standalone` field answers the question whether the `Module` is being run as +standalone bot or part of a `ModuleBot`. diff --git a/examples/echo/.gitignore b/examples/echo/.gitignore new file mode 100644 index 0000000..da78a19 --- /dev/null +++ b/examples/echo/.gitignore @@ -0,0 +1,5 @@ +# These files are ignored because they may contain sensitive information you +# wouldn't want in your repo. If you need to have a config file in your repo, +# store a bot.conf.default with default settings. +*.conf +*.cookie diff --git a/examples/echo/bot.conf.default b/examples/echo/bot.conf.default new file mode 100644 index 0000000..940e8e4 --- /dev/null +++ b/examples/echo/bot.conf.default @@ -0,0 +1,6 @@ +[general] +nick = EchoBot +cookie_file = bot.cookie + +[rooms] +test diff --git a/examples/echo/echobot.py b/examples/echo/echobot.py new file mode 100644 index 0000000..e404f3c --- /dev/null +++ b/examples/echo/echobot.py @@ -0,0 +1,23 @@ +import yaboli + + +class EchoBot(yaboli.Bot): + HELP_GENERAL = "/me echoes back what you said" + HELP_SPECIFIC = [ + "This bot only has one command:", + "!echo – reply with exactly ", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.register_botrulez(kill=True) + self.register_general("echo", self.cmd_echo) + + async def cmd_echo(self, room, message, args): + text = args.raw.strip() # ignoring leading and trailing whitespace + await message.reply(text) + + +if __name__ == "__main__": + yaboli.enable_logging() + yaboli.run(EchoBot) diff --git a/examples/gitignore_with_venv b/examples/gitignore_with_venv new file mode 100644 index 0000000..f69b963 --- /dev/null +++ b/examples/gitignore_with_venv @@ -0,0 +1,17 @@ +# python stuff +__pycache__/ + +# venv stuff +bin/ +include/ +lib/ +lib64 +pyvenv.cfg + +# bot stuff +# +# These files are ignored because they may contain sensitive information you +# wouldn't want in your repo. If you need to have a config file in your repo, +# store a bot.conf.default with default settings. +*.conf +*.cookie diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..6fd0e6a --- /dev/null +++ b/mypy.ini @@ -0,0 +1,4 @@ +[mypy] +disallow_untyped_defs = True +disallow_incomplete_defs = True +no_implicit_optional = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..79ad530 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "yaboli" +version = "1.2.0" +dependencies = [ + "websockets >=10.3, <11" +] + +# When updating the version, also: +# - update the README.md installation instructions +# - update the changelog +# - set a tag to the update commit + +# Meanings of version numbers +# +# Format: a.b.c +# +# a - increased when: major change such as a rewrite +# b - increased when: changes breaking backwards compatibility +# c - increased when: minor changes preserving backwards compatibility +# +# To specify version requirements for yaboli, the following format is +# recommended if you need version a.b.c: +# +# yaboli >=a.b.c, None: + handler = logging.StreamHandler() + handler.setFormatter(FORMATTER) + + logger = logging.getLogger(name) + logger.setLevel(level) + logger.addHandler(handler) + +def run( + bot_constructor: BotConstructor, + config_file: str = "bot.conf", + ) -> None: + async def _run() -> None: + while True: + # Load the config file + config = configparser.ConfigParser(allow_no_value=True) + config.read(config_file) + + bot = bot_constructor(config, config_file) + await bot.run() + + asyncio.run(_run()) + +def run_modulebot( + modulebot_constructor: ModuleBotConstructor, + module_constructors: Dict[str, ModuleConstructor], + config_file: str = "bot.conf", + ) -> None: + async def _run() -> None: + while True: + # Load the config file + config = configparser.ConfigParser(allow_no_value=True) + config.read(config_file) + + modulebot = modulebot_constructor(config, config_file, + module_constructors) + await modulebot.run() + + asyncio.run(_run()) diff --git a/yaboli/basic_types.py b/yaboli/basic_types.py deleted file mode 100644 index 5ba0110..0000000 --- a/yaboli/basic_types.py +++ /dev/null @@ -1,163 +0,0 @@ -import time - -class SessionView(): - """ - This class keeps track of session details. - http://api.euphoria.io/#sessionview - """ - - def __init__(self, id, name, server_id, server_era, session_id, is_staff=None, is_manager=None): - """ - id - agent/account id - name - name of the client when the SessionView was captured - server_id - id of the server - server_era - era of the server - session_id - session id (unique across euphoria) - is_staff - client is staff - is_manager - client is manager - """ - - self.id = id - self.name = name - self.server_id = server_id - self.server_era = server_era - self.session_id = session_id - self.staff = is_staff - self.manager = is_manager - - @classmethod - def from_data(cls, data): - """ - Creates and returns a session created from the data. - - data - a euphoria SessionView - """ - - view = cls(None, None, None, None, None) - view.read_data(data) - return view - - def read_data(self, data): - if "id" in data: self.id = data.get("id") - if "name" in data: self.name = data.get("name") - if "server_id" in data: self.server_id = data.get("server_id") - if "server_era" in data: self.server_era = data.get("server_era") - if "session_id" in data: self.session_id = data.get("session_id") - if "is_staff" in data: self.is_staff = data.get("is_staff") - if "is_manager" in data: self.is_manager = data.get("is_manager") - - def session_type(self): - """ - session_type() -> str - - The session's type (bot, account, agent). - """ - - return self.id.split(":")[0] if ":" in self.id else None - -class Message(): - """ - This class represents a single euphoria message. - http://api.euphoria.io/#message - """ - - def __init__(self, id, time, sender, content, parent=None, edited=None, previous_edit_id=None, - deleted=None, truncated=None, encryption_key_id=None): - """ - id - message id - time - time the message was sent (epoch) - sender - SessionView of the sender - content - content of the message - parent - id of the parent message, or None - edited - time of last edit (epoch) - previous_edit_id - edit id of the most recent edit of this message - deleted - time of deletion (epoch) - truncated - message was truncated - encryption_key_id - id of the key that encrypts the message in storage - """ - - self.id = id - self.time = time - self.sender = sender - self.content = content - self.parent = parent - self.edited = edited - self.previous_edit_id = previous_edit_id - self.deleted = deleted - self.truncated = truncated - self.encryption_key_id = encryption_key_id - - @classmethod - def from_data(self, data): - """ - Creates and returns a message created from the data. - NOTE: This also creates a session object using the data in "sender". - - data - a euphoria message: http://api.euphoria.io/#message - """ - - sender = SessionView.from_data(data.get("sender")) - - return self( - data.get("id"), - data.get("time"), - sender, - data.get("content"), - parent=data.get("parent"), - edited=data.get("edited"), - deleted=data.get("deleted"), - truncated=data.get("truncated"), - previous_edit_id=data.get("previous_edit_id"), - encryption_key_id=data.get("encryption_key_id") - ) - - def time_formatted(self, date=True): - """ - time_formatted(date=True) -> str - - date - include date in format - - Time in a readable format: - With date: YYYY-MM-DD HH:MM:SS - Without date: HH:MM:SS - """ - - if date: - return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(self.time)) - else: - return time.strftime("%H:%M:%S", time.gmtime(self.time)) - - def formatted(self, show_time=False, date=True, insert_string=None, repeat_insert_string=True): - """ - formatted() -> strftime - - The message contents in the following format (does not end on a newline): -