From e3c1de3a84b69d5d03d506a61384c86c8b2e32a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A4=9C=E5=9D=82=E9=9B=85?=
<23130178+ShadowRZ@users.noreply.github.com>
Date: Tue, 6 Sep 2022 16:50:14 +0800
Subject: [PATCH] Initial commit
---
.github/workflows/lint.yml | 39 +++++
.github/workflows/test.yml | 57 ++++++++
.gitignore | 26 ++++
CONTRIBUTING.md | 96 ++++++++++++
LICENSE | 177 ++++++++++++++++++++++
README.md | 160 ++++++++++++++++++++
SETUP.md | 173 ++++++++++++++++++++++
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/__init__.py | 8 +
my_project_name/bot_commands.py | 95 ++++++++++++
my_project_name/callbacks.py | 211 +++++++++++++++++++++++++++
my_project_name/chat_functions.py | 154 +++++++++++++++++++
my_project_name/config.py | 136 +++++++++++++++++
my_project_name/errors.py | 12 ++
my_project_name/main.py | 121 +++++++++++++++
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 | 59 ++++++++
tests/__init__.py | 0
tests/test_callbacks.py | 50 +++++++
tests/test_config.py | 81 ++++++++++
tests/utils.py | 22 +++
34 files changed, 2511 insertions(+)
create mode 100644 .github/workflows/lint.yml
create mode 100644 .github/workflows/test.yml
create mode 100644 .gitignore
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 SETUP.md
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/__init__.py
create mode 100644 my_project_name/bot_commands.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 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/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..9350e67
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,39 @@
+# This workflow will install Python dependencies, then run various linting programs on a single Python version
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: Lint
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ lint:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python 3.9
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.9
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -U isort==5.6.4 flake8==3.8.4 flake8-comprehensions==3.3.1 black==20.8b1
+
+ - name: Check import statement sorting
+ run: |
+ isort -c --df my_project_name/ my-project-name tests
+
+ - name: Python syntax errors, undefined names, etc.
+ run: |
+ flake8 . --count --show-source --statistics
+
+ - name: PEP8 formatting
+ run: |
+ black --check --diff my_project_name/ my-project-name tests
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..a731ab5
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,57 @@
+# This workflow will install Python dependencies, then run unit testing across the earliest and latest supported Python versions
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: Run unit tests
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ python_36:
+
+ # We need to use 20.04 to get access to the libolm3 package
+ runs-on: ubuntu-20.04
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python 3.6
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.6
+
+ - name: Install project dependencies
+ run: |
+ # Install libolm, required for end-to-end encryption functionality
+ sudo apt install -y libolm-dev libolm3
+ # Install python dependencies
+ python setup.py install
+
+ - name: Run unit tests
+ run: |
+ python -m unittest
+
+ python_39:
+
+ # We need to use 20.04 to get access to the libolm3 package
+ runs-on: ubuntu-20.04
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python 3.9
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.9
+
+ - name: Install project dependencies
+ run: |
+ # Install libolm, required for end-to-end encryption functionality
+ sudo apt install -y libolm-dev libolm3
+ # Install python dependencies
+ python setup.py install
+
+ - name: Run unit tests
+ run: |
+ python -m unittest
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7ddc594
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+# PyCharm
+.idea/
+
+# Python virtualenv environment folders
+env/
+env3/
+.env/
+
+# Bot local files
+*.db
+store/
+
+# Config file
+config.yaml
+
+# Python
+__pycache__/
+*.egg-info/
+build/
+dist/
+
+# Config file
+config.yaml
+
+# Log files
+*.log
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/docker/.env b/docker/.env
new file mode 100644
index 0000000..a047209
--- /dev/null
+++ b/docker/.env
@@ -0,0 +1,5 @@
+# Default environment variables used in docker-compose.yml.
+# Overridden by the host's environment variables
+
+# Where `localhost` should route to
+HOST_IP_ADDRESS=127.0.0.1
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 0000000..2c2cc39
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,101 @@
+# To build the image, run `docker build` command from the root of the
+# repository:
+#
+# docker build -f docker/Dockerfile .
+#
+# There is an optional PYTHON_VERSION build argument which sets the
+# version of python to build against. For example:
+#
+# docker build -f docker/Dockerfile --build-arg PYTHON_VERSION=3.10 .
+#
+# An optional LIBOLM_VERSION build argument which sets the
+# version of libolm to build against. For example:
+#
+# docker build -f docker/Dockerfile --build-arg LIBOLM_VERSION=3.2.10 .
+#
+
+
+##
+## Creating a builder container
+##
+
+# We use an initial docker container to build all of the runtime dependencies,
+# then transfer those dependencies to the container we're going to ship,
+# before throwing this one away
+ARG PYTHON_VERSION=3.10
+FROM docker.io/python:${PYTHON_VERSION}-alpine as builder
+
+##
+## Build libolm for matrix-nio e2e support
+##
+
+# Install libolm build dependencies
+ARG LIBOLM_VERSION=3.2.10
+RUN apk add --no-cache \
+ make \
+ cmake \
+ gcc \
+ g++ \
+ git \
+ libffi-dev \
+ yaml-dev \
+ python3-dev
+
+# Build libolm
+#
+# Also build the libolm python bindings and place them at /python-libs
+# We will later copy contents from both of these folders to the runtime
+# container
+COPY docker/build_and_install_libolm.sh /scripts/
+RUN /scripts/build_and_install_libolm.sh ${LIBOLM_VERSION} /python-libs
+
+# Install Postgres dependencies
+RUN apk add --no-cache \
+ musl-dev \
+ libpq \
+ postgresql-dev
+
+# Install python runtime modules. We do this before copying the source code
+# such that these dependencies can be cached
+# This speeds up subsequent image builds when the source code is changed
+RUN mkdir -p /src/my_project_name
+COPY my_project_name/__init__.py /src/my_project_name/
+COPY README.md my-project-name /src/
+
+# Build the dependencies
+COPY setup.py /src/setup.py
+RUN pip install --prefix="/python-libs" --no-warn-script-location "/src/.[postgres]"
+
+# Now copy the source code
+COPY *.py *.md /src/
+COPY my_project_name/*.py /src/my_project_name/
+
+# And build the final module
+RUN pip install --prefix="/python-libs" --no-warn-script-location "/src/.[postgres]"
+
+##
+## Creating the runtime container
+##
+
+# Create the container we'll actually ship. We need to copy libolm and any
+# python dependencies that we built above to this container
+FROM docker.io/python:${PYTHON_VERSION}-alpine
+
+# Copy python dependencies from the "builder" container
+COPY --from=builder /python-libs /usr/local
+
+# Copy libolm from the "builder" container
+COPY --from=builder /usr/local/lib/libolm* /usr/local/lib/
+
+# Install any native runtime dependencies
+RUN apk add --no-cache \
+ libstdc++ \
+ libpq \
+ postgresql-dev
+
+# Specify a volume that holds the config file, SQLite3 database,
+# and the matrix-nio store
+VOLUME ["/data"]
+
+# Start the bot
+ENTRYPOINT ["my-project-name", "/data/config.yaml"]
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
new file mode 100644
index 0000000..0f7987b
--- /dev/null
+++ b/docker/Dockerfile.dev
@@ -0,0 +1,71 @@
+# This dockerfile is crafted specifically for development purposes.
+# Please use `Dockerfile` instead if you wish to deploy for production.
+#
+# This file differs as it does not use a builder container, nor does it
+# reinstall the project's python package after copying the source code,
+# saving significant time during rebuilds.
+#
+# To build the image, run `docker build` command from the root of the
+# repository:
+#
+# docker build -f docker/Dockerfile .
+#
+# There is an optional PYTHON_VERSION build argument which sets the
+# version of python to build against. For example:
+#
+# docker build -f docker/Dockerfile --build-arg PYTHON_VERSION=3.10 .
+#
+# An optional LIBOLM_VERSION build argument which sets the
+# version of libolm to build against. For example:
+#
+# docker build -f docker/Dockerfile --build-arg LIBOLM_VERSION=3.2.10 .
+#
+
+ARG PYTHON_VERSION=3.10
+FROM docker.io/python:${PYTHON_VERSION}-alpine
+
+##
+## Build libolm for matrix-nio e2e support
+##
+
+# Install libolm build dependencies
+ARG LIBOLM_VERSION=3.2.10
+RUN apk add --no-cache \
+ make \
+ cmake \
+ gcc \
+ g++ \
+ git \
+ libffi-dev \
+ yaml-dev \
+ python3-dev
+
+# Build libolm
+COPY docker/build_and_install_libolm.sh /scripts/
+RUN /scripts/build_and_install_libolm.sh ${LIBOLM_VERSION}
+
+# Install native runtime dependencies
+RUN apk add --no-cache \
+ musl-dev \
+ libpq \
+ postgresql-dev \
+ libstdc++
+
+# Install python runtime modules. We do this before copying the source code
+# such that these dependencies can be cached
+RUN mkdir -p /src/my_project_name
+COPY my_project_name/__init__.py /src/my_project_name/
+COPY README.md my-project-name /src/
+COPY setup.py /src/setup.py
+RUN pip install -e "/src/.[postgres]"
+
+# Now copy the source code
+COPY my_project_name/*.py /src/my_project_name/
+COPY *.py /src/
+
+# Specify a volume that holds the config file, SQLite3 database,
+# and the matrix-nio store
+VOLUME ["/data"]
+
+# Start the app
+ENTRYPOINT ["my-project-name", "/data/config.yaml"]
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 0000000..b039e31
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,156 @@
+# Docker
+
+The docker image will run my-project-name with a SQLite database and
+end-to-end encryption dependencies included. For larger deployments, a
+connection to a Postgres database backend is recommended.
+
+## Setup
+
+### The `/data` volume
+
+The docker container expects the `config.yaml` file to exist at
+`/data/config.yaml`. To easily configure this, it is recommended to create a
+directory on your filesystem, and mount it as `/data` inside the container:
+
+```
+mkdir data
+```
+
+We'll later mount this directory into the container so that its contents
+persist across container restarts.
+
+### Creating a config file
+
+Copy `sample.config.yaml` to a file named `config.yaml` inside of your newly
+created `data` directory. Fill it out as you normally would, with a few minor
+differences:
+
+* The bot store directory should reside inside of the data directory so that it
+ is not wiped on container restart. Change it from the default to
+ `/data/store`. There is no need to create this directory yourself, it will be
+ created on startup if it does not exist.
+
+* Choose whether you want to use SQLite or Postgres as your database backend.
+ Postgres has increased performance over SQLite, and is recommended for
+ deployments with many users.
+
+ If using SQLite, ensure your database file is
+ stored inside the `/data` directory:
+
+ ```
+ database: "sqlite:///data/bot.db"
+ ```
+
+ If using postgres, point to your postgres instance instead:
+
+ ```
+ database: "postgres://username:password@postgres/my-project-name?sslmode=disable"
+ ```
+
+ **Note:** a postgres container is defined in `docker-compose.yaml` for your convenience.
+ If you would like to use it, set your database connection string to:
+
+ ```
+ database: "postgres://postgres:somefancypassword@postgres/postgres?sslmode=disable"
+ ```
+
+ The password `somefancypassword` is defined in the docker compose file.
+
+Change any other config values as necessary. For instance, you may also want to
+store log files in the `/data` directory.
+
+## Running
+
+First, create a volume for the data directory created in the above section:
+
+```
+docker volume create \
+ --opt type=none \
+ --opt o=bind \
+ --opt device="/path/to/data/dir" data_volume
+```
+
+Optional: If you want to use the postgres container defined in
+`docker-compose.yaml`, start that first:
+
+```
+docker-compose up -d postgres
+```
+
+Start the bot with:
+
+```
+docker-compose up my-project-name
+```
+
+This will run the bot and log the output to the terminal. You can instead run
+the container detached with the `-d` flag:
+
+```
+docker-compose up -d my-project-name
+```
+
+(Logs can later be accessed with the `docker logs` command).
+
+This will use the `latest` tag from
+[Docker Hub](https://hub.docker.com/somebody/my-project-name).
+
+If you would rather run from the checked out code, you can use:
+
+```
+docker-compose up local-checkout
+```
+
+This will build an optimized, production-ready container. If you are developing
+instead and would like a development container for testing local changes, use
+the `start-dev.sh` script and consult [CONTRIBUTING.md](../CONTRIBUTING.md).
+
+**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.
+
+## Updating
+
+To update the container, navigate to the bot's `docker` directory and run:
+
+```
+docker-compose pull my-project-name
+```
+
+Then restart the bot.
+
+## Systemd
+
+A systemd service file is provided for your convenience at
+[my-project-name.service](my-project-name.service). The service uses
+`docker-compose` to start and stop the bot.
+
+Copy the file to `/etc/systemd/system/my-project-name.service` and edit to
+match your setup. You can then start the bot with:
+
+```
+systemctl start my-project-name
+```
+
+and stop it with:
+
+```
+systemctl stop my-project-name
+```
+
+To run the bot on system startup:
+
+```
+systemctl enable my-project-name
+```
+
+## Building the image
+
+To build a production image from source, use the following `docker build` command
+from the repo's root:
+
+```
+docker build -t somebody/my-project-name:latest -f docker/Dockerfile .
+```
diff --git a/docker/build_and_install_libolm.sh b/docker/build_and_install_libolm.sh
new file mode 100755
index 0000000..b9cd1ed
--- /dev/null
+++ b/docker/build_and_install_libolm.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env sh
+#
+# Call with the following arguments:
+#
+# ./build_and_install_libolm.sh
+#
+# 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/__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/bot_commands.py b/my_project_name/bot_commands.py
new file mode 100644
index 0000000..4286309
--- /dev/null
+++ b/my_project_name/bot_commands.py
@@ -0,0 +1,95 @@
+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
+
+
+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()
+ elif self.command.startswith("react"):
+ await self._react()
+ elif self.command.startswith("help"):
+ await self._show_help()
+ else:
+ await self._unknown_command()
+
+ 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 = "Some text"
+ 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 a bot made with matrix-nio! Use `help commands` to view "
+ "available commands."
+ )
+ await send_text_to_room(self.client, self.room.room_id, text)
+ return
+
+ topic = self.args[0]
+ if topic == "rules":
+ text = "These are the rules!"
+ elif topic == "commands":
+ text = "Available commands: ..."
+ else:
+ text = "Unknown help topic!"
+ 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/callbacks.py b/my_project_name/callbacks.py
new file mode 100644
index 0000000..8019c1e
--- /dev/null
+++ b/my_project_name/callbacks.py
@@ -0,0 +1,211 @@
+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 invite_event_filtered_callback(
+ self, room: MatrixRoom, event: InviteMemberEvent
+ ) -> None:
+ """
+ Since the InviteMemberEvent is fired for every m.room.member state received
+ in a sync response's `rooms.invite` section, we will receive some that are
+ not actually our own invite event (such as the inviter's membership).
+ This makes sure we only call `callbacks.invite` with our own invite events.
+ """
+ if event.state_key == self.client.user_id:
+ # This is our own membership (invite) event
+ await self.invite(room, event)
+
+ 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..9554ffd
--- /dev/null
+++ b/my_project_name/main.py
@@ -0,0 +1,121 @@
+#!/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_event_filtered_callback, (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..54e2196
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,59 @@
+#!/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",
+ ],
+ 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/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