Initial commit
This commit is contained in:
commit
e3c1de3a84
34 changed files with 2511 additions and 0 deletions
39
.github/workflows/lint.yml
vendored
Normal file
39
.github/workflows/lint.yml
vendored
Normal file
|
@ -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
|
57
.github/workflows/test.yml
vendored
Normal file
57
.github/workflows/test.yml
vendored
Normal file
|
@ -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
|
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
@ -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
|
96
CONTRIBUTING.md
Normal file
96
CONTRIBUTING.md
Normal file
|
@ -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.
|
177
LICENSE
Normal file
177
LICENSE
Normal file
|
@ -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
|
160
README.md
Normal file
160
README.md
Normal file
|
@ -0,0 +1,160 @@
|
|||
# Nio Template [](https://github.com/poljar/matrix-nio) <a href="https://matrix.to/#/#nio-template:matrix.org"><img src="https://img.shields.io/matrix/nio-template:matrix.org?color=blue&label=Join%20the%20Matrix%20Room&server_fqdn=matrix-client.matrix.org" /></a>
|
||||
|
||||
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!
|
173
SETUP.md
Normal file
173
SETUP.md
Normal file
|
@ -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).
|
5
docker/.env
Normal file
5
docker/.env
Normal file
|
@ -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
|
101
docker/Dockerfile
Normal file
101
docker/Dockerfile
Normal file
|
@ -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"]
|
71
docker/Dockerfile.dev
Normal file
71
docker/Dockerfile.dev
Normal file
|
@ -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"]
|
156
docker/README.md
Normal file
156
docker/README.md
Normal file
|
@ -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 .
|
||||
```
|
32
docker/build_and_install_libolm.sh
Executable file
32
docker/build_and_install_libolm.sh
Executable file
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env sh
|
||||
#
|
||||
# Call with the following arguments:
|
||||
#
|
||||
# ./build_and_install_libolm.sh <libolm version> <python bindings install dir>
|
||||
#
|
||||
# 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
|
64
docker/docker-compose.yml
Normal file
64
docker/docker-compose.yml
Normal file
|
@ -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
|
16
docker/my-project-name.service
Normal file
16
docker/my-project-name.service
Normal file
|
@ -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
|
49
docker/start-dev.sh
Executable file
49
docker/start-dev.sh
Executable file
|
@ -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
|
10
my-project-name
Executable file
10
my-project-name
Executable file
|
@ -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)
|
8
my_project_name/__init__.py
Normal file
8
my_project_name/__init__.py
Normal file
|
@ -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"
|
95
my_project_name/bot_commands.py
Normal file
95
my_project_name/bot_commands.py
Normal file
|
@ -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.",
|
||||
)
|
211
my_project_name/callbacks.py
Normal file
211
my_project_name/callbacks.py
Normal file
|
@ -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}."
|
||||
)
|
154
my_project_name/chat_functions.py
Normal file
154
my_project_name/chat_functions.py
Normal file
|
@ -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'<a href="https://matrix.to/#/{user_id}">{displayname}</a>'
|
||||
|
||||
|
||||
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,
|
||||
)
|
136
my_project_name/config.py
Normal file
136
my_project_name/config.py
Normal file
|
@ -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
|
12
my_project_name/errors.py
Normal file
12
my_project_name/errors.py
Normal file
|
@ -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,))
|
121
my_project_name/main.py
Normal file
121
my_project_name/main.py
Normal file
|
@ -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())
|
52
my_project_name/message_responses.py
Normal file
52
my_project_name/message_responses.py
Normal file
|
@ -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)
|
126
my_project_name/storage.py
Normal file
126
my_project_name/storage.py
Normal file
|
@ -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)
|
49
sample.config.yaml
Normal file
49
sample.config.yaml
Normal file
|
@ -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
|
20
scripts-dev/lint.sh
Executable file
20
scripts-dev/lint.sh
Executable file
|
@ -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
|
64
scripts-dev/rename_project.sh
Executable file
64
scripts-dev/rename_project.sh
Executable file
|
@ -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!"
|
19
setup.cfg
Normal file
19
setup.cfg
Normal file
|
@ -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
|
59
setup.py
Normal file
59
setup.py
Normal file
|
@ -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"],
|
||||
)
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
50
tests/test_callbacks.py
Normal file
50
tests/test_callbacks.py
Normal file
|
@ -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()
|
81
tests/test_config.py
Normal file
81
tests/test_config.py
Normal file
|
@ -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()
|
22
tests/utils.py
Normal file
22
tests/utils.py
Normal file
|
@ -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
|
Loading…
Reference in a new issue