From 7b3498bd27b8c52694b86f03f842d308a78d4b7b Mon Sep 17 00:00:00 2001 From: kalipso Date: Mon, 2 Aug 2021 22:12:33 +0200 Subject: [PATCH] init --- CONTRIBUTING.md | 96 +++++++++ LICENSE | 177 ++++++++++++++++ README.md | 160 ++++++++++++++ SETUP.md | 173 +++++++++++++++ bot.db | Bin 0 -> 8192 bytes docker/.env | 5 + docker/Dockerfile | 101 +++++++++ docker/Dockerfile.dev | 71 +++++++ docker/README.md | 156 ++++++++++++++ docker/build_and_install_libolm.sh | 32 +++ docker/docker-compose.yml | 64 ++++++ docker/my-project-name.service | 16 ++ docker/start-dev.sh | 49 +++++ my-project-name | 10 + my_project_name.egg-info/PKG-INFO | 180 ++++++++++++++++ my_project_name.egg-info/SOURCES.txt | 20 ++ my_project_name.egg-info/dependency_links.txt | 1 + my_project_name.egg-info/requires.txt | 15 ++ my_project_name.egg-info/top_level.txt | 1 + my_project_name/__init__.py | 8 + .../__pycache__/__init__.cpython-39.pyc | Bin 0 -> 339 bytes .../__pycache__/bot_commands.cpython-39.pyc | Bin 0 -> 3849 bytes .../__pycache__/caldav_handler.cpython-39.pyc | Bin 0 -> 3760 bytes .../__pycache__/callbacks.cpython-39.pyc | Bin 0 -> 5464 bytes .../__pycache__/chat_functions.cpython-39.pyc | Bin 0 -> 4046 bytes .../__pycache__/config.cpython-39.pyc | Bin 0 -> 3483 bytes .../__pycache__/errors.cpython-39.pyc | Bin 0 -> 696 bytes .../__pycache__/main.cpython-39.pyc | Bin 0 -> 2415 bytes .../message_responses.cpython-39.pyc | Bin 0 -> 1766 bytes .../__pycache__/storage.cpython-39.pyc | Bin 0 -> 3476 bytes my_project_name/bot_commands.py | 120 +++++++++++ my_project_name/caldav_handler.py | 105 ++++++++++ my_project_name/callbacks.py | 198 ++++++++++++++++++ my_project_name/chat_functions.py | 154 ++++++++++++++ my_project_name/config.py | 136 ++++++++++++ my_project_name/errors.py | 12 ++ my_project_name/main.py | 119 +++++++++++ my_project_name/message_responses.py | 52 +++++ my_project_name/storage.py | 126 +++++++++++ sample.config.yaml | 49 +++++ scripts-dev/lint.sh | 20 ++ scripts-dev/rename_project.sh | 64 ++++++ setup.cfg | 19 ++ setup.py | 62 ++++++ shell.nix | 17 ++ ...ystemli.org_ABCDEFGHIJ.blacklisted_devices | 0 .../@calendar1312:systemli.org_ABCDEFGHIJ.db | Bin 0 -> 139264 bytes ...12:systemli.org_ABCDEFGHIJ.ignored_devices | 18 ++ ...12:systemli.org_ABCDEFGHIJ.trusted_devices | 0 ...ystemli.org_DJEUFOANEU.blacklisted_devices | 0 .../@calendar1312:systemli.org_DJEUFOANEU.db | Bin 0 -> 167936 bytes ...12:systemli.org_DJEUFOANEU.ignored_devices | 19 ++ ...12:systemli.org_DJEUFOANEU.trusted_devices | 0 tests/__init__.py | 0 tests/test_callbacks.py | 50 +++++ tests/test_config.py | 81 +++++++ tests/utils.py | 22 ++ 57 files changed, 2778 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SETUP.md create mode 100644 bot.db create mode 100644 docker/.env create mode 100644 docker/Dockerfile create mode 100644 docker/Dockerfile.dev create mode 100644 docker/README.md create mode 100755 docker/build_and_install_libolm.sh create mode 100644 docker/docker-compose.yml create mode 100644 docker/my-project-name.service create mode 100755 docker/start-dev.sh create mode 100755 my-project-name create mode 100644 my_project_name.egg-info/PKG-INFO create mode 100644 my_project_name.egg-info/SOURCES.txt create mode 100644 my_project_name.egg-info/dependency_links.txt create mode 100644 my_project_name.egg-info/requires.txt create mode 100644 my_project_name.egg-info/top_level.txt create mode 100644 my_project_name/__init__.py create mode 100644 my_project_name/__pycache__/__init__.cpython-39.pyc create mode 100644 my_project_name/__pycache__/bot_commands.cpython-39.pyc create mode 100644 my_project_name/__pycache__/caldav_handler.cpython-39.pyc create mode 100644 my_project_name/__pycache__/callbacks.cpython-39.pyc create mode 100644 my_project_name/__pycache__/chat_functions.cpython-39.pyc create mode 100644 my_project_name/__pycache__/config.cpython-39.pyc create mode 100644 my_project_name/__pycache__/errors.cpython-39.pyc create mode 100644 my_project_name/__pycache__/main.cpython-39.pyc create mode 100644 my_project_name/__pycache__/message_responses.cpython-39.pyc create mode 100644 my_project_name/__pycache__/storage.cpython-39.pyc create mode 100644 my_project_name/bot_commands.py create mode 100644 my_project_name/caldav_handler.py create mode 100644 my_project_name/callbacks.py create mode 100644 my_project_name/chat_functions.py create mode 100644 my_project_name/config.py create mode 100644 my_project_name/errors.py create mode 100644 my_project_name/main.py create mode 100644 my_project_name/message_responses.py create mode 100644 my_project_name/storage.py create mode 100644 sample.config.yaml create mode 100755 scripts-dev/lint.sh create mode 100755 scripts-dev/rename_project.sh create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 shell.nix create mode 100644 store/@calendar1312:systemli.org_ABCDEFGHIJ.blacklisted_devices create mode 100644 store/@calendar1312:systemli.org_ABCDEFGHIJ.db create mode 100644 store/@calendar1312:systemli.org_ABCDEFGHIJ.ignored_devices create mode 100644 store/@calendar1312:systemli.org_ABCDEFGHIJ.trusted_devices create mode 100644 store/@calendar1312:systemli.org_DJEUFOANEU.blacklisted_devices create mode 100644 store/@calendar1312:systemli.org_DJEUFOANEU.db create mode 100644 store/@calendar1312:systemli.org_DJEUFOANEU.ignored_devices create mode 100644 store/@calendar1312:systemli.org_DJEUFOANEU.trusted_devices create mode 100644 tests/__init__.py create mode 100644 tests/test_callbacks.py create mode 100644 tests/test_config.py create mode 100644 tests/utils.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bb7d12b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,96 @@ +# Contributing to nio-template + +Thank you for taking interest in this little project. Below is some information +to help you with contributing. + +## Setting up your development environment + +See the +[Install the dependencies section of SETUP.md](SETUP.md#install-the-dependencies) +for help setting up a running environment for the bot. + +If you would rather not or are unable to run docker, the following instructions +will explain how to install the project dependencies natively. + +#### Install libolm + +You can install [libolm](https://gitlab.matrix.org/matrix-org/olm) from source, +or alternatively, check your system's package manager. Version `3.0.0` or +greater is required. + +**(Optional) postgres development headers** + +By default, the bot uses SQLite as its storage backend. This is fine for a +few hundred users, but if you plan to support a much higher volume +of requests, you may consider using Postgres as a database backend instead. + +If you want to use postgres as a database backend, you'll need to install +postgres development headers: + +Debian/Ubuntu: + +``` +sudo apt install libpq-dev libpq5 +``` + +Arch: + +``` +sudo pacman -S postgresql-libs +``` + +#### Install Python dependencies + +Create and activate a Python 3 virtual environment: + +``` +virtualenv -p python3 env +source env/bin/activate +``` + +Install python dependencies: + +``` +pip install -e . +``` + +(Optional) If you want to use postgres as a database backend, use the following +command to install postgres dependencies alongside those that are necessary: + +``` +pip install ".[postgres]" +``` + +### Development dependencies + +There are some python dependencies that are required for linting/testing etc. +You can install them with: + +``` +pip install -e ".[dev]" +``` + +## Code style + +Please follow the [PEP8](https://www.python.org/dev/peps/pep-0008/) style +guidelines and format your import statements with +[isort](https://pypi.org/project/isort/). + +## Linting + +Run the following script to automatically format your code. This *should* make +the linting CI happy: + +``` +./scripts-dev/lint.sh +``` + +## What to work on + +Take a look at the [issues +list](https://github.com/anoadragon453/nio-template/issues). What +feature would you like to see or bug do you want to be fixed? + +If you would like to talk any ideas over before working on them, you can reach +me at [@andrewm:amorgan.xyz](https://matrix.to/#/@andrewm:amorgan.xyz) +on matrix. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e63a63 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# Nio Template [![Built with matrix-nio](https://img.shields.io/badge/built%20with-matrix--nio-brightgreen)](https://github.com/poljar/matrix-nio) + +A template for creating bots with +[matrix-nio](https://github.com/poljar/matrix-nio). The documentation for +matrix-nio can be found +[here](https://matrix-nio.readthedocs.io/en/latest/nio.html). + +This repo contains a working Matrix echo bot that can be easily extended to your needs. Detailed documentation is included as well as a step-by-step guide on basic bot building. + +Features include out-of-the-box support for: + +* Bot commands +* SQLite3 and Postgres database backends +* Configuration files +* Multi-level logging +* Docker +* Participation in end-to-end encrypted rooms + +## Projects using nio-template + +* [anoadragon453/matrix-reminder-bot](https://github.com/anoadragon453/matrix-reminder-bot +) - A matrix bot to remind you about things +* [gracchus163/hopeless](https://github.com/gracchus163/hopeless) - COREbot for the Hope2020 conference Matrix server +* [alturiak/nio-smith](https://github.com/alturiak/nio-smith) - A modular bot for @matrix-org that can be dynamically +extended by plugins +* [anoadragon453/msc-chatbot](https://github.com/anoadragon453/msc-chatbot) - A matrix bot for matrix spec proposals +* [anoadragon453/matrix-episode-bot](https://github.com/anoadragon453/matrix-episode-bot) - A matrix bot to post episode links +* [TheForcer/vision-nio](https://github.com/TheForcer/vision-nio) - A general purpose matrix chatbot +* [anoadragon453/drawing-challenge-bot](https://github.com/anoadragon453/drawing-challenge-bot) - A matrix bot to +post historical, weekly art challenges from reddit to a room +* [8go/matrix-eno-bot](https://github.com/8go/matrix-eno-bot) - A bot to be used as a) personal assistant or b) as +an admin tool to maintain your Matrix installation or server +* [elokapina/bubo](https://github.com/elokapina/bubo) - Matrix bot to help with community management +* [elokapina/middleman](https://github.com/elokapina/middleman) - Matrix bot to act as a middleman, for example as a support bot +* [chc4/matrix-pinbot](https://github.com/chc4/matrix-pinbot) - Matrix bot for pinning messages to a dedicated channel + +Want your project listed here? [Edit this +page!](https://github.com/anoadragon453/nio-template/edit/master/README.md) + +## Getting started + +See [SETUP.md](SETUP.md) for how to setup and run the template project. + +## Project structure + +*A reference of each file included in the template repository, its purpose and +what it does.* + +The majority of the code is kept inside of the `my_project_name` folder, which +is in itself a [python package](https://docs.python.org/3/tutorial/modules.html), +the `__init__.py` file inside declaring it as such. + +To run the bot, the `my-project-name` script in the root of the codebase is +available. It will import the `main` function from the `main.py` file in the +package and run it. To properly install this script into your python environment, +run `pip install -e .` in the project's root directory. + +`setup.py` contains package information (for publishing your code to +[PyPI](https://pypi.org)) and `setup.cfg` just contains some configuration +options for linting tools. + +`sample.config.yaml` is a sample configuration file. People running your bot +should be advised to copy this file to `config.yaml`, then edit it according to +their needs. Be sure never to check the edited `config.yaml` into source control +since it'll likely contain sensitive details such as passwords! + +Below is a detailed description of each of the source code files contained within +the `my_project_name` directory: + +### `main.py` + +Initialises the config file, the bot store, and nio's AsyncClient (which is +used to retrieve and send events to a matrix homeserver). It also registering +some callbacks on the AsyncClient to tell it to call some functions when +certain events are received (such as an invite to a room, or a new message in a +room the bot is in). + +It also starts the sync loop. Matrix clients "sync" with a homeserver, by +asking constantly asking for new events. Each time they do, the client gets a +sync token (stored in the `next_batch` field of the sync response). If the +client provides this token the next time it syncs (using the `since` parameter +on the `AsyncClient.sync` method), the homeserver will only return new event +*since* those specified by the given token. + +This token is saved and provided again automatically by using the +`client.sync_forever(...)` method. + +### `config.py` + +This file reads a config file at a given path (hardcoded as `config.yaml` in +`main.py`), processes everything in it and makes the values available to the +rest of the bot's code so it knows what to do. Most of the options in the given +config file have default values, so things will continue to work even if an +option is left out of the config file. Obviously there are some config values +that are required though, like the homeserver URL, username, access token etc. +Otherwise the bot can't function. + +### `storage.py` + +Creates (if necessary) and connects to a SQLite3 database and provides commands +to put or retrieve data from it. Table definitions should be specified in +`_initial_setup`, and any necessary migrations should be put in +`_run_migrations`. There's currently no defined method for how migrations +should work though. + +### `callbacks.py` + +Holds callback methods which get run when the bot get a certain type of event +from the homserver during sync. The type and name of the method to be called +are specified in `main.py`. Currently there are two defined methods, one that +gets called when a message is sent in a room the bot is in, and another that +runs when the bot receives an invite to the room. + +The message callback function, `message`, checks if the message was for the +bot, and whether it was a command. If both of those are true, the bot will +process that command. + +The invite callback function, `invite`, processes the invite event and attempts +to join the room. This way, the bot will auto-join any room it is invited to. + +### `bot_commands.py` + +Where all the bot's commands are defined. New commands should be defined in +`process` with an associated private method. `echo` and `help` commands are +provided by default. + +A `Command` object is created when a message comes in that's recognised as a +command from a user directed at the bot (either through the specified command +prefix (defined by the bot's config file), or through a private message +directly to the bot. The `process` command is then called for the bot to act on +that command. + +### `message_responses.py` + +Where responses to messages that are posted in a room (but not necessarily +directed at the bot) are specified. `callbacks.py` will listen for messages in +rooms the bot is in, and upon receiving one will create a new `Message` object +(which contains the message text, amongst other things) and calls `process()` +on it, which can send a message to the room as it sees fit. + +A good example of this would be a Github bot that listens for people mentioning +issue numbers in chat (e.g. "We should fix #123"), and the bot sending messages +to the room immediately afterwards with the issue name and link. + +### `chat_functions.py` + +A separate file to hold helper methods related to messaging. Mostly just for +organisational purposes. Currently just holds `send_text_to_room`, a helper +method for sending formatted messages to a room. + +### `errors.py` + +Custom error types for the bot. Currently there's only one special type that's +defined for when a error is found while the config file is being processed. + +## Questions? + +Any questions? Please ask them in +[#nio-template:amorgan.xyz](https://matrix.to/#/!vmWBOsOkoOtVHMzZgN:amorgan.xyz?via=amorgan.xyz) +and we'll help you out! diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..49f91ad --- /dev/null +++ b/SETUP.md @@ -0,0 +1,173 @@ +# Setup + +nio-template is a sample repository of a working Matrix bot that can be taken +and transformed into one's own bot, service or whatever else may be necessary. +Below is a quick setup guide to running the existing bot. + +## Install the dependencies + +There are two paths to installing the dependencies for development. + +### Using `docker-compose` + +It is **recommended** to use Docker Compose to run the bot while +developing, as all necessary dependencies are handled for you. After +installation and ensuring the `docker-compose` command works, you need to: + +1. Create a data directory and config file by following the + [docker setup instructions](docker#setup). + +2. Create a docker volume pointing to that directory: + + ``` + docker volume create \ + --opt type=none \ + --opt o=bind \ + --opt device="/path/to/data/dir" data_volume + ``` + +Run `docker/start-dev.sh` to start the bot. + +**Note:** If you are trying to connect to a Synapse instance running on the +host, you need to allow the IP address of the docker container to connect. This +is controlled by `bind_addresses` in the `listeners` section of Synapse's +config. If present, either add the docker internal IP address to the list, or +remove the option altogether to allow all addresses. + +### Running natively + +If you would rather not or are unable to run docker, the following will +instruct you on how to install the dependencies natively: + +#### Install libolm + +You can install [libolm](https://gitlab.matrix.org/matrix-org/olm) from source, +or alternatively, check your system's package manager. Version `3.0.0` or +greater is required. + +**(Optional) postgres development headers** + +By default, the bot uses SQLite as its storage backend. This is fine for a few +hundred users, but if you plan to support a much higher volume of requests, you +may consider using Postgres as a database backend instead. + +If you want to use postgres as a database backend, you'll need to install +postgres development headers: + +Debian/Ubuntu: + +``` +sudo apt install libpq-dev libpq5 +``` + +Arch: + +``` +sudo pacman -S postgresql-libs +``` + +#### Install Python dependencies + +Create and activate a Python 3 virtual environment: + +``` +virtualenv -p python3 env +source env/bin/activate +``` + +Install python dependencies: + +``` +pip install -e . +``` + +(Optional) If you want to use postgres as a database backend, use the following +command to install postgres dependencies alongside those that are necessary: + +``` +pip install -e ".[postgres]" +``` + +## Configuration + +Copy the sample configuration file to a new `config.yaml` file. + +``` +cp sample.config.yaml config.yaml +``` + +Edit the config file. The `matrix` section must be modified at least. + +#### (Optional) Set up a Postgres database + +Create a postgres user and database for matrix-reminder-bot: + +``` +sudo -u postgresql psql createuser nio-template -W # prompts for a password +sudo -u postgresql psql createdb -O nio-template nio-template +``` + +Edit the `storage.database` config option, replacing the `sqlite://...` string with `postgres://...`. The syntax is: + +``` +database: "postgres://username:password@localhost/dbname?sslmode=disable" +``` + +See also the comments in `sample.config.yaml`. + +## Running + +### Docker + +Refer to the docker [run instructions](docker/README.md#running). + +### Native installation + +Make sure to source your python environment if you haven't already: + +``` +source env/bin/activate +``` + +Then simply run the bot with: + +``` +my-project-name +``` + +You'll notice that "my-project-name" is scattered throughout the codebase. When +it comes time to modifying the code for your own purposes, you are expected to +replace every instance of "my-project-name" and its variances with your own +project's name. + +By default, the bot will run with the config file at `./config.yaml`. However, an +alternative relative or absolute filepath can be specified after the command: + +``` +my-project-name other-config.yaml +``` + +## Testing the bot works + +Invite the bot to a room and it should accept the invite and join. + +By default nio-template comes with an `echo` command. Let's test this now. +After the bot has successfully joined the room, try sending the following +in a message: + +``` +!c echo I am a bot! +``` + +The message should be repeated back to you by the bot. + +## Going forwards + +Congratulations! Your bot is up and running. Now you can modify the code, +re-run the bot and see how it behaves. Have fun! + +## Troubleshooting + +If you had any difficulties with this setup process, please [file an +issue](https://github.com/anoadragon453/nio-template/issues]) or come talk +about it in [the matrix room](https://matrix.to/#/#nio-template). diff --git a/bot.db b/bot.db new file mode 100644 index 0000000000000000000000000000000000000000..6bdea39be5e470ade3222df27c9804ed930dd825 GIT binary patch literal 8192 zcmWFz^vNtqRY=P(%1ta$FlG>7U}R))P*7lCU|@n`1}I=;U|?W@vOyGx52Ep5CI&tK zT3!YQ21fo82L2L!N=M~JLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(c!fYC zCnLMKzCL54V@YCCPHJvudQoCYW`16LS!z*nW_})q0F!f&t7C|(LWrZ2kE;TPw1Nhg z0vdptq~Pfn;_B`iq!19~>FXF2so?DziK12$(l`IZ!2f4>h4H9wMnhmU1V%$(Gz3ON iU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0EA;84I!~g( +# +# Example: +# +# ./build_and_install_libolm.sh 3.1.4 /python-bindings +# +# Note that if a python bindings installation directory is not supplied, bindings will +# be installed to the default directory. +# + +set -ex + +# Download the specified version of libolm +git clone -b "$1" https://gitlab.matrix.org/matrix-org/olm.git olm && cd olm + +# Build libolm +cmake . -Bbuild +cmake --build build + +# Install +make install + +# Build the python3 bindings +cd python && make olm-python3 + +# Install python3 bindings +mkdir -p "$2" || true +DESTDIR="$2" make install-python3 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..dd1334c --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,64 @@ +version: '3.1' # specify docker-compose version + +volumes: + # Set up with `docker volume create ...`. See docker/README.md for more info. + data_volume: + external: true + pg_data_volume: + +services: + # Runs from the latest release + my-project-name: + image: somebody/my-project-name + restart: always + volumes: + - data_volume:/data + # Used for allowing connections to homeservers hosted on the host machine + # (while docker host mode is still broken on Linux). + # + # Defaults to 127.0.0.1 and is set in docker/.env + extra_hosts: + - "localhost:${HOST_IP_ADDRESS}" + + # Builds and runs an optimized container from local code + local-checkout: + build: + context: .. + dockerfile: docker/Dockerfile + # Build arguments may be specified here + # args: + # PYTHON_VERSION: 3.8 + volumes: + - data_volume:/data + # Used for allowing connections to homeservers hosted on the host machine + # (while docker host networking mode is still broken on Linux). + # + # Defaults to 127.0.0.1 and is set in docker/.env + extra_hosts: + - "localhost:${HOST_IP_ADDRESS}" + + # Builds and runs a development container from local code + local-checkout-dev: + build: + context: .. + dockerfile: docker/Dockerfile.dev + # Build arguments may be specified here + # args: + # PYTHON_VERSION: 3.8 + volumes: + - data_volume:/data + # Used for allowing connections to homeservers hosted on the host machine + # (while docker host networking mode is still broken on Linux). + # + # Defaults to 127.0.0.1 and is set in docker/.env + extra_hosts: + - "localhost:${HOST_IP_ADDRESS}" + + # Starts up a postgres database + postgres: + image: postgres + restart: always + volumes: + - pg_data_volume:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: somefancypassword diff --git a/docker/my-project-name.service b/docker/my-project-name.service new file mode 100644 index 0000000..daa99d1 --- /dev/null +++ b/docker/my-project-name.service @@ -0,0 +1,16 @@ +[Unit] +Description=A matrix bot that does amazing things! + +[Service] +Type=simple +User=my-project-name +Group=my-project-name +WorkingDirectory=/path/to/my-project-name/docker +ExecStart=/usr/bin/docker-compose up my-project-name +ExecStop=/usr/bin/docker-compose stop my-project-name +RemainAfterExit=yes +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/docker/start-dev.sh b/docker/start-dev.sh new file mode 100755 index 0000000..4fa45e0 --- /dev/null +++ b/docker/start-dev.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# A script to quickly setup a running development environment +# +# It's primary purpose is to set up docker networking correctly so that +# the bot can connect to remote services as well as those hosted on +# the host machine. +# + +# Change directory to where this script is located. We'd like to run +# `docker-compose` in the same directory to use the adjacent +# docker-compose.yml and .env files +cd `dirname "$0"` + +function on_exit { + cd - +} + +# Ensure we change back to the old directory on script exit +trap on_exit EXIT + +# To allow the docker container to connect to services running on the host, +# we need to use the host's internal ip address. Attempt to retrieve this. +# +# Check whether the ip address has been defined in the environment already +if [ -z "$HOST_IP_ADDRESS" ]; then + # It's not defined. Try to guess what it is + + # First we try the `ip` command, available primarily on Linux + export HOST_IP_ADDRESS="`ip route get 1 | sed -n 's/^.*src \([0-9.]*\) .*$/\1/p'`" + + if [ $? -ne 0 ]; then + # That didn't work. `ip` isn't available on old Linux systems, or MacOS. + # Try `ifconfig` instead + export HOST_IP_ADDRESS="`ifconfig $(netstat -rn | grep -E "^default|^0.0.0.0" | head -1 | awk '{print $NF}') | grep 'inet ' | awk '{print $2}' | grep -Eo '([0-9]*\.){3}[0-9]*'`" + + if [ $? -ne 0 ]; then + # That didn't work either, give up + echo " +Unable to determine host machine's internal IP address. +Please set HOST_IP_ADDRESS environment variable manually and re-run this script. +If you do not have a need to connect to a homeserver running on the host machine, +set HOST_IP_ADDRESS=127.0.0.1" + exit 1 + fi + fi +fi + +# Build and run latest code +docker-compose up --build local-checkout-dev diff --git a/my-project-name b/my-project-name new file mode 100755 index 0000000..4b9c8ef --- /dev/null +++ b/my-project-name @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +import asyncio + +try: + from my_project_name import main + + # Run the main function of the bot + asyncio.get_event_loop().run_until_complete(main.main()) +except ImportError as e: + print("Unable to import my_project_name.main:", e) diff --git a/my_project_name.egg-info/PKG-INFO b/my_project_name.egg-info/PKG-INFO new file mode 100644 index 0000000..9884ed3 --- /dev/null +++ b/my_project_name.egg-info/PKG-INFO @@ -0,0 +1,180 @@ +Metadata-Version: 2.1 +Name: my-project-name +Version: 0.0.1 +Summary: A matrix bot to do amazing things! +Home-page: https://github.com/anoadragon453/nio-template +License: UNKNOWN +Platform: UNKNOWN +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Description-Content-Type: text/markdown +Provides-Extra: postgres +Provides-Extra: dev +License-File: LICENSE + +# Nio Template [![Built with matrix-nio](https://img.shields.io/badge/built%20with-matrix--nio-brightgreen)](https://github.com/poljar/matrix-nio) + +A template for creating bots with +[matrix-nio](https://github.com/poljar/matrix-nio). The documentation for +matrix-nio can be found +[here](https://matrix-nio.readthedocs.io/en/latest/nio.html). + +This repo contains a working Matrix echo bot that can be easily extended to your needs. Detailed documentation is included as well as a step-by-step guide on basic bot building. + +Features include out-of-the-box support for: + +* Bot commands +* SQLite3 and Postgres database backends +* Configuration files +* Multi-level logging +* Docker +* Participation in end-to-end encrypted rooms + +## Projects using nio-template + +* [anoadragon453/matrix-reminder-bot](https://github.com/anoadragon453/matrix-reminder-bot +) - A matrix bot to remind you about things +* [gracchus163/hopeless](https://github.com/gracchus163/hopeless) - COREbot for the Hope2020 conference Matrix server +* [alturiak/nio-smith](https://github.com/alturiak/nio-smith) - A modular bot for @matrix-org that can be dynamically +extended by plugins +* [anoadragon453/msc-chatbot](https://github.com/anoadragon453/msc-chatbot) - A matrix bot for matrix spec proposals +* [anoadragon453/matrix-episode-bot](https://github.com/anoadragon453/matrix-episode-bot) - A matrix bot to post episode links +* [TheForcer/vision-nio](https://github.com/TheForcer/vision-nio) - A general purpose matrix chatbot +* [anoadragon453/drawing-challenge-bot](https://github.com/anoadragon453/drawing-challenge-bot) - A matrix bot to +post historical, weekly art challenges from reddit to a room +* [8go/matrix-eno-bot](https://github.com/8go/matrix-eno-bot) - A bot to be used as a) personal assistant or b) as +an admin tool to maintain your Matrix installation or server +* [elokapina/bubo](https://github.com/elokapina/bubo) - Matrix bot to help with community management +* [elokapina/middleman](https://github.com/elokapina/middleman) - Matrix bot to act as a middleman, for example as a support bot +* [chc4/matrix-pinbot](https://github.com/chc4/matrix-pinbot) - Matrix bot for pinning messages to a dedicated channel + +Want your project listed here? [Edit this +page!](https://github.com/anoadragon453/nio-template/edit/master/README.md) + +## Getting started + +See [SETUP.md](SETUP.md) for how to setup and run the template project. + +## Project structure + +*A reference of each file included in the template repository, its purpose and +what it does.* + +The majority of the code is kept inside of the `my_project_name` folder, which +is in itself a [python package](https://docs.python.org/3/tutorial/modules.html), +the `__init__.py` file inside declaring it as such. + +To run the bot, the `my-project-name` script in the root of the codebase is +available. It will import the `main` function from the `main.py` file in the +package and run it. To properly install this script into your python environment, +run `pip install -e .` in the project's root directory. + +`setup.py` contains package information (for publishing your code to +[PyPI](https://pypi.org)) and `setup.cfg` just contains some configuration +options for linting tools. + +`sample.config.yaml` is a sample configuration file. People running your bot +should be advised to copy this file to `config.yaml`, then edit it according to +their needs. Be sure never to check the edited `config.yaml` into source control +since it'll likely contain sensitive details such as passwords! + +Below is a detailed description of each of the source code files contained within +the `my_project_name` directory: + +### `main.py` + +Initialises the config file, the bot store, and nio's AsyncClient (which is +used to retrieve and send events to a matrix homeserver). It also registering +some callbacks on the AsyncClient to tell it to call some functions when +certain events are received (such as an invite to a room, or a new message in a +room the bot is in). + +It also starts the sync loop. Matrix clients "sync" with a homeserver, by +asking constantly asking for new events. Each time they do, the client gets a +sync token (stored in the `next_batch` field of the sync response). If the +client provides this token the next time it syncs (using the `since` parameter +on the `AsyncClient.sync` method), the homeserver will only return new event +*since* those specified by the given token. + +This token is saved and provided again automatically by using the +`client.sync_forever(...)` method. + +### `config.py` + +This file reads a config file at a given path (hardcoded as `config.yaml` in +`main.py`), processes everything in it and makes the values available to the +rest of the bot's code so it knows what to do. Most of the options in the given +config file have default values, so things will continue to work even if an +option is left out of the config file. Obviously there are some config values +that are required though, like the homeserver URL, username, access token etc. +Otherwise the bot can't function. + +### `storage.py` + +Creates (if necessary) and connects to a SQLite3 database and provides commands +to put or retrieve data from it. Table definitions should be specified in +`_initial_setup`, and any necessary migrations should be put in +`_run_migrations`. There's currently no defined method for how migrations +should work though. + +### `callbacks.py` + +Holds callback methods which get run when the bot get a certain type of event +from the homserver during sync. The type and name of the method to be called +are specified in `main.py`. Currently there are two defined methods, one that +gets called when a message is sent in a room the bot is in, and another that +runs when the bot receives an invite to the room. + +The message callback function, `message`, checks if the message was for the +bot, and whether it was a command. If both of those are true, the bot will +process that command. + +The invite callback function, `invite`, processes the invite event and attempts +to join the room. This way, the bot will auto-join any room it is invited to. + +### `bot_commands.py` + +Where all the bot's commands are defined. New commands should be defined in +`process` with an associated private method. `echo` and `help` commands are +provided by default. + +A `Command` object is created when a message comes in that's recognised as a +command from a user directed at the bot (either through the specified command +prefix (defined by the bot's config file), or through a private message +directly to the bot. The `process` command is then called for the bot to act on +that command. + +### `message_responses.py` + +Where responses to messages that are posted in a room (but not necessarily +directed at the bot) are specified. `callbacks.py` will listen for messages in +rooms the bot is in, and upon receiving one will create a new `Message` object +(which contains the message text, amongst other things) and calls `process()` +on it, which can send a message to the room as it sees fit. + +A good example of this would be a Github bot that listens for people mentioning +issue numbers in chat (e.g. "We should fix #123"), and the bot sending messages +to the room immediately afterwards with the issue name and link. + +### `chat_functions.py` + +A separate file to hold helper methods related to messaging. Mostly just for +organisational purposes. Currently just holds `send_text_to_room`, a helper +method for sending formatted messages to a room. + +### `errors.py` + +Custom error types for the bot. Currently there's only one special type that's +defined for when a error is found while the config file is being processed. + +## Questions? + +Any questions? Please ask them in +[#nio-template:amorgan.xyz](https://matrix.to/#/!vmWBOsOkoOtVHMzZgN:amorgan.xyz?via=amorgan.xyz) +and we'll help you out! + + diff --git a/my_project_name.egg-info/SOURCES.txt b/my_project_name.egg-info/SOURCES.txt new file mode 100644 index 0000000..e2dc7ea --- /dev/null +++ b/my_project_name.egg-info/SOURCES.txt @@ -0,0 +1,20 @@ +LICENSE +README.md +my-project-name +setup.cfg +setup.py +my_project_name/__init__.py +my_project_name/bot_commands.py +my_project_name/caldav_handler.py +my_project_name/callbacks.py +my_project_name/chat_functions.py +my_project_name/config.py +my_project_name/errors.py +my_project_name/main.py +my_project_name/message_responses.py +my_project_name/storage.py +my_project_name.egg-info/PKG-INFO +my_project_name.egg-info/SOURCES.txt +my_project_name.egg-info/dependency_links.txt +my_project_name.egg-info/requires.txt +my_project_name.egg-info/top_level.txt \ No newline at end of file diff --git a/my_project_name.egg-info/dependency_links.txt b/my_project_name.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/my_project_name.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/my_project_name.egg-info/requires.txt b/my_project_name.egg-info/requires.txt new file mode 100644 index 0000000..67871ec --- /dev/null +++ b/my_project_name.egg-info/requires.txt @@ -0,0 +1,15 @@ +matrix-nio[e2e]>=0.10.0 +Markdown>=3.1.1 +PyYAML>=5.1.2 +python-dateutil>=2.0.0 +caldav +icalendar + +[dev] +isort==5.0.4 +flake8==3.8.3 +flake8-comprehensions==3.2.3 +black==19.10b0 + +[postgres] +psycopg2>=2.8.5 diff --git a/my_project_name.egg-info/top_level.txt b/my_project_name.egg-info/top_level.txt new file mode 100644 index 0000000..e1b8a64 --- /dev/null +++ b/my_project_name.egg-info/top_level.txt @@ -0,0 +1 @@ +my_project_name diff --git a/my_project_name/__init__.py b/my_project_name/__init__.py new file mode 100644 index 0000000..b2df4de --- /dev/null +++ b/my_project_name/__init__.py @@ -0,0 +1,8 @@ +import sys + +# Check that we're not running on an unsupported Python version. +if sys.version_info < (3, 5): + print("my_project_name requires Python 3.5 or above.") + sys.exit(1) + +__version__ = "0.0.1" diff --git a/my_project_name/__pycache__/__init__.cpython-39.pyc b/my_project_name/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3080c73cf09633e79154a6c319a59b06286e26fb GIT binary patch literal 339 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 literal 0 HcmV?d00001 diff --git a/my_project_name/__pycache__/caldav_handler.cpython-39.pyc b/my_project_name/__pycache__/caldav_handler.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..629dbe1312d12c754f6206375ff246e2d5ec6b38 GIT binary patch literal 3760 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 UykZ9m#$u3LIT!^vS(vz(0lHzHpa1{> literal 0 HcmV?d00001 diff --git a/my_project_name/bot_commands.py b/my_project_name/bot_commands.py new file mode 100644 index 0000000..972a5ef --- /dev/null +++ b/my_project_name/bot_commands.py @@ -0,0 +1,120 @@ +from nio import AsyncClient, MatrixRoom, RoomMessageText + +from my_project_name.chat_functions import react_to_event, send_text_to_room +from my_project_name.config import Config +from my_project_name.storage import Storage +from my_project_name.caldav_handler import CaldavHandler + + +class Command: + def __init__( + self, + client: AsyncClient, + store: Storage, + config: Config, + command: str, + room: MatrixRoom, + event: RoomMessageText, + ): + """A command made by a user. + + Args: + client: The client to communicate to matrix with. + + store: Bot storage. + + config: Bot configuration parameters. + + command: The command and arguments. + + room: The room the command was sent in. + + event: The event describing the command. + """ + self.client = client + self.store = store + self.config = config + self.command = command + self.room = room + self.event = event + self.args = self.command.split()[1:] + + async def process(self): + """Process the command""" + #if self.command.startswith("echo"): + # await self._echo() + if self.command.startswith("react"): + await self._react() + elif self.command.startswith("help"): + await self._show_help() + elif self.command.startswith("today"): + await self._show_today() + elif self.command.startswith("week"): + await self._show_week() + elif self.command.startswith("month"): + await self._show_month() + #else: + # await self._unknown_command() + + async def _show_today(self): + handler = CaldavHandler() + response = handler.print_today() + if len(response) == 0: + response = "today is nothing planned yet. riot or read theory" + await send_text_to_room(self.client, self.room.room_id, response) + + async def _show_week(self): + handler = CaldavHandler() + response = handler.print_week() + await send_text_to_room(self.client, self.room.room_id, response) + + async def _show_month(self): + handler = CaldavHandler() + response = handler.print_month() + await send_text_to_room(self.client, self.room.room_id, response, markdown_convert=True) + + async def _echo(self): + """Echo back the command's arguments""" + response = " ".join(self.args) + await send_text_to_room(self.client, self.room.room_id, response) + + async def _react(self): + """Make the bot react to the command message""" + # React with a start emoji + reaction = "⭐" + await react_to_event( + self.client, self.room.room_id, self.event.event_id, reaction + ) + + # React with some generic text + reaction = "(A)" + await react_to_event( + self.client, self.room.room_id, self.event.event_id, reaction + ) + + async def _show_help(self): + """Show the help text""" + if not self.args: + text = ( + "Hello, I am kallauser MC's (A)wesome calendar bot <3! Use `help commands` to view " + "available commands.\n" + "Use `help rules` to view the rules" + ) + await send_text_to_room(self.client, self.room.room_id, text) + return + + topic = self.args[0] + if topic == "rules": + text = "be nice to each other." + elif topic == "commands": + text = "Available commands: today, week, month" + else: + text = "I dont know what you are talking about.." + await send_text_to_room(self.client, self.room.room_id, text) + + async def _unknown_command(self): + await send_text_to_room( + self.client, + self.room.room_id, + f"Unknown command '{self.command}'. Try the 'help' command for more information.", + ) diff --git a/my_project_name/caldav_handler.py b/my_project_name/caldav_handler.py new file mode 100644 index 0000000..a5b1da7 --- /dev/null +++ b/my_project_name/caldav_handler.py @@ -0,0 +1,105 @@ +import sys +import time +import logging +import collections +from os.path import exists +import json +import dateutil.rrule as rrule + +import caldav +import pytz +from icalendar import Calendar, Event +import datetime +import caldav + + +class CaldavHandler: + def get_config(self, path): + with open("./config.json") as f: + return json.load(f) + + def __init__(self): + self._config_path = "./config.json" + + if not exists(self._config_path): + print("No config file found. Aborting.") + + self._config = self.get_config(self._config_path) + self._caldavclient = caldav.DAVClient(self._config["caldav"]["url"], + username=self._config["caldav"]["username"], + password=self._config["caldav"]["password"]) + + + def get_event_map(self, events, time_span): + result = {} + for event in events: + event.load() + e = event.instance.vevent + + list_of_occurences = [] + + if e.getChildValue('rrule') == None: + list_of_occurences.append(e.getChildValue('dtstart')) + else: + #recurring events only return with the date of the first recurring event ever created + #we have to use rrule manually to expand the dates to display them correctly + rule = rrule.rrulestr(e.getChildValue('rrule'), dtstart=e.getChildValue('dtstart')) + list_of_occurences = rule.between(datetime.datetime.now(datetime.timezone.utc), + datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=time_span), + inc=True) + + for datetime_of_event in list_of_occurences: + datestr = datetime_of_event.strftime("%x") + eventstr = str( "(" + e.dtstart.value.strftime("%H:%M") + " - " + e.dtend.value.strftime("%H:%M") + ") " + e.summary.value) + + if datestr in result: + result[datestr].append(eventstr) + else: + result[datestr] = [ eventstr ] + + #sort start times + for key in result: + result[key].sort() + + od = collections.OrderedDict(sorted(result.items())) + return od + + def event_map_to_string(self, event_map): + result = "" + for k, v in event_map.items(): + dt_string = k + format = "%x" + dt_object = datetime.datetime.strptime(dt_string, format) + result += "##### " + dt_object.strftime("%A, %d. of %B") + ":\n" + for event in v: + result += "* " + event + "\n" + + print(result) + return result + + def event_to_string(self, event): + event.load() + e = event.instance.vevent + datestr = e.dtstart.value.strftime("%X") + return str( "(" + e.dtstart.value.strftime("%a, %-d. %b - %H:%M") + " - " + e.dtend.value.strftime("%H:%M") + ") " + e.summary.value) + + def get_events(self, start_time, end_time): + cal = self._caldavclient.principal().calendars() + for ca in cal: + events = ca.date_search(start=start_time, end=end_time, expand=True) + return events + + def send_events(self, events, time_span): + return self.event_map_to_string(self.get_event_map(events, time_span)) + + def print_month(self): + events = self.get_events(datetime.date.today(), datetime.date.today() + datetime.timedelta(days=30)) + return self.send_events(events, 30) + + def print_week(self): + events = self.get_events(datetime.date.today(), datetime.date.today() + datetime.timedelta(days=7)) + return self.send_events(events, 7) + + def print_today(self): + events = self.get_events(datetime.date.today(), datetime.date.today() + datetime.timedelta(days=1)) + return self.send_events(events, 1) diff --git a/my_project_name/callbacks.py b/my_project_name/callbacks.py new file mode 100644 index 0000000..08bbbc1 --- /dev/null +++ b/my_project_name/callbacks.py @@ -0,0 +1,198 @@ +import logging + +from nio import ( + AsyncClient, + InviteMemberEvent, + JoinError, + MatrixRoom, + MegolmEvent, + RoomGetEventError, + RoomMessageText, + UnknownEvent, +) + +from my_project_name.bot_commands import Command +from my_project_name.chat_functions import make_pill, react_to_event, send_text_to_room +from my_project_name.config import Config +from my_project_name.message_responses import Message +from my_project_name.storage import Storage + +logger = logging.getLogger(__name__) + + +class Callbacks: + def __init__(self, client: AsyncClient, store: Storage, config: Config): + """ + Args: + client: nio client used to interact with matrix. + + store: Bot storage. + + config: Bot configuration parameters. + """ + self.client = client + self.store = store + self.config = config + self.command_prefix = config.command_prefix + + async def message(self, room: MatrixRoom, event: RoomMessageText) -> None: + """Callback for when a message event is received + + Args: + room: The room the event came from. + + event: The event defining the message. + """ + # Extract the message text + msg = event.body + + # Ignore messages from ourselves + if event.sender == self.client.user: + return + + logger.debug( + f"Bot message received for room {room.display_name} | " + f"{room.user_name(event.sender)}: {msg}" + ) + + # Process as message if in a public room without command prefix + has_command_prefix = msg.startswith(self.command_prefix) + + # room.is_group is often a DM, but not always. + # room.is_group does not allow room aliases + # room.member_count > 2 ... we assume a public room + # room.member_count <= 2 ... we assume a DM + if not has_command_prefix and room.member_count > 2: + # General message listener + message = Message(self.client, self.store, self.config, msg, room, event) + await message.process() + return + + # Otherwise if this is in a 1-1 with the bot or features a command prefix, + # treat it as a command + if has_command_prefix: + # Remove the command prefix + msg = msg[len(self.command_prefix) :] + + command = Command(self.client, self.store, self.config, msg, room, event) + await command.process() + + async def invite(self, room: MatrixRoom, event: InviteMemberEvent) -> None: + """Callback for when an invite is received. Join the room specified in the invite. + + Args: + room: The room that we are invited to. + + event: The invite event. + """ + logger.debug(f"Got invite to {room.room_id} from {event.sender}.") + + # Attempt to join 3 times before giving up + for attempt in range(3): + result = await self.client.join(room.room_id) + if type(result) == JoinError: + logger.error( + f"Error joining room {room.room_id} (attempt %d): %s", + attempt, + result.message, + ) + else: + break + else: + logger.error("Unable to join room: %s", room.room_id) + + # Successfully joined room + logger.info(f"Joined {room.room_id}") + + async def _reaction( + self, room: MatrixRoom, event: UnknownEvent, reacted_to_id: str + ) -> None: + """A reaction was sent to one of our messages. Let's send a reply acknowledging it. + + Args: + room: The room the reaction was sent in. + + event: The reaction event. + + reacted_to_id: The event ID that the reaction points to. + """ + logger.debug(f"Got reaction to {room.room_id} from {event.sender}.") + + # Get the original event that was reacted to + event_response = await self.client.room_get_event(room.room_id, reacted_to_id) + if isinstance(event_response, RoomGetEventError): + logger.warning( + "Error getting event that was reacted to (%s)", reacted_to_id + ) + return + reacted_to_event = event_response.event + + # Only acknowledge reactions to events that we sent + if reacted_to_event.sender != self.config.user_id: + return + + # Send a message acknowledging the reaction + reaction_sender_pill = make_pill(event.sender) + reaction_content = ( + event.source.get("content", {}).get("m.relates_to", {}).get("key") + ) + message = ( + f"{reaction_sender_pill} reacted to this event with `{reaction_content}`!" + ) + await send_text_to_room( + self.client, + room.room_id, + message, + reply_to_event_id=reacted_to_id, + ) + + async def decryption_failure(self, room: MatrixRoom, event: MegolmEvent) -> None: + """Callback for when an event fails to decrypt. Inform the user. + + Args: + room: The room that the event that we were unable to decrypt is in. + + event: The encrypted event that we were unable to decrypt. + """ + logger.error( + f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'!" + f"\n\n" + f"Tip: try using a different device ID in your config file and restart." + f"\n\n" + f"If all else fails, delete your store directory and let the bot recreate " + f"it (your reminders will NOT be deleted, but the bot may respond to existing " + f"commands a second time)." + ) + + red_x_and_lock_emoji = "❌ 🔐" + + # React to the undecryptable event with some emoji + await react_to_event( + self.client, + room.room_id, + event.event_id, + red_x_and_lock_emoji, + ) + + async def unknown(self, room: MatrixRoom, event: UnknownEvent) -> None: + """Callback for when an event with a type that is unknown to matrix-nio is received. + Currently this is used for reaction events, which are not yet part of a released + matrix spec (and are thus unknown to nio). + + Args: + room: The room the reaction was sent in. + + event: The event itself. + """ + if event.type == "m.reaction": + # Get the ID of the event this was a reaction to + relation_dict = event.source.get("content", {}).get("m.relates_to", {}) + + reacted_to = relation_dict.get("event_id") + if reacted_to and relation_dict.get("rel_type") == "m.annotation": + await self._reaction(room, event, reacted_to) + return + + logger.debug( + f"Got unknown event with type to {event.type} from {event.sender} in {room.room_id}." + ) diff --git a/my_project_name/chat_functions.py b/my_project_name/chat_functions.py new file mode 100644 index 0000000..136726f --- /dev/null +++ b/my_project_name/chat_functions.py @@ -0,0 +1,154 @@ +import logging +from typing import Optional, Union + +from markdown import markdown +from nio import ( + AsyncClient, + ErrorResponse, + MatrixRoom, + MegolmEvent, + Response, + RoomSendResponse, + SendRetryError, +) + +logger = logging.getLogger(__name__) + + +async def send_text_to_room( + client: AsyncClient, + room_id: str, + message: str, + notice: bool = True, + markdown_convert: bool = True, + reply_to_event_id: Optional[str] = None, +) -> Union[RoomSendResponse, ErrorResponse]: + """Send text to a matrix room. + + Args: + client: The client to communicate to matrix with. + + room_id: The ID of the room to send the message to. + + message: The message content. + + notice: Whether the message should be sent with an "m.notice" message type + (will not ping users). + + markdown_convert: Whether to convert the message content to markdown. + Defaults to true. + + reply_to_event_id: Whether this message is a reply to another event. The event + ID this is message is a reply to. + + Returns: + A RoomSendResponse if the request was successful, else an ErrorResponse. + """ + # Determine whether to ping room members or not + msgtype = "m.notice" if notice else "m.text" + + content = { + "msgtype": msgtype, + "format": "org.matrix.custom.html", + "body": message, + } + + if markdown_convert: + content["formatted_body"] = markdown(message) + + if reply_to_event_id: + content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}} + + try: + return await client.room_send( + room_id, + "m.room.message", + content, + ignore_unverified_devices=True, + ) + except SendRetryError: + logger.exception(f"Unable to send message response to {room_id}") + + +def make_pill(user_id: str, displayname: str = None) -> str: + """Convert a user ID (and optionally a display name) to a formatted user 'pill' + + Args: + user_id: The MXID of the user. + + displayname: An optional displayname. Clients like Element will figure out the + correct display name no matter what, but other clients may not. If not + provided, the MXID will be used instead. + + Returns: + The formatted user pill. + """ + if not displayname: + # Use the user ID as the displayname if not provided + displayname = user_id + + return f'{displayname}' + + +async def react_to_event( + client: AsyncClient, + room_id: str, + event_id: str, + reaction_text: str, +) -> Union[Response, ErrorResponse]: + """Reacts to a given event in a room with the given reaction text + + Args: + client: The client to communicate to matrix with. + + room_id: The ID of the room to send the message to. + + event_id: The ID of the event to react to. + + reaction_text: The string to react with. Can also be (one or more) emoji characters. + + Returns: + A nio.Response or nio.ErrorResponse if an error occurred. + + Raises: + SendRetryError: If the reaction was unable to be sent. + """ + content = { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": event_id, + "key": reaction_text, + } + } + + return await client.room_send( + room_id, + "m.reaction", + content, + ignore_unverified_devices=True, + ) + + +async def decryption_failure(self, room: MatrixRoom, event: MegolmEvent) -> None: + """Callback for when an event fails to decrypt. Inform the user""" + logger.error( + f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'!" + f"\n\n" + f"Tip: try using a different device ID in your config file and restart." + f"\n\n" + f"If all else fails, delete your store directory and let the bot recreate " + f"it (your reminders will NOT be deleted, but the bot may respond to existing " + f"commands a second time)." + ) + + user_msg = ( + "Unable to decrypt this message. " + "Check whether you've chosen to only encrypt to trusted devices." + ) + + await send_text_to_room( + self.client, + room.room_id, + user_msg, + reply_to_event_id=event.event_id, + ) diff --git a/my_project_name/config.py b/my_project_name/config.py new file mode 100644 index 0000000..3510d85 --- /dev/null +++ b/my_project_name/config.py @@ -0,0 +1,136 @@ +import logging +import os +import re +import sys +from typing import Any, List, Optional + +import yaml + +from my_project_name.errors import ConfigError + +logger = logging.getLogger() +logging.getLogger("peewee").setLevel( + logging.INFO +) # Prevent debug messages from peewee lib + + +class Config: + """Creates a Config object from a YAML-encoded config file from a given filepath""" + + def __init__(self, filepath: str): + self.filepath = filepath + if not os.path.isfile(filepath): + raise ConfigError(f"Config file '{filepath}' does not exist") + + # Load in the config file at the given filepath + with open(filepath) as file_stream: + self.config_dict = yaml.safe_load(file_stream.read()) + + # Parse and validate config options + self._parse_config_values() + + def _parse_config_values(self): + """Read and validate each config option""" + # Logging setup + formatter = logging.Formatter( + "%(asctime)s | %(name)s [%(levelname)s] %(message)s" + ) + + log_level = self._get_cfg(["logging", "level"], default="INFO") + logger.setLevel(log_level) + + file_logging_enabled = self._get_cfg( + ["logging", "file_logging", "enabled"], default=False + ) + file_logging_filepath = self._get_cfg( + ["logging", "file_logging", "filepath"], default="bot.log" + ) + if file_logging_enabled: + handler = logging.FileHandler(file_logging_filepath) + handler.setFormatter(formatter) + logger.addHandler(handler) + + console_logging_enabled = self._get_cfg( + ["logging", "console_logging", "enabled"], default=True + ) + if console_logging_enabled: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + logger.addHandler(handler) + + # Storage setup + self.store_path = self._get_cfg(["storage", "store_path"], required=True) + + # Create the store folder if it doesn't exist + if not os.path.isdir(self.store_path): + if not os.path.exists(self.store_path): + os.mkdir(self.store_path) + else: + raise ConfigError( + f"storage.store_path '{self.store_path}' is not a directory" + ) + + # Database setup + database_path = self._get_cfg(["storage", "database"], required=True) + + # Support both SQLite and Postgres backends + # Determine which one the user intends + sqlite_scheme = "sqlite://" + postgres_scheme = "postgres://" + if database_path.startswith(sqlite_scheme): + self.database = { + "type": "sqlite", + "connection_string": database_path[len(sqlite_scheme) :], + } + elif database_path.startswith(postgres_scheme): + self.database = {"type": "postgres", "connection_string": database_path} + else: + raise ConfigError("Invalid connection string for storage.database") + + # Matrix bot account setup + self.user_id = self._get_cfg(["matrix", "user_id"], required=True) + if not re.match("@.*:.*", self.user_id): + raise ConfigError("matrix.user_id must be in the form @name:domain") + + self.user_password = self._get_cfg(["matrix", "user_password"], required=False) + self.user_token = self._get_cfg(["matrix", "user_token"], required=False) + if not self.user_token and not self.user_password: + raise ConfigError("Must supply either user token or password") + + self.device_id = self._get_cfg(["matrix", "device_id"], required=True) + self.device_name = self._get_cfg( + ["matrix", "device_name"], default="nio-template" + ) + self.homeserver_url = self._get_cfg(["matrix", "homeserver_url"], required=True) + + self.command_prefix = self._get_cfg(["command_prefix"], default="!c") + " " + + def _get_cfg( + self, + path: List[str], + default: Optional[Any] = None, + required: Optional[bool] = True, + ) -> Any: + """Get a config option from a path and option name, specifying whether it is + required. + + Raises: + ConfigError: If required is True and the object is not found (and there is + no default value provided), a ConfigError will be raised. + """ + # Sift through the the config until we reach our option + config = self.config_dict + for name in path: + config = config.get(name) + + # If at any point we don't get our expected option... + if config is None: + # Raise an error if it was required + if required and not default: + raise ConfigError(f"Config option {'.'.join(path)} is required") + + # or return the default value + return default + + # We found the option. Return it. + return config diff --git a/my_project_name/errors.py b/my_project_name/errors.py new file mode 100644 index 0000000..7ec2414 --- /dev/null +++ b/my_project_name/errors.py @@ -0,0 +1,12 @@ +# This file holds custom error types that you can define for your application. + + +class ConfigError(RuntimeError): + """An error encountered during reading the config file. + + Args: + msg: The message displayed to the user on error. + """ + + def __init__(self, msg: str): + super(ConfigError, self).__init__("%s" % (msg,)) diff --git a/my_project_name/main.py b/my_project_name/main.py new file mode 100644 index 0000000..e2570d5 --- /dev/null +++ b/my_project_name/main.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +import asyncio +import logging +import sys +from time import sleep + +from aiohttp import ClientConnectionError, ServerDisconnectedError +from nio import ( + AsyncClient, + AsyncClientConfig, + InviteMemberEvent, + LocalProtocolError, + LoginError, + MegolmEvent, + RoomMessageText, + UnknownEvent, +) + +from my_project_name.callbacks import Callbacks +from my_project_name.config import Config +from my_project_name.storage import Storage + +logger = logging.getLogger(__name__) + + +async def main(): + """The first function that is run when starting the bot""" + + # Read user-configured options from a config file. + # A different config file path can be specified as the first command line argument + if len(sys.argv) > 1: + config_path = sys.argv[1] + else: + config_path = "config.yaml" + + # Read the parsed config file and create a Config object + config = Config(config_path) + + # Configure the database + store = Storage(config.database) + + # Configuration options for the AsyncClient + client_config = AsyncClientConfig( + max_limit_exceeded=0, + max_timeouts=0, + store_sync_tokens=True, + encryption_enabled=True, + ) + + # Initialize the matrix client + client = AsyncClient( + config.homeserver_url, + config.user_id, + device_id=config.device_id, + store_path=config.store_path, + config=client_config, + ) + + if config.user_token: + client.access_token = config.user_token + client.user_id = config.user_id + + # Set up event callbacks + callbacks = Callbacks(client, store, config) + client.add_event_callback(callbacks.message, (RoomMessageText,)) + client.add_event_callback(callbacks.invite, (InviteMemberEvent,)) + client.add_event_callback(callbacks.decryption_failure, (MegolmEvent,)) + client.add_event_callback(callbacks.unknown, (UnknownEvent,)) + + # Keep trying to reconnect on failure (with some time in-between) + while True: + try: + if config.user_token: + # Use token to log in + client.load_store() + + # Sync encryption keys with the server + if client.should_upload_keys: + await client.keys_upload() + else: + # Try to login with the configured username/password + try: + login_response = await client.login( + password=config.user_password, + device_name=config.device_name, + ) + + # Check if login failed + if type(login_response) == LoginError: + logger.error("Failed to login: %s", login_response.message) + return False + except LocalProtocolError as e: + # There's an edge case here where the user hasn't installed the correct C + # dependencies. In that case, a LocalProtocolError is raised on login. + logger.fatal( + "Failed to login. Have you installed the correct dependencies? " + "https://github.com/poljar/matrix-nio#installation " + "Error: %s", + e, + ) + return False + + # Login succeeded! + + logger.info(f"Logged in as {config.user_id}") + await client.sync_forever(timeout=30000, full_state=True) + + except (ClientConnectionError, ServerDisconnectedError): + logger.warning("Unable to connect to homeserver, retrying in 15s...") + + # Sleep so we don't bombard the server with login requests + sleep(15) + finally: + # Make sure to close the client connection on disconnect + await client.close() + + +# Run the main function in an asyncio event loop +asyncio.get_event_loop().run_until_complete(main()) diff --git a/my_project_name/message_responses.py b/my_project_name/message_responses.py new file mode 100644 index 0000000..6d016ae --- /dev/null +++ b/my_project_name/message_responses.py @@ -0,0 +1,52 @@ +import logging + +from nio import AsyncClient, MatrixRoom, RoomMessageText + +from my_project_name.chat_functions import send_text_to_room +from my_project_name.config import Config +from my_project_name.storage import Storage + +logger = logging.getLogger(__name__) + + +class Message: + def __init__( + self, + client: AsyncClient, + store: Storage, + config: Config, + message_content: str, + room: MatrixRoom, + event: RoomMessageText, + ): + """Initialize a new Message + + Args: + client: nio client used to interact with matrix. + + store: Bot storage. + + config: Bot configuration parameters. + + message_content: The body of the message. + + room: The room the event came from. + + event: The event defining the message. + """ + self.client = client + self.store = store + self.config = config + self.message_content = message_content + self.room = room + self.event = event + + async def process(self) -> None: + """Process and possibly respond to the message""" + if self.message_content.lower() == "hello world": + await self._hello_world() + + async def _hello_world(self) -> None: + """Say hello""" + text = "Hello, world!" + await send_text_to_room(self.client, self.room.room_id, text) diff --git a/my_project_name/storage.py b/my_project_name/storage.py new file mode 100644 index 0000000..580ebd1 --- /dev/null +++ b/my_project_name/storage.py @@ -0,0 +1,126 @@ +import logging +from typing import Any, Dict + +# The latest migration version of the database. +# +# Database migrations are applied starting from the number specified in the database's +# `migration_version` table + 1 (or from 0 if this table does not yet exist) up until +# the version specified here. +# +# When a migration is performed, the `migration_version` table should be incremented. +latest_migration_version = 0 + +logger = logging.getLogger(__name__) + + +class Storage: + def __init__(self, database_config: Dict[str, str]): + """Setup the database. + + Runs an initial setup or migrations depending on whether a database file has already + been created. + + Args: + database_config: a dictionary containing the following keys: + * type: A string, one of "sqlite" or "postgres". + * connection_string: A string, featuring a connection string that + be fed to each respective db library's `connect` method. + """ + self.conn = self._get_database_connection( + database_config["type"], database_config["connection_string"] + ) + self.cursor = self.conn.cursor() + self.db_type = database_config["type"] + + # Try to check the current migration version + migration_level = 0 + try: + self._execute("SELECT version FROM migration_version") + row = self.cursor.fetchone() + migration_level = row[0] + except Exception: + self._initial_setup() + finally: + if migration_level < latest_migration_version: + self._run_migrations(migration_level) + + logger.info(f"Database initialization of type '{self.db_type}' complete") + + def _get_database_connection( + self, database_type: str, connection_string: str + ) -> Any: + """Creates and returns a connection to the database""" + if database_type == "sqlite": + import sqlite3 + + # Initialize a connection to the database, with autocommit on + return sqlite3.connect(connection_string, isolation_level=None) + elif database_type == "postgres": + import psycopg2 + + conn = psycopg2.connect(connection_string) + + # Autocommit on + conn.set_isolation_level(0) + + return conn + + def _initial_setup(self) -> None: + """Initial setup of the database""" + logger.info("Performing initial database setup...") + + # Set up the migration_version table + self._execute( + """ + CREATE TABLE migration_version ( + version INTEGER PRIMARY KEY + ) + """ + ) + + # Initially set the migration version to 0 + self._execute( + """ + INSERT INTO migration_version ( + version + ) VALUES (?) + """, + (0,), + ) + + # Set up any other necessary database tables here + + logger.info("Database setup complete") + + def _run_migrations(self, current_migration_version: int) -> None: + """Execute database migrations. Migrates the database to the + `latest_migration_version`. + + Args: + current_migration_version: The migration version that the database is + currently at. + """ + logger.debug("Checking for necessary database migrations...") + + # if current_migration_version < 1: + # logger.info("Migrating the database from v0 to v1...") + # + # # Add new table, delete old ones, etc. + # + # # Update the stored migration version + # self._execute("UPDATE migration_version SET version = 1") + # + # logger.info("Database migrated to v1") + + def _execute(self, *args) -> None: + """A wrapper around cursor.execute that transforms placeholder ?'s to %s for postgres. + + This allows for the support of queries that are compatible with both postgres and sqlite. + + Args: + args: Arguments passed to cursor.execute. + """ + if self.db_type == "postgres": + self.cursor.execute(args[0].replace("?", "%s"), *args[1:]) + else: + self.cursor.execute(*args) diff --git a/sample.config.yaml b/sample.config.yaml new file mode 100644 index 0000000..d33538e --- /dev/null +++ b/sample.config.yaml @@ -0,0 +1,49 @@ +# Welcome to the sample config file +# Below you will find various config sections and options +# Default values are shown + +# The string to prefix messages with to talk to the bot in group chats +command_prefix: "!c" + +# Options for connecting to the bot's Matrix account +matrix: + # The Matrix User ID of the bot account + user_id: "@bot:example.com" + # Matrix account password (optional if access token used) + user_password: "" + # Matrix account access token (optional if password used) + #user_token: "" + # The URL of the homeserver to connect to + homeserver_url: https://example.com + # The device ID that is **non pre-existing** device + # If this device ID already exists, messages will be dropped silently in encrypted rooms + device_id: ABCDEFGHIJ + # What to name the logged in device + device_name: my-project-name + +storage: + # The database connection string + # For SQLite3, this would look like: + # database: "sqlite://bot.db" + # For Postgres, this would look like: + # database: "postgres://username:password@localhost/dbname?sslmode=disable" + database: "sqlite://bot.db" + # The path to a directory for internal bot storage + # containing encryption keys, sync tokens, etc. + store_path: "./store" + +# Logging setup +logging: + # Logging level + # Allowed levels are 'INFO', 'WARNING', 'ERROR', 'DEBUG' where DEBUG is most verbose + level: INFO + # Configure logging to a file + file_logging: + # Whether logging to a file is enabled + enabled: false + # The path to the file to log to. May be relative or absolute + filepath: bot.log + # Configure logging to the console output + console_logging: + # Whether logging to the console is enabled + enabled: true diff --git a/scripts-dev/lint.sh b/scripts-dev/lint.sh new file mode 100755 index 0000000..79ba3d6 --- /dev/null +++ b/scripts-dev/lint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# +# Runs linting scripts over the local checkout +# isort - sorts import statements +# flake8 - lints and finds mistakes +# black - opinionated code formatter + +set -e + +if [ $# -ge 1 ] +then + files=$* + else + files="my_project_name my-project-name tests" +fi + +echo "Linting these locations: $files" +isort $files +flake8 $files +python3 -m black $files diff --git a/scripts-dev/rename_project.sh b/scripts-dev/rename_project.sh new file mode 100755 index 0000000..885da57 --- /dev/null +++ b/scripts-dev/rename_project.sh @@ -0,0 +1,64 @@ +#!/bin/bash -e + +# Check that regex-rename is installed +if ! command -v regex-rename &> /dev/null +then + echo "regex-rename python module not found. Please run 'python -m pip install regex-rename'" + exit 1 +fi + +# GNU sed and BSD(Mac) sed handle -i differently :( +function is_gnu_sed(){ + sed --version >/dev/null 2>&1 +} + +# Allow specifying either: +# * One argument, which is the new project name, assuming the old project name is "my project name" +# * Or two arguments, where one can specify 1. the old project name and 2. the new project name +if [ $# -eq 1 ]; then + PLACEHOLDER="my project name" + REPLACEMENT=$1 +elif [ $# -eq 2 ]; then + PLACEHOLDER=$1 + REPLACEMENT=$2 +else + echo "Usage:" + echo "./"$(basename "$0") "\"new name\"" + echo "./"$(basename "$0") "\"old name\" \"new name\"" + exit 1 +fi + +PLACEHOLDER_DASHES="${PLACEHOLDER// /-}" +PLACEHOLDER_UNDERSCORES="${PLACEHOLDER// /_}" + +REPLACEMENT_DASHES="${REPLACEMENT// /-}" +REPLACEMENT_UNDERSCORES="${REPLACEMENT// /_}" + +echo "Updating file and folder names..." + +# Iterate over all directories (besides venv's and .git) and rename files/folders +# Yes this looks like some crazy voodoo, but it's necessary as regex-rename does +# not provide any sort of recursive functionality... +find . -type d -not -path "./env*" -not -path "./.git" -not -path "./.git*" \ + -exec sh -c "cd {} && \ + regex-rename --rename \"(.*)$PLACEHOLDER_DASHES(.*)\" \"\1$REPLACEMENT_DASHES\2\" && \ + regex-rename --rename \"(.*)$PLACEHOLDER_UNDERSCORES(.*)\" \"\1$REPLACEMENT_UNDERSCORES\2\"" \; > /dev/null + +echo "Updating references within files..." + +# Iterate through each file and replace strings within files +for file in $(grep --exclude-dir=env --exclude-dir=venv --exclude-dir=.git --exclude *.pyc -lEw "$PLACEHOLDER_DASHES|$PLACEHOLDER_UNDERSCORES" -R * .[^.]*); do + echo "Checking $file" + if [[ $file != $(basename "$0") ]]; then + if is_gnu_sed; then + sed -i "s/$PLACEHOLDER_DASHES/$REPLACEMENT_DASHES/g" $file + sed -i "s/$PLACEHOLDER_UNDERSCORES/$REPLACEMENT_UNDERSCORES/g" $file + else + sed -i "" "s/$PLACEHOLDER_DASHES/$REPLACEMENT_DASHES/g" $file + sed -i "" "s/$PLACEHOLDER_UNDERSCORES/$REPLACEMENT_UNDERSCORES/g" $file + fi + echo " - $file" + fi +done + +echo "Done!" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..60ce4af --- /dev/null +++ b/setup.cfg @@ -0,0 +1,19 @@ +[flake8] +# see https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes +# for error codes. The ones we ignore are: +# W503: line break before binary operator +# W504: line break after binary operator +# E203: whitespace before ':' (which is contrary to pep8?) +# E731: do not assign a lambda expression, use a def +# E501: Line too long (black enforces this for us) +ignore=W503,W504,E203,E731,E501 + +[isort] +line_length = 88 +sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER +default_section=THIRDPARTY +known_first_party=my_project_name +known_tests=tests +multi_line_output=3 +include_trailing_comma=true +combine_as_imports=true diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..618a444 --- /dev/null +++ b/setup.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +import os + +from setuptools import find_packages, setup + + +def exec_file(path_segments): + """Execute a single python file to get the variables defined in it""" + result = {} + code = read_file(path_segments) + exec(code, result) + return result + + +def read_file(path_segments): + """Read a file from the package. Takes a list of strings to join to + make the path""" + file_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), *path_segments) + with open(file_path) as f: + return f.read() + + +version = exec_file(("my_project_name", "__init__.py"))["__version__"] +long_description = read_file(("README.md",)) + + +setup( + name="my-project-name", + version=version, + url="https://github.com/anoadragon453/nio-template", + description="A matrix bot to do amazing things!", + packages=find_packages(exclude=["tests", "tests.*"]), + install_requires=[ + "matrix-nio[e2e]>=0.10.0", + "Markdown>=3.1.1", + "PyYAML>=5.1.2", + "python-dateutil>=2.0.0", + "caldav", + "icalendar", + ], + extras_require={ + "postgres": ["psycopg2>=2.8.5"], + "dev": [ + "isort==5.0.4", + "flake8==3.8.3", + "flake8-comprehensions==3.2.3", + "black==19.10b0", + ], + }, + classifiers=[ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + ], + long_description=long_description, + long_description_content_type="text/markdown", + # Allow the user to run the bot with `my-project-name ...` + scripts=["my-project-name"], +) diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..9683e0a --- /dev/null +++ b/shell.nix @@ -0,0 +1,17 @@ +let + pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/87807e64a5ef5206b745a40af118c7be8db73681.tar.gz") {}; + + fhs = pkgs.buildFHSUserEnv { + name = "my-fhs-environment"; + + targetPkgs = _: [ + pkgs.python39 + pkgs.python39Packages.virtualenv + pkgs.olm + pkgs.glibc + ]; + + profile = '' + ''; + }; +in fhs.env diff --git a/store/@calendar1312:systemli.org_ABCDEFGHIJ.blacklisted_devices b/store/@calendar1312:systemli.org_ABCDEFGHIJ.blacklisted_devices new file mode 100644 index 0000000..e69de29 diff --git a/store/@calendar1312:systemli.org_ABCDEFGHIJ.db b/store/@calendar1312:systemli.org_ABCDEFGHIJ.db new file mode 100644 index 0000000000000000000000000000000000000000..37df964de8127e7c386a1eb133a1a229ed3efc28 GIT binary patch literal 139264 zcmWFz^vNtqRY=P(%1ta$FlG>7U}R))P*7lCUoEDx}Xi=EwRpD5Yp5$l}l9`qnQD#)>=;$0_nVgzv zP-&1GSrVKY=oIA{=o+Ez>8S1J?HZNs9-JDL=Wptr>zMB2SL9ag>RRC$5)fDs=pUFJ z9G;PCmhPTa=ARu{=4N4<;gqJIQk79*mYtkbX_8|Q;OUf@;}oTx7g?4P?iZRCVxn#7 z>TZ%&5|tQ`Xr7)_7+J3G>1>dZ5>=LESz%O|8=e1kkLW}0acl$7F`p5v4e5gzGf>S|PE>f!61nv!eeY7mlQ=37~q zSYc#RQkGU+t{ogQJIomkrxr^5fPB;l4zL{Vc@NwZx~kQYvEOp z?oydjl9KP@m*F1bZf4?_oE24AVC<7toZ??mm~Il_l^79R;1gkCnH6kYl96m|P?Y5s zX_1v~808*hQ5qGM>X#m6?&MgRk(=n5=aygP=wy^0T47XDkmqVxVw#j$;Zm0BFbu2q8%LQkery4nwOGTWN2(? zWK~>QT#}lblc|?qlP4hiU~hdm%o!oRIsJ#oAxTzyo|(f)}?U#OMQ$ zGApOCA|sYgJz}hZ8>bq?kOCJ@HHeV|PAyhWM^ISfTGEafC*UBY6fsJ`PDm+YjDU@h zQp5-WD{}lgG{sa8G`M2?JQ(uf4J{kg}Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0wW>>lvr38JUJB;gWTMM-M!sY6P+qDUDI5AosqU;C^3W8RC$-X zh#I{8ez z|7q*}QPW35U^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1ZW-tpjH1^*8eke zDlqVFB`nZ-#Fg^8tMl};t)ZtfPDj>ZAm`UXiE1_hY` zh55!l#)(B`zDa%&+J+Vt5tfD(k$&3YCGN>iwWNm*4{!Jhc%jiSoTGEBoW3S690l7h0elXA-}olPUMB7BVW-J(3I z$||zl9gEUZ!%7P+Lre5C(}L5y!ixP;yvp(|k`0YRgG-#f^nDFdgNz+BN|FmaEgd7A zv@_BR^sB<03W76y9lb(w!VEpjGqY1ojPwg~B8!9c3j^H}^9{?B!UD}JO~MRA1EL(g zQ@sL=)5B98jop0$vQvzrih=^G^pi{jvQtv40t(AZ)4VHlLi~aZ^78eAf()wkBZ9)p zy-ma1Tpi0zQ=QX|{0pnhg9^+d-BR5uJj*JB4GcdQLrja^v(i$v^CJ_@echA71A~kUeVi-mCqJk=uE3tP*2aLl;9!V?WD^;E;@e zZEv%PA{WQd2oIx@kcg19@W{&YqyRtdu(I-SeQ!_KoP5J_$K(u`@WRAGlS2K*C=xz^Tgz;R0A)slwx!5s`Qeo?4a-v zlZXH$0{eK%*%yrksR#ByIt zUkfvBvmno+BK>@e4Bz~Gqo{~P#~_n*!z$kvcSdkp@@(@-PW8y~NDMJ?3wA25N;mK_ zNl7Vmcd{r%hP;c8K3R8SI{ZC;q{<&qrX>t9)v;bZ7rToo1OXK3MO?wpxw5tM3PY?`UcndXy~oMvY3TkMu$SzK)FWLXjr z6qso2kysH?Ql6BUm+e)UXzXE;p5*Ra5Sj0s8|Ytd;O<|noo}4#YFL&U;+h{;kz{O; z<(6jd;+C4>m6nm|>gg6y;%5??lkSslYF-$aXzb%@?q8go;h!Gmlb>mlSm@(#YL>j z=;@de9FSVk!|1@QW)UkQs(IhTK~_?U&z3}kpCio;h?XMM;$mC z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd70wBLwuBlNm`ENoEGACtOmfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5E#lK z0Gj_F?f(zu7#wx`Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD5DtOS{y*U` z8dWhG0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd70w9q=+RFsQIEVcEL~uy6KV)Ar)Wc)c{i7i;8UmvsFd71*Auw1%z*>csK{1&zxwNP()yUM; z(6S`5z{w;f(Ig_Tsw^_d!^zYr&%4seJ1xA@%gC@QDzL~qA~Y*3(##?(TgXD0l|ee0 zF*OCO)6Xp+Kg6xXFU;E|AhYyHiP zqk^+N!YlH#O5HQObB**1%d)h+J#$lB{Br$5%Pb43s?0+xvPv_H1+5j(tTp$E^0vtH zuc{0U$*=TGO3TQNsE7#CPV#apODpkDE^zlYbTKcDG6)Jw7O;>=Mta(#pKNtjbI$|EMH)Bg2ZUsH6}NPkswoWNXW`GxKw+QZ3SxvU9zBbIUT4 z-1E#!JdH~Ol70RB!vd27EfYOTOEP?m%lWNk(5y9#bTvtL%qaEC56}0{G_p)hOv=pk z^KkYsHg^pUE2}aN&MS4xNi8okO6IeWMz%J@GT6+|Put7F!n?@T(AP3b+c_iEH80S( zJS{OG*C4>W%rDTx!`!o=*nrPk3eDP5Q-1@4puF6Y0wC{s7@fU>|ecTZjmNn~p?D&12IlPvQJ^2!23veMkWOG>hG5~Fg$gUyY+LqaUV z^v#le%rkPciVb+JCD5$RcB|5k@+-{^Da;CpN-Rusb~Mp56l zBBY$hS`5uv@6y~vQ{$X4eWy}imogW#sMHWYU(?)>a3`MvOON8Hq}23OKW~$wD8q7Y z3sGciEla$8qAHV;oYM`nBP+{F3JTIAoxIA8^2{m>A`NoFvx|#dgUr103oLxOtwqqR zH3~0CHViKC&T}nIugWUQFK|x@3&{2=OE2^*&k4^+3eQNZiU_I7D9K%UTG{+6ec8T>VU+ z5Dy_Cr5P76U~YfYT9l5$h4s&Z2N zP0c-0{N3C_EDiOIQUY>Q$~}y{bJG3&g0zEtLMw|SIjsfItkn(*iwrietn^MRPV-1} zGVw9;EUt1d&MnjqEekLWPASdI4>Kvt3e3$2BDm<*j zFCy2#IL##4-O0To)X6h4JuB5f-^V4$fWw*(&DyBa5ErNXs7QkXZ?g=aQa|TnZ_m)s zG&gU5$H=U}Ds2l7eG8LJFKOiTUKA|16o z4UJ9n3f)SI41x{4@-xDX9NDdT(5x*oFD?mlbuq6r@D9&64Ak}y3eV3>Gl(+IH%p5& z5A#Thv@j?)t}t=+2xYV2Mz%K6D9X*J!oM&t+|tD-D%sFHJh&{t%&{u6(%m->%GCUb#B%rSyevnb zd?)|R^mJAWPGoEIgWa-S^}S6iqs-m&^PB^mQ;jp7l1rRCT%z2QBK!@!lfzwtea#~* z@_bpXInb>2F!Qf0H8yig)%VpdC^X3TaCVEz4NI{sObd-j3r$Q8&B;kgPBS$3G4N%v zU`Mvr%sHzx$g48ZvZ%PsFQ7cwu-wBe!#F9^A|yREz1%CzyxiC%Q9Cd#!Yh!)nhnj` z(#Vt&_xuQRi&B^3wA9?Nf}+AyZ|?&4Y%|N0GA}1zAHUF0=R6NzV<%5$3sz)nJp1M-pzGtJEM15zWIty$2lO-~L?(l&7| zGz#-94XTJT%n8s=EUC;aO>z#%GN|;4^0DxVbWP02%y0{2vS3EGHq+b9)3L}UFDx{m zJh93x-^|xF%)QJfKe;q3EIlneFvZz4)Fn7E$-p;|$%+YNEhnOyH!!XAD2Q^+$x8Jv zHZTj(POsE9HY|;DG;<1!@H9vZs7lPXFbguw$%wEpX9U^G39aSbv>gK?y^AX{1I<0O z^WD=dEz(W3Bi*VjOWiyx!*Z&s@kr0=`DRK)xhCBR*$7VLlb! zFMO=LS9zZemNwIV0sI($jS^y!f0G1YjO3Q<#`4Q4`U}=7+v@BSf4OM|6(5zgESavm>PWz|!ncXtEX2TL;}q}jmI%us1ourw1wnguM)1eIpyXW>*tnmu6RX5o~E z`5N5+=hudu|NoKyHUCro`}{ZgFY}+}KhA%Ue>eYD{`LGT`4{ug<)6+!k-wY2mA{_9 zlE0Wgmp`39kw2P0l;5A}CCY-tpj6_Tf~@8epQ zW#pEgQfv%TACT>ssPF9Cn<7{A5TXKn1A4AYHk>%pJG%J>ROav7Jbw6j4~<;Zor2=9!jJnV4K= z=w_e}QlFQZ>r`e~;b^RFl#&!ylIxsn6mF=UQ{`8f?igj_o#q#nm|~LWTN+pxss>W; zo9Q0lTo76s;TsST>X>4Yn~@Z5kdtQ^l%E!9Q0kOamSgbgepdDFaZt0s?DUfsSi@`9AZ$E;}n)#m{jSP zWad+t<>Ha#8|dzC6y#l6Te!?Uq;R?OLn{QlFQZS!UwlnPn0c;#TSI<``U* zlj)U`UmWP3XQ`dy?Comcm*QGnX&h`3sS8r?n_m)|Qtp+JSs9R0S{UgV9Ga5h7MxL< zUzK5QuI&@*;#QRF=auCgnd_?qQXiI@W8z{~8Lpp~lN%D5U!0lZ66x&iQxcSxIW8lBRf0_Rr|4Hx~fRFrd_@DDX;=jwkfqymsGX7osTltUh?;rf7 z#i$EMLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(D2D(GGbf`lh%f>Xh9JTe zM3{gGa}Z$$A~ZpS28d7x5o#d907U462t5#?3nFwtgf@uK0-gWQATX4}X4LJYAut*O zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Ulnu05tzU+W#jMN~7|lAut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?w83cqe`v?wsOv{VU^E0qLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMniy@5CEyU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1n3X~%*>peyc`k?ylfvC_-pyn_+5A| z@fq?Ya!c^2a82j<%H_im&i0W*f-RdhfX#{JH**ck5oUHqf2IQrI~iIST5vkCQCNaq z+|!t`(L6IRCAA{Iv?M)0GcP?mwX!I+ur#%}q&Pk?IXS;HuOvP*CBKwIf-M)PS)&r8 zAut*OqaiRF0;3^7jSy&3;bj-s)@E$3FG)8hDk+4xMufoCf#r&eGxPJ}LB^oTx%mgVdb<083{lWfDo%wf)Kmy^b#o1J z^>cO&R#3`KP0!EC&CE;6FU?DVswxJ#49W%>t*PMer{Lo1;~L_s;OrRe?C9dEqY&!n z85ruSfpDD;LWia%e<_1K1;O1Y&BHG4YtGnc4{iZqbtF<70INJNH1IwBTwEh?cofMD zC6Fg^2M40Tp`_Uq!o@CbZqC@^4o(ADZO1Ce4RsPUJz!OX7WSx7PD0W^axg{;(@{`L zE5M$jit_VwL2-_1FCwWaDLDD~J0Yb()O3bqD{4A}N`RAAW{MJ)B&F%e%_bge&d30c z^5Vjr%#zgj#L|*{Fh3q^V8t6^73QhuWEZ#BWNcIh>rKr|E~+djNlgKH4!P}!D!~Pf z9cY3>;E;A&hXXl4es33%j_YB4aBvIOY%v5iC}yHP9GC zNPx^DBDNqJu*O$uacU7(iImLZf}F(4_`Jm2RLpuHB{e6tBo#|tqNAXMV3DkyS7|o0tA24AeV*{!j zqhM2lx*l6-gHj>ZXe!P~EW#cS(E1W;C~}bKGqZ_1%i;;~t3=Wp!lKi67veY6_ivXF;gc>}^QbYz3)KJW(iY93NKOg&B2L6Nm)_k6P z3cROznt6P{-g(GVC7fzc2c4S~@R7@{GNFOA$5FRsi> zF3HbM%|mXBBLwB3tqy2g93g?x7Dq^dnj_Fw4ybjdX)J}Wv53$YZ-`(^Hx^5R8}QAB zkX8}gnFuvfP`5!F@CXTz1BhtAgT0N}E<|!Vom<+Pnk3GdV9cmULW2YvUHIo*AY&z1 z)PTYdDHJqa#o5Fojd3+?u^13$rV82xL+m=|VpfcAwkB@%Pe^NTV|GIFs#iH!u zmdcEc;^1C)eoijbpU7DqNmL9P)Ud>ZRXsG(Aml*7kDORcMcBkWm2oB(gvNM7B)dhM zT!q=im6aJ=0>SwmNeYQ60(BuQF(3>BxdQ4t%os)+Z%ZypO)LS8x0YmL>xJhe7MH}A z7AK~s#-}8fVCkkK21`kedDs9hMlY3WNkTJNh)rBml(8Hf<=`$jqz4|KnF8VoLqiZ6 z#~`72xB^Jrg2X_ZD$<`-wCxI{X8 z`;-KwCAnn!W@Kd>I69{XyM_cuMj9Cy8R{BX=o%O)7@Aobm|7WI=owlV8JHVj$e0-D z8CaTIT3VJyrj)qnN0?ibx)i6S=7tp%6{dQ77r1AeS*DbEIr;kdg@!ukdH5PTc@{Wn z8|RuCnpqTALhZhw21$$UJx@Kk+T4oqWR#j;mo2EOayQG9U2PFH41o#K!2b*S_ zl%*#7IXWi#nwAuJW)$b?f zrs#+0n-~SV<{4z>MCKc2I+_}Uhle|s`&Sm}hlY8%lt*a0yA)fN=9%Xg7*rJ*oBNlR z1O^2g6c1=d83z~?ms{w2SyVcj zy9byjJ6gC_y809bxfPl_hI%IFrU!WjW>grP8~K@8nwck?hv#@(IEMM;r-!8lc%)Pm z%rPV_+||s_JToXXH?hp3 zAm1m|C)dNrAl=fjAke(r%sDY5+tt0uu}t4l+c7yLz&)zSKi}IWHP6DsH7ml$Ji^nz zG{CVi$HUSurz)o+FC@gkAj!Zi#kkPb(7!S=z|1?@&^g1`F{~&p(Ip~1#W&x)JlHAK zugb;LKisuEzog35(V#fk$E(=eB}Ch)*x$s#4NeYxzfkdw8S#lI50Fc zw8%X(GCbR>%-<_5!!;t(JIOV&*t9GopiDc=G$5-eqtLlL%rPT4tI{wavMeGfH6qc! zEXrBm*Dun<+aS z8BnB~n;ICK8JJdj6hyh^WTpBS8<>S?r&nql8qnU>|b!LB(4B~_(i;VJ@C&m1!le!I_a>VZJ6I8NmUeDY=QEf#y*aAsP9`h9*AdWu<00X=b6`fsy6j zVNuSxj)5ueB^4R2VL6`R!KsF>1*N5#dHUHIsTD=R8Oc>f`M%mw$&M}|iN$VVKKezG z%|#lpNcSuoT0!xqLsHD{N zR6lQ%qA0_1;v?P3BhWWoKg80#IIGaa&!WgOGO^INEIcUHN#8NU*d;qNBQVh`C9^a% z(WTrqu*AsAG1xaP%*!vLIN#VU)GIsPz}PFpE!ZSUyDGChCAhq_Bp|cg(#$g_sZ>AB zx5y>HHOa!;!ZggO%FDSlIV{*EIMSpdGT1ad%d{%Y-PtiBFWk^6%st&TH6kD*)W{>; zG$_eAH_OPlSlcowL_f09Uq2u`AS>Nj-!dXUQrp7PImb8DFUPD)cXKHVJV`H%oRcagDHa z&rS-8C@k=BDR6f4Ov|WDOfEBY!)&92ig!Z;OC#*XyQzV>rHN5^L9$_Rfp?y3X?j&w zQGS7YN?1U)S6Og!{OF?8nWlEJ>Sz1_>Q%Gr{UrK?0rjKbvNn&PZNp4=^cVL#US744|VPQdTfPrChdZ@F1ad1SiMY&^Cp|4A#S4wzs zX?9kQuX$x~pt+fANWON4w^M##W}-)KzIKkWU${wtd$wDixoe7kW<`34WtdmKPgtUR zu%~xeM3zrzibbWrd3I%0iA$76g>gx!VT5CHwnuZ38Z6TDGIO2E3@aRswT)7e!b)Sy|d zc=#B)MtHjT8ALedcqI9hc?Nm7o0;Zi88{W1hZ_ZF2YN&jAL&jx;mL)0m7x{cPHC0d z{)O(5C50{-m1e#MX(r`Ch6V*GDJB_Se&OYw`Yz#Se%=L4l z<`o&fo)P}8X6}`dhGzaQ1p#I5#XgaVVb1QU8E$Unk7`=^4er`UL@Q7Fo%eQGVt{K^0ymK_vzr-o@UT zP7xV~Del3E6_p+p+MX`PrNLR{9^S!u#)c7Dp`Mj~hT#=qhK}i`d4?uYdFIaHhJlGj zrp_KA21X?nu4zeCrsY{)CE=zK!8wWgffeC-rujKx<;JewP8q=--d>i57EYz17C{D; zWkD_$rUsGzK>?MOK82>C+5w5F`X(Mm$(4Ri*~#UWCZQo&p!NUkOupd$KbInh4txxN z;-ZXy1;s{;8Zfj%pt;BhR)Pd%J0|LTJ4WP}xCEsHMWttESh{$+ru&t+=jc}jY5PaH zWI8)#hIkg0d196zmU@=v<`!la7&0aXdWIGT#+JtBK2hEldHz+Ep&|K|o=IsLxe*l+ zLE1@PPGxB&{>cUI-i9vbrBMb!VaddoAkNN3mB!}!na;&gu3p-ve&yOuE|n!lZrV{s zff40~SuUo|`K6}0sTO`IMP(K4rD-l1p&^wKWfsA?KH=F>`r3u=P7#%X6-MP%0p=+c zUL`JO&dyPOMgdh-#$Jj3DT&FxWuZB_Q3ZZEK{-)|+2;O4Bvo z{!wmT7A_(B+U|v}g%$d4RR)Gp!6l*IuC5UoS;g4~t}dQYl_^F=zKPyRF8Y;;<)$HF zL4FzDX@wT~zHSxiPC;&_DgNR4ksi4@hPlqU?yhBCIgwGWc|m36<>g_frs3%s#UbV9 z$rjE=-md<^PHE}6CFPNoM&{nG`K~U_MTW3QFHFl%3MmXTGxy2Q%W?9w2+FF;E>5dV zi3-sV)K8Bnb@eDRsPxK9D~-y=h+1PyJxfzdGwg#thGrI)W~PyO8J! z<_8&Bm_!sC7iMIeRr!Xdm8N>7R1`S*C1r;=1y<_kl@t`Y8)T&WWjjR`7p0X3c~=L>iZf6S2+3QRpq2qdb;^%mNpj|z#`o@(>=hsAha~XHy|L?F~uS` zBPrY%A}cF{%%if33O$^Ra{b-hD}77?QatmVe2PNNL-JCyJ%UOs zQUd&Q%DqY|!n{(=!%M>>GMpU4w98#9e4`4&asz$b+$;(!JzPqP(~L{fj4iz*P4o3# zi*mCg%d;wS3`=qi0!sA54a&^j3;imLlRXksDosmFg3=0IeM8Ii^HV(BLi3}t(>=nf zybO#@{ha(vOARW*EvlRg@)9%By~})(b1kw>irh-eic=k{Jc_+?9F2?3jY~|6oFYo} z^Uc$OQ_>O(47^MX(!%rIE6cPEoU0-uqsj|%!p##sN>c-yi}YcUo@nW!U1S^*=v7u0 z>Yi%q=#>6=*O@8o3~3qGBq_bF~BGTK#^`@YHVU2 zRT|>rlphsoP~dHr;Zy48T5@-;@$P798Is}c zVv%Q&lM|Hh;_O^lmhK&8S(2HUoE04CmSJj?R%}-08sJ#2Z(thXSDsmFo@eGC8WQDh zQWfBB=wniuWfH1i?p#_NlwKMhkXYiF5gBC?7?zsoR_+>}?pA6RnqTUpUm9hRo|@|! z=xSJ0k(gHQQDW?zYh)N^;u#dF?UEB2tR3K;oa&pDq@C>=WKxiv=UrM96j)ZC;a(Y0 zUY;Ln>KGNAQ{b0jlpo*{Syth!pIWRPP-5g(l$euOR#lSXWML6*?BgAg=aZZ1lx30bTN0JpT%-q!bmtI* zq8z8N+`^*G1&iyP#kQmZxj$| zmKAL1ogL(s8d{a=9%)hLo*EIJ?^_w<7vSe!9GvPGY>;2*lH(PSF45GUT&6d zRuZD`;})FcRgh8Sk#A(_8SI>qYM5%|n44Qw=#!M1qS z%*{1U4hawTPfj;+4M;Jv2yihB(s$SP^eGN;3h)R?EzHbGiwsXK4m8Y6^C^!?O7-(g zj0#RSG${#9*Vd1;^r>_Stx7X2O3Y1mvB)+`NiwgDvT!#JHVP}tv&{50HP`m?&iC^5 z3=Oah3p6oy4)YJm^ma|I3d(T{PcI574=i*E&d#uiv~bHa^fpd0C`g-=zsqke9sk9R~^ zVPu7yr9p9dBB=k*%4Uw#{~uai&QaHnhQMeDjE2By2+%bInv2X~ZH4UIq%3V0i*ggw zDzA((=R~&>KO>*gv{IK0Q*%o%r%ZjXB#(-)z;b6ZEWH9tJxc>4LlZNMR)mRxo{_P+ zxw&zvslS0iP+o3Hfs;!?enpn6e~N#$nUA)aQ(|aRxS3}`l&PC{Kv`g#I|+k6&SiFF29;)}d46R+UfxB%d0~!uCaEC->28HZ?%|2y-mb=F z1|g;9*_C0gLFMMAMFz%Uk%a+1dHN}?*%l#A?v*9^Q3meDIaLwPDM=-H+U3T{0g1-W zm02G8&LuhJ5#^@&kxovAd8WBWS@~w3xh2_|CO!ofDc=6MZjnLxxq11Hsm@u2mMLjY zN#@1cM!v?L9xiT<;bGbC`pH55mPwukQ9c=-0jcR}<_12Ap&1^TCP7At<^KBl7U`yb z{`pRhCP~`49$sZ(+F@p9CGKUWCBE851y03z!9`K|VUCp^J|&(N9)89-X+@!C+M(%* z`F=%4#cpNU{#iz0sYz8P>6K~vo++N;?nRY}nb|&$g%%~HfxcNr{yA0US;5UkX0S*v zaV@W?s5Ej*bI$dPa!vH}OU>{%G^sMq&-5}aFf})f(oZoe33V;XFLK0)T4PH+3v){x zYx<0g%#6$}vfZk*qx?#9LkhD3q7ntlg_fG+dPD~06%-Z}<^&joJNs1@1SOiKn3ftCR|SSux#j10l%#5#nYd(xhDW9s z_;@5$8aoxcC3id@!1(>^8rg@ea2DyYprdXusrW&R@n`)P* zMpIQuwxrWLuAdAlW*gct@z zdT8h5JGw-gIfi&-nO9ZlyJ|c6Y5P`&6=ZvxW+zoyl=y0!_?9Jk6`O`xMg^z(R+ef< zG#8n|B0bzZDLKW&E3wkJs2GFclAT$RZ0YIhk(J?C z>SLMe<>zCZ7im#unqi*kWa^yi8DSFP=4M{vol_LPY#tPq>SJJ-Xg?=Q=0e-$Srwd@X=+fCTHsk}nde=QZ&^{9o1T|c7?h!} z?GfMSz&I>{L?dR+^QS7L@Ik zqo0+YAKF}G0*myp2**5&ve4vU#~^b<%T(W7=cK@t@Ca>l^T0emA7=xj;$r{QL^DG( zZOrjJ(3rlFp_vi(;@!a5(7>Q1vcSnCCD9}zuc|CE$ivChD9^jn$vZ8)(#y!ODk`wZ zJ0dhIEYi#(ESva9cl0ze3)jyJ4l^<PBXD6v&_>E_3*VcGcheP57f_& z^3twKHpy{yH!jQbH`F#P$;r+M&n+qlF>@{sswylmNHZwU&ec!K%a1UPG&CvnGV*bE zGtG^3%=L3IHA>b_btx&%vnVvEswzwk$}9>_&C)IoGs*W0iz>^>@GB|tbIlAjGBV8y zat_xw&nfnGi3~{(aLg;xHYv8qDJV}&&Ga^Mjdb$Ka`7<;DKIbdtujn?%<%|!E%fy= zaSDoZOAL1pPE1bCPfrVT@iuUeax@G|Ee;IUc1{a$j_{~TaS2KZ@HPo8v#_Wr&$IAM zFN!R24M{II&kJuZGKNKZS&px{rEhwEQfYZ=p;<_ZZ((VAib+^Sp0TA-U{*+;zP^uZ zS(cGodJ6VB-O|k51T?CTx|ka@(r0OCW@1(r>{FT&;S}sx;pk!!9BC5a=bRMo=^l|A z;u%pAlxH54T47R@5^7)^LBjB^bA^FHN>*B$W1@vyVYr`{x23jcUU8_iyJ>c6nz?tD zQ+{P&Vy17Bt8sd`S5m2QRg#%;ntr5Dnz_DNX-Io{M`*XhHsU7LAs$=x{p_+cUG#a zTXvv%hQ6CYMoyS{nP;A9il2UEu2));VY+vEV6v-Minm30ly{DyvuRG2scDI8MnIBf zWx1zOg^#m)dWDIve{pz7X^5M4Wl>UKpubmPa++UdYGQf0xp!i5L6n=Dac;S7U}R))P*7lCU}U{GUVU|?imU=U(pU|?ZD z0FW4hhn21PaS5#o3^H-|j0!6E z^3Mq`O7#vfwRH3kaP}-UFbXp$HOMNc%n#BI%r^IS4$W{a$qz5fD$EITcQp(x^7MC& zsxWb}%&+kDE3EVm&rZ)PD>BG8EA#O3bMi2Cb}COU^$JaOFU!rZ49PbucX3Nf&owD9 zG)W244)xX!4m0%42+as^_RWh7$}i6gb#p1u_i=Fzvn&sFa?{p#H*s@Jvh;Q|4a;(m z$jgq*uJjHo^R_GsH7`yp$f}IWG!4u%@XqnB%E~s#ipWe#bj&eKarX4g_Y2qdF?S0$ zC@3k8EDSevO)}L^^GS0~%{49d2rVzt&Nc9MjVkhs3^ocj@-vAH$|xu<^Yc!#Oo=GU z$jde^4y{b}N^(kz$W78tkMc|M&CB<3F%R`{Gz``cN~&`A4M<8Z4J|YZFAJ)uh{*Ct zG78LgsW1%4&aLpS)ON|K^zcdxuPmxEDy#@A^)hzOboMgOOLj6!@i&PKO3^ketjLXY zDoXS$^s-EH^UX>$_e+j&%knXE2`;dVEH%va&C4ruHulamH%rMgbPLvw%rY>naEx?v zPjx9sOiXggs5J48D9$v|_KFH}P4{z7%5e=h2re(Fj3}`P^^7q0_bzbF_Ac@_Gz~O0 z&dxRrFE7sv&++$Y^ ztqe3QG6@W?OfoY|H1+TccC<*&Pp?X;3XXENaEWv=&9A8RcP{mIbdB;#G7NO`4#+Cc ztqRVr^tCjsbazfM%uIB)D9`h%@GcDxEe`av%s29ku#6}wcQ1-6FsQIB%_s~@&oR~a zC@S~JaSqA!wg@vT_jd|UD#`ct)Yo^hG&ZcVD2>d{3f3-lcg#q%NHg_!4>xkpcFQ-h zu*?f|3H8kL)GqaPF%B+EtuoFEGt2We(AM@0GA_wa^H1|M%+mJ>_bW4T_ABx)N+~f4 z4$nvm_DQbvC=3Y?%gd?qPIpRUU|>+<0?+?nXW+j+SW2H!cZ`O>Xb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeD5E%j*ERKqt4#|l*sd*`hMTW+PMpng@#U-h^IhlI- zMd>bHuAy%Jj()D8p!t7c9%Tmp%lymuEBSr+<@rAG9p#(B7tg22`qzo$c5YJR#^ZemGMW(BA};hK}0o0?am;FyF85QB`>>d>! z6cOf-mztRe!dP^|QWBP!V2*W1aB2wjH!>bWJH+1lV_x3An`Vva6`k#Da6y!Jdle3Gfo4bdn7w%puth*YYlwYFF z0n*6J!RZr`>#gPI2!9h;WPTo#<6thAV zJB3FEdpi2Lc}3t+%mP*H=Ih}Y5)|g{9EwLVGgPr_fJYmrHC;CHbP1fBLu93lp@9lSO_Uaj1DjpR0kF!*vRdIdN- zX9X5SdAn7nrxoRg`xT??2mxglPfkYs z{eKpS8od2~W>pqnPDVogeU=)vr zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2n@*(P?ca{U{DcaU|>+@VPIfT zWCN}Lm*Q4r;J?Vr$k)jIkk^oRBF{p;jr@Xqf!vKe7kLkI2XZU&IPyF4FXVg3o5&-` zU&y_Yr;tyP_v4TZfl)_~hQMeDjE2By2#kinXb6mkz-S1Jh5)`0&}U9&jI1=Y2uY9d z3(Gf4$t(16O)bpxaL#p#atto>)OQX@4D={ZPAM((4|MUA0;!Lvs_^p5)-Ei|@OMi} zEB3Jrb#r%4F?KSHO7tzs@QjKo3iV1g2+IsBFO~$U_YEj<3H1#Ka`sQM3@WHF(Jzj4 zGAby@$;e15D2YtY^7c(OHOu!)E_BS70I4rbDi1U<^fC+x_A$@V&o(K@_VThUb#~M* zN_8{{Fm?$T0CS zG>`K14Dd@g&n$5j1F6sR$S=;SEGZ1sHZsYJ)HZa=E{n>DaxN~+Fw0C!&d$~@h%ofX z@k#To$`=KxPxN-pH3?2i_Y0}aF)a@d^DOaBcQ1+X^QtKF4K)qV%_!3@2}_EIEY0v0 z0jV!?*3L*b@F>qT%J8$aC`m3Xb4>OtOAhv~3QjSy408-OPPZ@#4=|}LDi;Q+cg_mS zEDFjpcTO~^DA&%63NQ4@um}yUOfHPdau0C~4XyO_Pb!QG^U+Ti0;vzpN^|qfC{Fb< zPA&4a4EMG0I4@iEb%t+@p3fHjPQ03FiFg-D9xi1e@wcF)SNER8B~Gj&Tb3MoiW^b9k~(+;aJPj{`fG`Fa7cTICG@#O}o zFEb8v_jJuDayBW-^NR5CC@={sOb#)q^2;@j$VxNQFGm@7adpcJO)j!9OXdWrH!-QIGA^$4w9rp7 zHO`HSN(wAL3v&?ZSG%Bz(Nvq2COg44p0I7G?4@%8Q^GvEt&C0B($SZL5 zFLqCM3-OPvDi4b^&T-XuO*2jMaEoxucVq{t&nU?^ElrJ#aJ4L~Omfk73{8uOvW$pI z2}nyaaCa&xiOO+Ga&!#y50CO>1F83n2rjJ*H}J1Y33oLKb#`+PGYQD8a4vB(EQ@l< zHVVwnEAmbDh%8I2%4P+rPf5uz)h`*EY`bGqP|=&GOBu%qoa*%yqHw zbn=cga5gJu0jbaPO7rx}4t3NoGOVZy&N4C$tc=RZ$}9>p^fPdC(a%dQtxEIDFiuG` z4rKPW~4DdVIy^sN84>jE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mk06YXhEApj4gd~WN01@IKLJUNRf(Q{1LEH2H83f?&8D)%yz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2n@pz0L}l8_Wy@r#EyDtGz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nkV0Uz|BnfR}-RL5*FPf&VyvH-8|%2;VKfxqQidYP@fF*YlS0TJZeiImpw_kP3BHuLk-_Oh_>E^;;WwT#ks&Pa95 z3p6fIOAN?02rw`63-s_X_bezj5Vlr9v$ibQr!*zPDcG^X(ZwP-(j>yqIVs%JJt8;6 zGomCY&par#!lWoA)WA3*U&ulk*;;=yFE`g+KfA=Us*aD=I0( z!&AUQ9@*M5?ach#s#J^gr0iTT-`ui{B=MmOfMj1k|FFR1K+8mr(vl3{;&K6N zIW%iaA`6^MQW8xf@~X-rgFKu}jqitD*vnydy%h!XnKq!m{}-WRb1) za|_51aVzl)^L7acuFNXVF?Y$T3^wKBsrOd@FDmBE<*EBaI+{vfF(xW&kDK$OS&)cLZ%CMZzLK@jx%Mx#&sLG@y z=XAsD$jY*kf`ar&C$Dm&JhKXeNQ0d4?BXKVATzK00t;V0Ybi8qjlv6(4TB53^IS{Q ztFnsn3*1w}0b-fOGAC5 zlz`lnat|Z#oOFM`AnhQZ(8}US9&0f)YqQ;|w4?k=b3+QV0-_QN)0`bm^xYiIoSdU9 zioD#Mv@OhvoH9ZkJ=e8C>v$oXK-@qU!FSn$?$)zB_BFoi3#XsB3N88LPF*GUM%(EcM z)Xh7fEHKU8lgmOF+1iXs_Y}h<%e;cTvcQn6GsGRU%b0hDN5Q{K zlQ}H}k*#$q56STGNDWAL_ABr-3rbISimI{*a&mR@h%74bGbt+yE3t4%%Bso=_T;n{ zK(p4v%)heK*vu_e-&eb!&>-K#*)1wJEXA@gEi@u6G%-0eCnqI2&CuM(z?Z{UTbsqin% z3%7LfiApv!4-YO2FmtSmtaSGcD9EhvGO_SUG0yZbP7P(Z=0USoJ18tN*u1jRJFPg) zBhAUg$H=p|%Dp(ZP&>3Nz%)3eG&4WUq%12iHzSbEf*aY|lu~!Uazoehz_biQPm8MX zuoAzBTm$1YlVo=%_li&_&&c$wR0Dk_b<=hXi1aS5$P6_1(9U;Hv$RMz)sA$l zvMhD;tPIPks>%y-F*FJ)&8ReCwdO#x)}$mfxxmpe*WJJ?q9QlXB-r01(#+k|GpsT- zKO(W*Jv%SU(I?-@KQld@#eyB#+WcU*Y*&46)5<7w_xwEP0OwTWOsC`$Cl8k>_oN7a z1MlQ;mtbG>2#Y*l7Hc*%YtxehleA4-3ys1&OM@z+408gs6H6*HOOu=fvJ5JHqI@iT zB3%k7$qNe&C{L_%%Qy3N4RbH^$xklL3QJE54@_}34Rr}l zOfv8dWVU8Ov$ixcrNliy!rY?Nr8q4$H>{wjFxA_;z&+c{GNsJR$=Am(G}JlI!`Il! zlgWY^*;>y4cOzr3^qjE3vZx#vOMkEQz+y*ZmndHY$I5_ESO0*#q{2)yv;2V6NG2;L zkhPqM)KHxRHQ+Hw^@cysh@MPw`XW*nwz)3V`Nrfm9~Y4zJ*Dqm$#)$zBwbv zR!(T+!pu_NAkj3j(j+3Hva;B>tjNr?)ITlKQQOnd*fg)ut)$2x*w8CKBizUlH2<&0 zyN!YW3I7-Vqx{$Sm+^1sZ|9%EpTS?r@5vvq;r+tL%6paf8SggU<0G|sIO^}w5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GZ}12net+Dl!TqdWveiER52O!qD!YDp*O>rC|H^cAuR%y=7LHKgQb!B*Fs=vSf^PKEX{$?EdZA0 zfa>N4OS2=S`M}cbP-$MUG#f&i2Q1A7mF5OZvm&Iqz|yQxX-=>-3qqO$EX@LyW(P|% zBc$2D(#%k4Rql z`7iUIn9zmmV0KbJq9KaoG0Ka}5}-;>{& z-K_ssfn3M#OJQ841 z97J-9fk{yi$t40Ng+U~z5SSDMksJbGk{?8}^MOfT5Xr^^Cb>Z*D;JpL1d%KpV3Hj~ zGP8k6RuIX=0w$S3mkKa3GjnpX^1owX=6?YSL{0u*{9pKAKsX?QQ9K#~qaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFyul&y(xl$fq@O|ZiS{G1_lQCCLPfHzc5cM z1K%y)W}ZI&^E@AT_VbDHD)QU#ZsCdL4dS22&&{`zuYy09&z<)vSnnt?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71wAz;9q%oLR5THuxEXPTZ-9FpdfXJn+GQjt}zoo*a% znd)s2o}T4gZjqms8kQX5%cu>~;G9yN?UUzP;jbN*6Ortc<62^xlVn_(R2=H%ooyPN z>zrg_YL;K&98?_2s0GsCW}H@%7Mc?pl$WV(l2#d5>}ndGYvAXYnUhvgl4#@@WR~F^ z5fqh{Vd~4M3DV%I?UJ9Km7MNo7+RU06jD+ekRI%*ZDtwjl4I!TTojb)otTmAQsf*G zX2GZn(%@_AWEvb16r7%$QsCwqVv*&mUm6q=?r4%_P!<-NtX&-8Z4i`EY2oMW$*2R; z5E2N?Cl&Y`or|(g!Z=6~d9Ap&bUKQ>d;hxT@2htE|80r+3mL6hi z;bK;rWn7S%Y#N-L<5=WqYUys_R+5zO z?40eNZQ_zzP*7r4X5^GnWm%PKZjqYI7zol(6je~{>K;_)<(}zTk)9c5VpLw{l#%9~ zm{u8{ZjqjwS>h9Hlo*g*o?6Tp1JaO{nC0c8ZItL&=%-k5|Ngv zU7qdao*SZ{Y8)Ew=*t)l(va+u=}z0IR;Zuh8|iN7o^E91SeRJp6qHvGobTjQ zUXT%zT9oeS$`}dKVBirPXzUqO7U*o67Ufi)QWR{M60B%>YI{Pu5D;(=vB@b z4$|NkP!N<>kX%+$Y3ZEfizVTp3&*maVPr=b4xr|Ki`s|Aqe@{|o**{HOSj@L$8s;-ey?Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UjRz04p;m6Qeem)B=;5U{V)M>VQdoFsTP7L&0Pam<;3xQ_)~D z3QPur$rvyh32_6M9RVi8!DJYi3;~`0&&Z$0z@G4E8$t|TZ`49yPr@rg+Ebq)0{$PbMQFL1QVO)M$OtkBCZ zO3&5HO-;|w$<-?})Jse))-BFRGy={4GxA?x;J-kr-$wP1hQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb8{?=(8kqk}|je>Hq&>;QvD}7mnI98UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*Aut*Oqai@g5CEP34_W`ehk<_&J#8MfYcvE#Ltr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%%E*bs1Rtz>0rWaTV2wlFj{GBS?0G&eCfG_*8` zH!-v{H;gy6G&MIiGmkemHL@@=iZ`>gh&MDbH!w7bH!?D}G&V7fH@1Mx|Nmm(|3$2= zqv}ROU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1cpusfX@G8`pL^7!NAM* zk%7OKFOA=Y=MtYGPa?Mjj|$gxj;~xk9N}yqIV9M!Sp(RdSbj6tupD7#XY^+}z_62{ zg`owfUmJxb*u_1K85_+r^HNeP@=Ht7^E30(vr{XJQVUB{i%W{*6O)tkOY=(NGgI|pB zq=L6=q>h48acW*lYEgW4YNe7wh-*X$Tpd`hxHvOEFCJtJnw*<|kgKP=AIJ~|4W;5# zxI#^ZAXhinAXh(U*I)%D$dNvoc}e-Dc_~m;#UPhK*&w4e75x1aTwHxzLtGV{9fO@6 zU0ih(Lj61gLtQlxuG2y2(A4BFWw56pxErN;*u{O#85`}vEdZ>JL}~+ImFI;9zNepy zYXlCDBAKBC@+9uyKr}d%G@C-W*u~Aw8C%@JX#lJ3SOvMEPJ*TftZLB09yQ8INE%2E z#zG(EZ5 z#AD4F8Ng9qT$qztk{X{_T9Oau$72nwctfnhJoTLH;`W-1jmluXsd>pol?5fKDIm`w zw;fR>xS+8EO)#jE7%2r+8Wd|#FM>i(vx$?PU0hR>vDp`#L{Jr@@;IQ@LK6k5B*yocV0*;^vBs zjl$rVOi3-vOis;CtwfGRgdjUKD4{WkkidvNgcK-*5w2CzY>HuF7gtndY-I+=96}+2 z#R|0s8e<3vkXb~;7DNNq_$n<I%~3N_{nu#1a|GB!JcV-X|< z!h%p+q45S10@*-BuFX!Zj4!Dyz!D2sN74~)CRlVMl%fuCLd21rs0kkygGhmL8O&Ll znvKl-?Bb5vjE(Bx5J#IFh)1MJG#LSCkU%2>O%_QXBvR1iL7|5*T1nHEk4-#K8&?FO zX^uBU6W||R|35_Aa-+^34S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu;sqCx<= z{-2MFfrZV8fk%W(jzfdZ2m%=*h%y4NBK9kIrAH+RhXBiT!fHlUjE2By2#kgR-9n(b zs7Z$-+0ikXv(?Af)X6kBA}BaLH>JSMHN+yzSHCnUB;3&?%b+YQG+Dbi!rLGyqte39 z+0)3t$WYh7Lf61Z!O+sm)X>VrQqRE9(A3ZZn~ag6k%h5ANo0YONlKzgL|#={WRQoG zsZpMHrIUABc%_$-VO3OMk#|IBR#>E&MOe0nNs^a^fnz{HZlO;>j)j-MSBh&!m7jlB zQc_l6nQ54peyM+GV0v1GlWR&;l(R*iMPyN`Q%G2#c4k^$zNKS?MTu)ka6z`ezN1@( zzka5haix=6cxjQLnZ8?YVp*ndVTP}dL7I7xNxr_nznOlJYi3bIg=Kh^k9KLeer|qJ zp-)9nc6yYnzkgbmt8-L&sH1asrg5RGTZ(p0X}PI^UwC>_lAB+NL3(ATQ(k3arLk91 zT6(#uyNA9@WoB`nZ=%0}M`U7Rrjx0$S58TIlDT_cPF1pNuzPxjWv)wBm3vO8xnX%k znn|itXmUpSQMR~e*ey+Ze ztB-z0c}P-TS$24desV>ARa#`4W1dlFs98{uUruGIX`V@5Qige2sF`Dhi)E6lQ*wx_ zTR>rQk)u(lPlicORccvtQIj?-)`OB<3%v6DOw%)pL(+WmjEwYCDzeJ8(~ZL|Q@sts z)3cn*E%MV+!;(XMvBj-{rLnoWF*X?^0|R3Nv$9~H(v%3NV8;qa7mMIXlL$ZOq;OC7 zh};m*h?1Z@^PtoUlcJPR1LKH%5@Ove)jiy=ATT^N!z(b=vpCbzuPU(8r8v|i#UjhE zz|uP-&%@s_DZMOGyTIMAR6issJkhYkr6j!~+t4hn#4{zpD9tIW&^OH_*EcCLDY!DV zyrd$_$j{IyE6g)J%Spd7yvou%z&Ohyz$81xx5CKFGRxQ9vb-qSr@SIOFVDv}EGfCr z!q~?-G{rACyeQYlEF{c4tTe~pGcv2Byey}zIG`ZM%`(kDE66$DI50d|Ki$*7&!s%d zpgh;pILaW*Cnzx0*D2J)BPX)VAhRUNJts3YJ2EV!)W;;>+0EUhqAbxMG~F#ZSGy|F z(b&}`z}3e~+rOf!tXMzG!obNp#h|FPs4%Qt-`6xK!?7UPG$K5zpwQc^sMyFcCEugW zBFQL0F)!!}Cpu*F`B_KGkxu{7C7VFL_#o0c2t`+{;VL1`WPC2e6ra4K*l}W{+ zUf$WJ!MV;!CZ=Zj70yA$q1fWqz}(2v+#H*Xp@oI9rHQ#ul($8me^q5@NPeYfQd&lC zL`6i9c9NG;u{j26B3f`Y*3P!71~_XqzQ|4H{-ODw9uT;pu9|NleEgfVpr4f zTmwJH%$&4}l0+lNAhQhTh@hyn3{&iR-PFj`*Z^y+o10pg7)0h}cn0N^rX642~ zfnlyy#+B}wVaDajMiG|f8UB_oq51`3f$3i98Ac}A+6Cz*28D@+;ids;`A)uxrUv?1 zCPDfI=9Yn_*=f$&l}V9N`QZ`S20@8Ie%_W&E*`~EN$Ec3K6$3b<{3$$sV>;Ug>U5;U2yoLD@xx zE`?F0Ipw*C51#o;Ao+D7>$P7Dl;-{{te7`14Sga9v_5d$kj00WB+ zlN&<-XEA#%2%C$AzKvxKnFv$QlYH8sPOvD7m%v9vI=%yz5Nj`Azb4JphDh)OI> zb9OY*cXKpza*nbn@^W+1wlFJl$_RDzEb}lRzU*)hOV0DIOmYs7@XYbm&diPQ$_O#H zFfoqu%Qi91D9Q_oO3KQ}%**zV@X5=LN~$s_&ejghE%p!f&aX%i(XIJp$$S7f>Rr}$@^`DmLtC59%2n|T&QnYwuglm(`_lTc&07FT9FdlzQ;lona| zRBAh>nR*6VCZ*>2RRwvv85oBpRd_@g=Vh6i1!b64WF@7#8Dv$ucvloUXP1|kMphPS zd-@hS8b)~~TX-dTW||gcSQdIGCHgr#x|aEx28ZRDn&y~fRg{IL7lj2_rue#smW2EH z8n|bKM;J#~`1<--IA)}kl%*8-B?eeH1(_QAn>zX$1}0{CL?q|Bc=%KXBsv) zI9uk1J0-fi`xlt`801Cxh8Fl_Cl)$qCFc4DxVr|FhE@4jT0|LEh2^H`TNrD*hlf@c zgoc;9C8h?r8#@)6mS$zS1v(o=r1^zbn1{PrnwOav2Bqg~`&Z`V`Gpo{I~O=v_+)sw zriJ7?d6oHiYa3)|_@zW-RD}CfMg;{$mgnaB1crvWy5&@ATQnCn>B3^&Rof*$Ju5lg z%`mhwJt?H5G$1|LQ`^ik)FsEz(YYun(>pOE*`>%iBn)eR&%oHw!V;^Dk&&sfv4vrz zt4X?JMyX$Zc)ow8k!5OPQf8)~hqH&VxodD(S(R~cUa4D7YI&IvDXqiuQa_($UtfcW zz%YN;DuYC$%CwS zsEX90#GEh})BF@~*C?Ol47ZBp>`LW%EVHGH>YGHzoN>j%7B7E%OKC9Fmunm zyjJAd$4HMPqk`b_bmIWOK-auNudE;=BcmX<%v>W2=Wy-Fzzk1Q?@Bkf z2xq69kO*^M@5q7_?c8wlbYHJ>&&1*&r!2R^sPqa0kBp3@5PhRu?o38+wEAI`OI`>x z7c~XJGEksls8d*4dWfloi&<%waY1IXX>f9mW09k&rMrb&Nn);FNKuuxTT+ITBXZ$r zW@TVzWo)EpY-V6;f?RAG8CV&ZTA7;Y8CaNF7#o&Grj)qnN0?ibx)i6S=7tp%6{dQ7 z7r1AeS*DbEIr;kdg@!ukdH5PTkx*>9yCi0ZTjmCrIt3I~CPoG(nmeVqcxAgd`KMHQ zndB8@x|QU5SsLq`Mdd~21r_@Tcz79xdYOiYIJ&yKgq!EOX63tTJ7s2N6-MTUr-zxR zm*@GITIPG1nisgbCZ~BhIR~d%dgtX-SymcqM}!(^=Vu3dSb90<1VsjVc@;Vt8b`VN zW*7LE`*;Od=I6MlR(h8@Tb6jGmJ~#pCuIdXnU$rNc&4TlMJBtN`*{?X2N{`%W;>N< z7nvp-M0g~cgr;T%B_??odj$FC87F5s`xv>WC07}mSmpc*AY$Fz*wox2sx-vKDL*RGpupQK!>82G zx!Bt?G&If4+ut!VE3iu2!b9J}B-6{=(uIVf6!!>oqf{?_^AazYB(tyxw}7&$!m#}E z(g@4UB2UjS-_X>e0!u@m&@5yBoDi=nXCup~66dJI!ZMdoU*9V2fPj)xpJG?%kbGB{ zU@zmm;z-M?$`Y?Iue7pEFOSO5%mCj=|8Ns0^R$Z6sx))okknwqlnQtELJ#Lmvkceb zd{6I^s5}>Q^HR6;BBStZkBCGc�mh=YpsJeN)Fuw^aYqkgCd{B16Yg&!X(as$8=i z6ThJQ}+V*MEB6j%1V!1 zZ?`BPr}Xk{H%G4&GsE;Ci=c3mjPj&lll<`V2yH*3Y;U)+{IH}#zYIt1{IYzv#FWf( zzx2GE$OuPw&-}F1j3RRvC&wh8w2<(W2v2_ohSAv^@=D23U85l|8Ui!|p#A@BOim2E z+qknh71*w^^e{Qm$f{9ehh+$~xU+F2Cvzryw)td~CYBUsR_Nsyr5mP)R|V$zxn@TemnLUfxCZ-H zR8?f8BxgFhI%|6*XPAU~L`682c)Da2r03;jhni;QTVy7BWtzKW2NeeySB6+bW~OE4 zI6Iq$gaw&Zl(|?~y80Gn=LZCrlzJQF6ct5UCOJ73C#MwnWEZ=61{=6~N4b|qMi>{B z7^ept26z}|7x@>5lx79_hdNiexVvWL8~Iph`)8Yk8W`p~xmSc|msV6%6q}@!rWN~Z zJCgPN8XeSxvl;vib z2d9MRqXPauLdsdX2`K1JuhlRNrCWU*Hl$(c|%hx>T4~6=x@xl%!@S1-UszIvZ!X zI{6hCWkr;khj_c@8HQVyrewS48x#9UVOf+*wozbqUXgFIM`T%IRrWxnq@4WXl9Zz2oNP~*f+(Mm5Wj## zN6Ydgm*B+8upIp~w+f4Vi)@d`a<`PyfK-!^yhOK>03&b9D6cG|jFL3xl$5Nhbe|yi zQj4hMVBgGgeYdP+7jIMT%!u@`)s?j{oiy-&loQ%T!AlIsj#Oy@pvcv!vZzHor{Q^J7>~IgmqO#mX?L31*ud1AU z^K{ejT-WfNOoQ?)=iDmuz`P{)AcI0DAAfCsf1|t-cT)=wW1pgsz^wEfH_zaVB$HIv z?BEg$C$j)cmk5KB@F>RugP`(MpYr?w*NTiX6GwxTAjx=yKD;|iHWR~d@nN*hV7^Gk3Ev7C z>zP^^84{imo|9cv=Ioji8R==^SXo}^7@lqx7!{&jk?tO9ndV()YU1Hp;^!Bx9pzS7 z=4p_TmYnEa8kT909vWy=m0FnZX6kDmVHlq16Ka^{ZtNW7S>$0_SX6G1s_j&0?39+} zmXhxk?(3Nv9_DUZ5)kMf=IP>YQfiV?k>*=k6yRKF?vY#Kk{V)CtnKVslI>d*2pZTc z*0%61&P&Tm^e+i_GVsYYcXbR5b4?AjOmlP2%E)v}b}n{HG!Mwj^ego-F)+(X&-3;7 z&hiKfPsw-pO)-zKG%*j)POr=i3XCwx%_z=`GVwL@F{=p9vB)t<4oV8vcM361vv4)C zFf8@U%neJ83e7bzuPXK`2&l{{Fg408%c}@24>bwX_sh3P%rPx=%u93)%l0Tub@Op@ zN->M_(>C$7jC4)s`@{D4Ax6p_%AKwyfFQWke(%{4_(=r#Ipz!1{Bd^G!%zVQ{ z3&UiK%%D=2$VmLPTasg?pNn^)QAR~jP@cbcWr=g5xw&z+bCqXmwpn6$adDBCt7Sz{ zSY$>~RKA;Ud9Gz*evosLzmc|ANU=|0pnpJ?d5&dZMz&9`OHz7Lphc;EN@RYbPi9a? zkdH}ZaFStmZjpC}YoT#|zISGcONmcPequpXX_TjBQDJb9QBZ1_XF!QXMMOxkOQE)3 zXt{T`SCp4&R(VuFUZ#0PRajzJRGzo7p=&{SNmQY>hhdezzL8s*OH{d`NsxYIXjpi* zd17T?M2K6lmv3cxZk4}*QF&^?P-wZ6iAj;8MOn5{SwvD!cu{#~h`T{XL{*lfXK=P_iKSn7 zd4X?fRF-pgXmX^XlTU=XPj+%{VtQV=Q+|bqWvXGJad@zYL2^Y|uxnaKaG*g(fmvm! zu}@@vc3N(khf9j5PkvZpslThGPpOwvq;aLTOO?52KxK++j=NiOc4eq}VML_Aw`Y}S zWKwW&uD4&fqk&;jWs4^hI3@YE`J|LbxmTuU=6M!4RfHr47J8UQl;@Zv=QtLJr#m@T zB>ENTCuIgXxhI;@s-5H&;OLwcSP_Xzr-5TsSVV@CZ$MI+g`

Fd(Vt7HewsTT?m0wACqI*iD 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

~bn7#dNmTPKKT;c6nQQ(+q8R(I16q%Q7 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 literal 0 HcmV?d00001 diff --git a/store/@calendar1312:systemli.org_DJEUFOANEU.ignored_devices b/store/@calendar1312:systemli.org_DJEUFOANEU.ignored_devices new file mode 100644 index 0000000..e472206 --- /dev/null +++ b/store/@calendar1312:systemli.org_DJEUFOANEU.ignored_devices @@ -0,0 +1,19 @@ +@calendar1312:systemli.org SVELWKREDW matrix-ed25519 69/0a5ay4XXyysMvr65uOfYA+I135nqFtr0S1JohW2A +@calendar1312:systemli.org EWYNXPWDOD matrix-ed25519 IPG23JglVQvZlD9OJgQsA3DZM0AyPUEOPnbqi66oPeY +@calendar1312:systemli.org EPDUUOEQKX matrix-ed25519 iKFIArDnVUPwazFo6MEVGvLocujVgfWQdC5UDSab0MQ +@calendar1312:systemli.org FMHATRVGCU matrix-ed25519 oSFkE/K5yZ7GonCPCe3iBctBHDZGbXO0KcWDSM7X8nM +@calendar1312:systemli.org BWYSIANFJX matrix-ed25519 F+APYKsxiQ7H+oGf98g5+YFz9uFIyVlzznTD12Ruhy4 +@calendar1312:systemli.org YPOSRBCBKB matrix-ed25519 duGNw1EwQfh1I8zWVtNXm03f4cGBGxUBIYgje0/LDR0 +@calendar1312:systemli.org NJKYRLIUFV matrix-ed25519 Y2ZFLxOqnW9DLZc17WSvP6AzYyGMPpixJ48Ld3iH3eU +@calendar1312:systemli.org EYXOTLPGCI matrix-ed25519 6CjuRJya9rsvNPwS1wH6h3bi8TgegwJV7w34a+QfXJQ +@calendar1312:systemli.org ABCDEFGHIJ matrix-ed25519 BwThHHePgCNpI6RggBZz8RBEBHYrpN4vrVt8DbjzjSI +@kalipso:matrix.org AYXVOBIYAQ matrix-ed25519 4CjbmezzldO57HdOFFT91/2dPmdwH2KlgONR+RLUysY +@kalipso:matrix.org PAJFNONXNZ matrix-ed25519 /FUXVLMt+J2POuSaj5vDLRWcV2JYrio1a81c8iRuDYY +@kalipso:matrix.org SBAYTLYYBZ matrix-ed25519 9tKLZybbCg1kYyvtppgYBJw2n6x0Y0lWksrER6Jop8M +@neinnein:matrix.org OBHZSKMKBF matrix-ed25519 v+iomze8gbkmJMmvhbGn7tI3uPcMNOVQcQ9aHuthMsw +@neinnein:matrix.org RAJPTDDCMK matrix-ed25519 MnKCaKIFlxDNcuUuYr8t5GkYKYD14hjBF2jYFD7ezyk +@neinnein:matrix.org ZXECGZWRXV matrix-ed25519 O63ZSkHWxojuGhKm2/qvj+KImdDNmNUv9pzz7Uxjuis +@clavicula:matrix.org DIVEKFVLJK matrix-ed25519 NFPoTFtNVKDPSyjsl7DjyS7KYmUVzxeqUZFE/Y6FPPA +@feog:matrix.org JZXGTEGXOI matrix-ed25519 T9S6NN+J88KrE1M9Z+CheEnQ3wfaPm0P7vNQHH7Ips0 +@reka34:systemli.org ELSZHNQAJZ matrix-ed25519 hyGd1b9npnvQTjfGKttjlaZlWS72KTT8V/6cL7hmjs0 +@reka34:systemli.org BTIAGQHOJJ matrix-ed25519 cZpSkCDKbwA5yTVAXAa6C9SfJ2uqcrAQ3xeuGgMW8Tw diff --git a/store/@calendar1312:systemli.org_DJEUFOANEU.trusted_devices b/store/@calendar1312:systemli.org_DJEUFOANEU.trusted_devices new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py new file mode 100644 index 0000000..ff6a20a --- /dev/null +++ b/tests/test_callbacks.py @@ -0,0 +1,50 @@ +import unittest +from unittest.mock import Mock + +import nio + +from my_project_name.callbacks import Callbacks +from my_project_name.storage import Storage + +from tests.utils import make_awaitable, run_coroutine + + +class CallbacksTestCase(unittest.TestCase): + def setUp(self) -> None: + # Create a Callbacks object and give it some Mock'd objects to use + self.fake_client = Mock(spec=nio.AsyncClient) + self.fake_client.user = "@fake_user:example.com" + + self.fake_storage = Mock(spec=Storage) + + # We don't spec config, as it doesn't currently have well defined attributes + self.fake_config = Mock() + + self.callbacks = Callbacks( + self.fake_client, self.fake_storage, self.fake_config + ) + + def test_invite(self): + """Tests the callback for InviteMemberEvents""" + # Tests that the bot attempts to join a room after being invited to it + + # Create a fake room and invite event to call the 'invite' callback with + fake_room = Mock(spec=nio.MatrixRoom) + fake_room_id = "!abcdefg:example.com" + fake_room.room_id = fake_room_id + + fake_invite_event = Mock(spec=nio.InviteMemberEvent) + fake_invite_event.sender = "@some_other_fake_user:example.com" + + # Pretend that attempting to join a room is always successful + self.fake_client.join.return_value = make_awaitable(None) + + # Pretend that we received an invite event + run_coroutine(self.callbacks.invite(fake_room, fake_invite_event)) + + # Check that we attempted to join the room + self.fake_client.join.assert_called_once_with(fake_room_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..4f942f5 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,81 @@ +import unittest +from unittest.mock import Mock + +from my_project_name.config import Config +from my_project_name.errors import ConfigError + + +class ConfigTestCase(unittest.TestCase): + def test_get_cfg(self): + """Test that Config._get_cfg works correctly""" + + # Here's our test dictionary. Pretend that this was parsed from a YAML config file. + test_config_dict = {"a_key": 5, "some_key": {"some_other_key": "some_value"}} + + # We create a fake config using Mock. _get_cfg will attempt to pull from self.config_dict, + # so we use a Mock to quickly create a dummy class, and set the 'config_dict' attribute to + # our test dictionary. + fake_config = Mock() + fake_config.config_dict = test_config_dict + + # Now let's make some calls to Config._get_cfg. We provide 'fake_cfg' as the first argument + # as a substitute for 'self'. _get_cfg will then be pulling values from fake_cfg.config_dict. + + # Test that we can get the value of a top-level key + self.assertEqual( + Config._get_cfg(fake_config, ["a_key"]), + 5, + ) + + # Test that we can get the value of a nested key + self.assertEqual( + Config._get_cfg(fake_config, ["some_key", "some_other_key"]), + "some_value", + ) + + # Test that the value provided by the default option is used when a key does not exist + self.assertEqual( + Config._get_cfg( + fake_config, + ["a_made_up_key", "this_does_not_exist"], + default="The default", + ), + "The default", + ) + + # Test that the value provided by the default option is *not* used when a key *does* exist + self.assertEqual( + Config._get_cfg(fake_config, ["a_key"], default="The default"), + 5, + ) + + # Test that keys that do not exist raise a ConfigError when the required argument is True + with self.assertRaises(ConfigError): + Config._get_cfg( + fake_config, ["a_made_up_key", "this_does_not_exist"], required=True + ) + + # Test that a ConfigError is not returned when a non-existent key is provided and required is False + self.assertIsNone( + Config._get_cfg( + fake_config, ["a_made_up_key", "this_does_not_exist"], required=False + ) + ) + + # Test that default is used for non-existent keys, even if required is True + # (Typically one shouldn't use a default with required=True anyways...) + self.assertEqual( + Config._get_cfg( + fake_config, + ["a_made_up_key", "this_does_not_exist"], + default="something", + required=True, + ), + "something", + ) + + # TODO: Test creating a test yaml file, passing the path to Config and _parse_config_values is called correctly + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..3fcf429 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,22 @@ +# Utility functions to make testing easier +import asyncio +from typing import Any, Awaitable + + +def run_coroutine(result: Awaitable[Any]) -> Any: + """Wrapper for asyncio functions to allow them to be run from synchronous functions""" + loop = asyncio.get_event_loop() + result = loop.run_until_complete(result) + loop.close() + return result + + +def make_awaitable(result: Any) -> Awaitable[Any]: + """ + Makes an awaitable, suitable for mocking an `async` function. + This uses Futures as they can be awaited multiple times so can be returned + to multiple callers. + """ + future = asyncio.Future() # type: ignore + future.set_result(result) + return future