├── screenshot.png ├── docker ├── .env ├── matrix-reminder-bot.service ├── build_and_install_libolm.sh ├── start-dev.sh ├── Dockerfile.dev ├── docker-compose.yml ├── Dockerfile └── README.md ├── matrix_reminder_bot ├── __init__.py ├── errors.py ├── main.py ├── functions.py ├── callbacks.py ├── reminder.py ├── config.py ├── storage.py └── bot_commands.py ├── matrix-reminder-bot ├── scripts-dev └── lint.sh ├── .gitignore ├── .github ├── dependabot.yml ├── issue_template.md └── workflows │ ├── lint.yml │ └── docker.yml ├── setup.cfg ├── RELEASING.md ├── setup.py ├── CONTRIBUTING.md ├── sample.config.yaml ├── README.md ├── LICENSE └── CHANGELOG.md /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anoadragon453/matrix-reminder-bot/HEAD/screenshot.png -------------------------------------------------------------------------------- /docker/.env: -------------------------------------------------------------------------------- 1 | # Default environment variables used in docker-compose.yml. 2 | # Overridden by the host's environment variables 3 | 4 | # Where `localhost` should route to 5 | HOST_IP_ADDRESS=127.0.0.1 6 | -------------------------------------------------------------------------------- /matrix_reminder_bot/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | # Check that we're not running on an unsupported Python version. 4 | if sys.version_info < (3, 8): 5 | print("matrix_reminder_bot requires Python 3.8 or above.") 6 | sys.exit(1) 7 | 8 | __version__ = "0.4.0" 9 | -------------------------------------------------------------------------------- /matrix-reminder-bot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import asyncio 3 | 4 | try: 5 | from matrix_reminder_bot import main 6 | 7 | # Run the main function of the bot 8 | asyncio.get_event_loop().run_until_complete(main.main()) 9 | except ImportError as e: 10 | print("Unable to import matrix_reminder_bot.main:", e) 11 | -------------------------------------------------------------------------------- /scripts-dev/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Runs linting scripts over the local checkout 4 | # isort - sorts import statements 5 | # flake8 - lints and finds mistakes 6 | # black - opinionated code formatter 7 | 8 | set -e 9 | 10 | if [ $# -ge 1 ] 11 | then 12 | files=$* 13 | else 14 | files="matrix_reminder_bot matrix-reminder-bot" 15 | fi 16 | 17 | echo "Linting these locations: $files" 18 | isort $files 19 | flake8 $files 20 | python3 -m black $files 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # PyCharm 2 | .idea/ 3 | 4 | # Python virtualenv environment folders 5 | venv/ 6 | env/ 7 | env3/ 8 | .env/ 9 | 10 | # Bot local files 11 | *.db 12 | 13 | # Config file 14 | config.yaml 15 | 16 | # Python 17 | __pycache__/ 18 | 19 | # Packaging files 20 | dist/ 21 | build/ 22 | 23 | # Log files 24 | *.log 25 | 26 | # Default docker volume location 27 | data/ 28 | 29 | # Default bot store directory 30 | store/ 31 | 32 | # Python egg directories 33 | *.egg-info/ 34 | -------------------------------------------------------------------------------- /docker/matrix-reminder-bot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=A matrix bot to remind you about things 3 | 4 | [Service] 5 | Type=simple 6 | User=matrix-reminder-bot 7 | Group=matrix-reminder-bot 8 | WorkingDirectory=/path/to/matrix-reminder-bot/docker 9 | ExecStart=/usr/bin/docker-compose up matrix-reminder-bot 10 | ExecStop=/usr/bin/docker-compose stop matrix-reminder-bot 11 | RemainAfterExit=yes 12 | Restart=always 13 | RestartSec=3 14 | 15 | [Install] 16 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # see https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes 3 | # for error codes. The ones we ignore are: 4 | # W503: line break before binary operator 5 | # W504: line break after binary operator 6 | # E203: whitespace before ':' (which is contrary to pep8?) 7 | # E731: do not assign a lambda expression, use a def 8 | # E501: Line too long (black enforces this for us) 9 | ignore=W503,W504,E203,E731,E501 10 | 11 | [isort] 12 | line_length = 88 13 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER 14 | default_section=THIRDPARTY 15 | known_first_party=matrix_reminder_bot 16 | known_tests=tests 17 | multi_line_output=3 18 | include_trailing_comma=true 19 | combine_as_imports=true 20 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | description: Generic issue 4 | --- 5 | ### Observed Behavior 6 | 7 | Please describe in detail *what* you observed. You might also want to add logs or screenshots here. 8 | 9 | ### Expected Behavior 10 | 11 | Please describe what should have happened instead, explaining *why* this is an issue. 12 | 13 | ### Steps to Reproduce 14 | 15 | Please describe the steps needed to reproduce the behavior stated above. 16 | 17 | ### Environment 18 | 19 | - **Bot version**: 20 | - **Deployment method**: 21 | - **Operating system**: 22 | - **Server timezone**: 23 | 24 | ### Proposed Fix (optional) 25 | 26 | If you have any suggestions on how to fix this issue, please add them here. 27 | You may also describe temporary workarounds. 28 | -------------------------------------------------------------------------------- /matrix_reminder_bot/errors.py: -------------------------------------------------------------------------------- 1 | class ConfigError(RuntimeError): 2 | """An error encountered during reading the config file 3 | 4 | Args: 5 | msg (str): The message displayed to the bot operator on error 6 | """ 7 | 8 | def __init__(self, msg: str): 9 | super().__init__("%s" % (msg,)) 10 | 11 | 12 | class CommandError(RuntimeError): 13 | """An error encountered while processing a command 14 | 15 | Args: 16 | msg: The message that is sent back to the user on error 17 | """ 18 | 19 | def __init__(self, msg: str): 20 | super().__init__("%s" % (msg,)) 21 | self.msg = msg 22 | 23 | 24 | class CommandSyntaxError(RuntimeError): 25 | """An error encountered if syntax of a called command was violated""" 26 | 27 | def __init__(self): 28 | super().__init__() 29 | -------------------------------------------------------------------------------- /docker/build_and_install_libolm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # 3 | # Call with the following arguments: 4 | # 5 | # ./build_and_install_libolm.sh 6 | # 7 | # Example: 8 | # 9 | # ./build_and_install_libolm.sh 3.1.4 /python-bindings 10 | # 11 | # Note that if a python bindings installation directory is not supplied, bindings will 12 | # be installed to the default directory. 13 | # 14 | 15 | set -ex 16 | 17 | # Download the specified version of libolm 18 | git clone -b "$1" https://gitlab.matrix.org/matrix-org/olm.git olm && cd olm 19 | 20 | # Build libolm 21 | cmake . -Bbuild 22 | cmake --build build 23 | 24 | # Install 25 | make install 26 | 27 | # Build the python3 bindings 28 | cd python && make olm-python3 29 | 30 | # Install python3 bindings 31 | mkdir -p "$2" || true 32 | DESTDIR="$2" make install-python3 33 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, then lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Lint 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v6 19 | 20 | - name: Set up Python 3.12 21 | uses: actions/setup-python@v6 22 | with: 23 | python-version: 3.12 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -U -e ".[dev]" 29 | 30 | - name: Check import statement sorting 31 | run: | 32 | isort -c --df matrix_reminder_bot matrix-reminder-bot 33 | 34 | - name: Python syntax errors, undefined names, etc. 35 | run: | 36 | flake8 . --count --show-source --statistics 37 | 38 | - name: PEP8 formatting 39 | run: | 40 | black --check --diff matrix_reminder_bot/ matrix-reminder-bot 41 | -------------------------------------------------------------------------------- /docker/start-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A script to quickly setup a running development environment for 3 | # matrix-reminder-bot 4 | # 5 | # It's primary purpose is to set up docker networking correctly so that 6 | # the bot can connect to remote services as well as those hosted on 7 | # the host machine. 8 | # 9 | 10 | # Change directory to where this script is located. We'd like to run 11 | # `docker-compose` in the same directory to use the adjacent 12 | # docker-compose.yml and .env files 13 | cd `dirname "$0"` 14 | 15 | function on_exit { 16 | cd - 17 | } 18 | 19 | # Ensure we change back to the old directory on script exit 20 | trap on_exit EXIT 21 | 22 | # To allow the docker container to connect to services running on the host, 23 | # we need to use the host's internal ip address. Attempt to retrieve this. 24 | # 25 | # Check whether the ip address has been defined in the environment already 26 | if [ -z "$HOST_IP_ADDRESS" ]; then 27 | # It's not defined. Try to guess what it is 28 | 29 | # First we try the `ip` command, available primarily on Linux 30 | export HOST_IP_ADDRESS="`ip route get 1 | sed -n 's/^.*src \([0-9.]*\) .*$/\1/p'`" 31 | 32 | if [ $? -ne 0 ]; then 33 | # That didn't work. `ip` isn't available on old Linux systems, or MacOS. 34 | # Try `ifconfig` instead 35 | 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]*'`" 36 | 37 | if [ $? -ne 0 ]; then 38 | # That didn't work either, give up 39 | echo " 40 | Unable to determine host machine's internal IP address. 41 | Please set HOST_IP_ADDRESS environment variable manually and re-run this script. 42 | If you do not have a need to connect to a homeserver running on the host machine, 43 | set HOST_IP_ADDRESS=127.0.0.1" 44 | exit 1 45 | fi 46 | fi 47 | fi 48 | 49 | # Build and run latest code 50 | docker-compose up --build local-checkout-dev 51 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | The following is a guide on the steps necessary to creating a release of `matrix-reminder-bot`. 4 | 5 | 1. Update the `__version__` variable in `matrix_reminder_bot/__init__.py`. 6 | 7 | 1. Store the version in an environment variable for convenience: 8 | 9 | ```sh 10 | ver=`python3 -c 'import matrix_reminder_bot; print(matrix_reminder_bot.__version__)'` 11 | ``` 12 | 13 | 1. Create a release branch off of master: 14 | 15 | ```sh 16 | git checkout -b release-v$ver 17 | ``` 18 | 19 | 1. Update `CHANGELOG.md` with the latest changes for this release. 20 | 21 | 1. Create a commit and push your changes to the release branch: 22 | 23 | ```sh 24 | git add -u && git commit -m $ver -n && git push -u origin $(git symbolic-ref --short HEAD) 25 | ``` 26 | 27 | 1. Check that the changelog is rendered correctly: 28 | 29 | ```sh 30 | xdg-open https://github.com/anoadragon453/matrix-reminder-bot/blob/release-v$ver/CHANGELOG.md 31 | ``` 32 | 33 | 1. Create a signed tag for the release: 34 | 35 | ```sh 36 | git tag -s v$ver 37 | ``` 38 | 39 | 1. Push the tag: 40 | 41 | ```sh 42 | git push origin tag v$ver 43 | ``` 44 | 45 | The commit message should just be the changelog entry, with `X.Y.Z` as the title. 46 | 47 | 1. Upload the release to PyPI: 48 | 49 | ```sh 50 | python3 setup.py sdist bdist_wheel 51 | python3 -m twine upload dist/matrix_reminder_bot-$ver-py3-none-any.whl dist/matrix-reminder-bot-$ver.tar.gz 52 | ``` 53 | 54 | 1. Check that the images on Docker Hub are building: https://hub.docker.com/repository/docker/anoa/matrix-reminder-bot 55 | 56 | 1. Create the release on GitHub: 57 | 58 | ```sh 59 | xdg-open https://github.com/anoadragon453/matrix-reminder-bot/releases/edit/v$ver 60 | ``` 61 | 62 | 1. Merge the release branch to `master`: 63 | 64 | ```sh 65 | git checkout master && git merge release-v$ver 66 | ``` 67 | 68 | 1. Push to `master`. 69 | 70 | ```sh 71 | git push origin master 72 | ``` 73 | 74 | 1. Announce release on https://matrix.to/#/#thisweekinmatrix:matrix.org 75 | 76 | 1. Celebrate! 77 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | 4 | from setuptools import find_packages, setup 5 | 6 | 7 | def exec_file(path_segments): 8 | """Execute a single python file to get the variables defined in it""" 9 | result = {} 10 | code = read_file(path_segments) 11 | exec(code, result) 12 | return result 13 | 14 | 15 | def read_file(path_segments): 16 | """Read a file from the package. Takes a list of strings to join to 17 | make the path""" 18 | file_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), *path_segments) 19 | with open(file_path) as f: 20 | return f.read() 21 | 22 | 23 | version = exec_file(("matrix_reminder_bot", "__init__.py"))["__version__"] 24 | long_description = read_file(("README.md",)) 25 | 26 | 27 | setup( 28 | name="matrix-reminder-bot", 29 | version=version, 30 | url="https://github.com/anoadragon453/matrix-reminder-bot", 31 | description="A matrix bot to remind you about things!", 32 | packages=find_packages(exclude=["tests", "tests.*"]), 33 | install_requires=[ 34 | "matrix-nio[e2e]>=0.24.0", 35 | "Markdown>=3.5.2", 36 | "PyYAML>=6.0.1", 37 | "dateparser>=1.2.0", 38 | "readabledelta>=0.0.2", 39 | "apscheduler>=3.10.4", 40 | "pytz>=2024.1", 41 | "arrow>=1.3.0", 42 | "pretty_cron>=1.2.0", 43 | ], 44 | extras_require={ 45 | "postgres": ["psycopg2>=2.9.9"], 46 | "dev": [ 47 | "isort==6.1.0", 48 | "flake8==7.3.0", 49 | "flake8-comprehensions==3.17.0", 50 | "black==25.12.0", 51 | ], 52 | }, 53 | classifiers=[ 54 | "License :: OSI Approved :: Apache Software License", 55 | "Programming Language :: Python :: 3 :: Only", 56 | "Programming Language :: Python :: 3.8", 57 | "Programming Language :: Python :: 3.9", 58 | "Programming Language :: Python :: 3.10", 59 | "Programming Language :: Python :: 3.11", 60 | "Programming Language :: Python :: 3.12", 61 | ], 62 | long_description=long_description, 63 | long_description_content_type="text/markdown", 64 | # Allow the user to run the bot with `matrix-reminder-bot ...` 65 | scripts=["matrix-reminder-bot"], 66 | ) 67 | -------------------------------------------------------------------------------- /docker/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # This dockerfile is crafted specifically for development purposes. 2 | # Please use `Dockerfile` instead if you wish to deploy for production. 3 | # 4 | # This file differs as it does not use a builder container, nor does it 5 | # reinstall the project's python package after copying the source code, 6 | # saving significant time during rebuilds. 7 | # 8 | # To build the image, run `docker build` command from the root of the 9 | # repository: 10 | # 11 | # docker build -f docker/Dockerfile . 12 | # 13 | # There is an optional PYTHON_VERSION build argument which sets the 14 | # version of python to build against. For example: 15 | # 16 | # docker build -f docker/Dockerfile --build-arg PYTHON_VERSION=3.9 . 17 | # 18 | # An optional LIBOLM_VERSION build argument which sets the 19 | # version of libolm to build against. For example: 20 | # 21 | # docker build -f docker/Dockerfile --build-arg LIBOLM_VERSION=3.2.15 . 22 | # 23 | 24 | ARG PYTHON_VERSION=3.12 25 | FROM docker.io/python:${PYTHON_VERSION}-alpine3.18 26 | 27 | ## 28 | ## Build libolm for matrix-nio e2e support 29 | ## 30 | 31 | # Install libolm build dependencies 32 | ARG LIBOLM_VERSION=3.2.16 33 | RUN apk add --no-cache \ 34 | make \ 35 | cmake \ 36 | gcc \ 37 | g++ \ 38 | git \ 39 | libffi-dev \ 40 | yaml-dev \ 41 | python3-dev 42 | 43 | # Build libolm 44 | COPY docker/build_and_install_libolm.sh /scripts/ 45 | RUN /scripts/build_and_install_libolm.sh ${LIBOLM_VERSION} 46 | 47 | # Install native runtime dependencies 48 | RUN apk add --no-cache \ 49 | musl-dev \ 50 | libpq \ 51 | postgresql-dev \ 52 | libstdc++ 53 | 54 | # Install python runtime modules. We do this before copying the source code 55 | # such that these dependencies can be cached 56 | RUN mkdir -p /src/matrix_reminder_bot 57 | COPY matrix_reminder_bot/__init__.py /src/matrix_reminder_bot/ 58 | COPY README.md matrix-reminder-bot /src/ 59 | COPY setup.py /src/setup.py 60 | RUN pip install -e "/src/.[postgres]" 61 | 62 | # Now copy the source code 63 | COPY matrix_reminder_bot/*.py /src/matrix_reminder_bot/ 64 | COPY *.py /src/ 65 | 66 | # Specify a volume that holds the config file, SQLite3 database, 67 | # and the matrix-nio store 68 | VOLUME ["/data"] 69 | 70 | # Start the app 71 | ENTRYPOINT ["matrix-reminder-bot", "/data/config.yaml"] 72 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to matrix-reminder-bot 2 | 3 | Thank you for taking interest in this little project. Below is some information 4 | to help you with contributing. 5 | 6 | ## Setting up your development environment 7 | 8 | ### Using `docker-compose` 9 | 10 | It is **recommended** to use Docker Compose to run matrix-reminder-bot while 11 | developing, as all necessary dependencies are handled for you. After 12 | installation and ensuring the `docker-compose` command works, you need to: 13 | 14 | 1. Create a data directory and config file by following the 15 | [docker setup instructions](docker#setup). 16 | 17 | 2. Create a docker volume pointing to that directory: 18 | 19 | ``` 20 | docker volume create \ 21 | --opt type=none \ 22 | --opt o=bind \ 23 | --opt device="/path/to/data/dir" data_volume 24 | ``` 25 | 26 | Run `docker/start-dev.sh` to start the bot. 27 | 28 | **Note:** If you are trying to connect to a Synapse instance running on the 29 | host, you need to allow the IP address of the docker container to connect. This 30 | is controlled by `bind_addresses` in the `listeners` section of Synapse's 31 | config. If present, either add the docker internal IP address to the list, or 32 | remove the option altogether to allow all addresses. 33 | 34 | ### Running natively 35 | 36 | If you would rather not or are unable to run docker, please follow the Native 37 | Installation, Configuration and Running sections in the 38 | [project readme](README.md#native-installation). 39 | 40 | ## Development dependencies 41 | 42 | There are some python dependencies that are required for linting/testing etc. 43 | You can install them with: 44 | 45 | ``` 46 | pip install -e ".[dev]" 47 | ``` 48 | 49 | ## Code style 50 | 51 | Please follow the [PEP8](https://www.python.org/dev/peps/pep-0008/) style 52 | guidelines and format your import statements with 53 | [isort](https://pypi.org/project/isort/). 54 | 55 | ## Linting 56 | 57 | Run the following script to automatically format your code. This *should* make 58 | the linting CI happy: 59 | 60 | ``` 61 | ./scripts-dev/lint.sh 62 | ``` 63 | 64 | ## What to work on 65 | 66 | Take a look at the [issues 67 | list](https://github.com/anoadragon453/matrix-reminder-bot/issues). What 68 | feature would you like to see or bug do you want to be fixed? 69 | 70 | If you would like to talk any ideas over before working on them, you can reach 71 | the maintainers at [`#matrix-reminder-bot:amorgan.xyz`](https://matrix.to/#/#matrix-reminder-bot:amorgan.xyz) 72 | on matrix. 73 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' # specify docker-compose version 2 | 3 | volumes: 4 | # Set up with `docker volume create ...`. See docker/README.md for more info. 5 | data_volume: 6 | external: true 7 | pg_data_volume: 8 | 9 | services: 10 | # Runs from the latest release 11 | matrix-reminder-bot: 12 | image: anoa/matrix-reminder-bot 13 | restart: always 14 | volumes: 15 | - data_volume:/data 16 | # Used for allowing connections to homeservers hosted on the host machine 17 | # (while docker host mode is still broken on Linux). 18 | # 19 | # Defaults to 127.0.0.1 and is set in docker/.env 20 | extra_hosts: 21 | - "localhost:${HOST_IP_ADDRESS}" 22 | 23 | # Runs from the latest release, but pulls the image from GitHub instead of Docker Hub 24 | matrix-reminder-bot-ghcr: 25 | image: ghcr.io/anoadragon453/matrix-reminder-bot 26 | restart: always 27 | volumes: 28 | - data_volume:/data 29 | # Used for allowing connections to homeservers hosted on the host machine 30 | # (while docker host mode is still broken on Linux). 31 | # 32 | # Defaults to 127.0.0.1 and is set in docker/.env 33 | extra_hosts: 34 | - "localhost:${HOST_IP_ADDRESS}" 35 | 36 | # Builds and runs an optimized container from local code 37 | local-checkout: 38 | build: 39 | context: .. 40 | dockerfile: docker/Dockerfile 41 | # Build arguments may be specified here 42 | # args: 43 | # PYTHON_VERSION: 3.12 44 | volumes: 45 | - data_volume:/data 46 | # Used for allowing connections to homeservers hosted on the host machine 47 | # (while docker host networking mode is still broken on Linux). 48 | # 49 | # Defaults to 127.0.0.1 and is set in docker/.env 50 | extra_hosts: 51 | - "localhost:${HOST_IP_ADDRESS}" 52 | 53 | # Builds and runs a development container from local code 54 | local-checkout-dev: 55 | build: 56 | context: .. 57 | dockerfile: docker/Dockerfile.dev 58 | # Build arguments may be specified here 59 | # args: 60 | # PYTHON_VERSION: 3.12 61 | volumes: 62 | - data_volume:/data 63 | # Used for allowing connections to homeservers hosted on the host machine 64 | # (while docker host networking mode is still broken on Linux). 65 | # 66 | # Defaults to 127.0.0.1 and is set in docker/.env 67 | extra_hosts: 68 | - "localhost:${HOST_IP_ADDRESS}" 69 | 70 | # Starts up a postgres database 71 | postgres: 72 | image: postgres 73 | restart: always 74 | volumes: 75 | - pg_data_volume:/var/lib/postgresql/data 76 | environment: 77 | POSTGRES_PASSWORD: matrixreminderbot 78 | -------------------------------------------------------------------------------- /sample.config.yaml: -------------------------------------------------------------------------------- 1 | # Welcome to the sample config file 2 | # Below you will find various config sections and options 3 | # Default values are shown 4 | 5 | # The string to prefix bot commands with 6 | command_prefix: "!" 7 | 8 | # Options for connecting to the bot's Matrix account 9 | matrix: 10 | # The Matrix User ID of the bot account 11 | user_id: "@bot:example.com" 12 | # Matrix account password 13 | user_password: "" 14 | # The public URL at which the homeserver's Client-Server API can be accessed 15 | homeserver_url: https://example.com 16 | # The device ID that is a **non pre-existing** device 17 | # If this device ID already exists, messages will be dropped silently in 18 | # encrypted rooms 19 | device_id: REMINDER 20 | # What to name the logged in device 21 | device_name: Reminder Bot 22 | 23 | storage: 24 | # The database connection string 25 | # For SQLite3, this would look like: 26 | # database: "sqlite://bot.db" 27 | # For Postgres, this would look like: 28 | # database: "postgres://username:password@localhost/dbname?sslmode=disable" 29 | database: "sqlite://bot.db" 30 | # The path to a directory for internal bot storage 31 | # containing encryption keys, sync tokens, etc. 32 | store_path: "./store" 33 | 34 | reminders: 35 | # Uncomment to set a default timezone that will be used when creating reminders. 36 | # If not set, UTC will be used 37 | #timezone: "Europe/London" 38 | 39 | # Restrict the bot to only respond to certain MXIDs 40 | allowlist: 41 | # Set to true to enable the allowlist 42 | enabled: false 43 | # A list of MXID regexes to be allowed 44 | # To allow a certain homeserver: 45 | # regexes: ["@[a-z0-9-_.]+:myhomeserver.tld"] 46 | # To allow a set of users: 47 | # regexes: ["@alice:someserver.tld", "@bob:anotherserver.tld"] 48 | # To allow nobody (same as blocking every MXID): 49 | # regexes: [] 50 | regexes: [] 51 | 52 | # Prevent the bot from responding to certain MXIDs 53 | # If both allowlist and blocklist are enabled, blocklist entries take precedence 54 | blocklist: 55 | # Set to true to enable the blocklist 56 | enabled: false 57 | # A list of MXID regexes to be blocked 58 | # To block a certain homeserver: 59 | # regexes: [".*:myhomeserver.tld"] 60 | # To block a set of users: 61 | # regexes: ["@alice:someserver.tld", "@bob:anotherserver.tld"] 62 | # To block absolutely everyone (same as allowing nobody): 63 | # regexes: [".*"] 64 | regexes: [] 65 | 66 | # Logging setup 67 | logging: 68 | # Logging level 69 | # Allowed levels are 'INFO', 'WARNING', 'ERROR', 'DEBUG' where DEBUG is most verbose 70 | level: INFO 71 | # Configure logging to a file 72 | file_logging: 73 | # Whether logging to a file is enabled 74 | enabled: false 75 | # The path to the file to log to. May be relative or absolute 76 | filepath: bot.log 77 | # Configure logging to the console (stdout/stderr) 78 | console_logging: 79 | # Whether console logging is enabled 80 | enabled: true 81 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # To build the image, run `docker build` command from the root of the 2 | # repository: 3 | # 4 | # docker build -f docker/Dockerfile . 5 | # 6 | # There is an optional PYTHON_VERSION build argument which sets the 7 | # version of python to build against. For example: 8 | # 9 | # docker build -f docker/Dockerfile --build-arg PYTHON_VERSION=3.9 . 10 | # 11 | # An optional LIBOLM_VERSION build argument which sets the 12 | # version of libolm to build against. For example: 13 | # 14 | # docker build -f docker/Dockerfile --build-arg LIBOLM_VERSION=3.2.15 . 15 | # 16 | 17 | 18 | ## 19 | ## Creating a builder container 20 | ## 21 | 22 | # We use an initial docker container to build all of the runtime dependencies, 23 | # then transfer those dependencies to the container we're going to ship, 24 | # before throwing this one away 25 | ARG PYTHON_VERSION=3.12 26 | FROM docker.io/python:${PYTHON_VERSION}-alpine3.18 as builder 27 | 28 | ## 29 | ## Build libolm for matrix-nio e2e support 30 | ## 31 | 32 | # Install libolm build dependencies 33 | ARG LIBOLM_VERSION=3.2.16 34 | RUN apk add --no-cache \ 35 | make \ 36 | cmake \ 37 | gcc \ 38 | g++ \ 39 | git \ 40 | libffi-dev \ 41 | yaml-dev \ 42 | python3-dev 43 | 44 | # Build libolm 45 | # 46 | # Also build the libolm python bindings and place them at /python-libs 47 | # We will later copy contents from both of these folders to the runtime 48 | # container 49 | COPY docker/build_and_install_libolm.sh /scripts/ 50 | RUN /scripts/build_and_install_libolm.sh ${LIBOLM_VERSION} /python-libs 51 | 52 | # Install Postgres dependencies 53 | RUN apk add --no-cache \ 54 | musl-dev \ 55 | libpq \ 56 | postgresql-dev 57 | 58 | # Install python runtime modules. We do this before copying the source code 59 | # such that these dependencies can be cached 60 | # This speeds up subsequent image builds when the source code is changed 61 | RUN mkdir -p /src/matrix_reminder_bot 62 | COPY matrix_reminder_bot/__init__.py /src/matrix_reminder_bot/ 63 | COPY README.md matrix-reminder-bot /src/ 64 | 65 | # Build the dependencies 66 | COPY setup.py /src/setup.py 67 | RUN pip install --prefix="/python-libs" --no-warn-script-location "/src/.[postgres]" 68 | 69 | # Now copy the source code 70 | COPY *.py *.md /src/ 71 | COPY matrix_reminder_bot/*.py /src/matrix_reminder_bot/ 72 | 73 | # And build the final module 74 | RUN pip install --prefix="/python-libs" --no-warn-script-location "/src/.[postgres]" 75 | 76 | ## 77 | ## Creating the runtime container 78 | ## 79 | 80 | # Create the container we'll actually ship. We need to copy libolm and any 81 | # python dependencies that we built above to this container 82 | FROM docker.io/python:${PYTHON_VERSION}-alpine3.18 83 | 84 | # Copy python dependencies from the "builder" container 85 | COPY --from=builder /python-libs /usr/local 86 | 87 | # Copy libolm from the "builder" container 88 | COPY --from=builder /usr/local/lib/libolm* /usr/local/lib/ 89 | 90 | # Install any native runtime dependencies 91 | RUN apk add --no-cache \ 92 | libstdc++ \ 93 | libpq \ 94 | postgresql-dev 95 | 96 | # Specify a volume that holds the config file, SQLite3 database, 97 | # and the matrix-nio store 98 | VOLUME ["/data"] 99 | 100 | # Start matrix-reminder-bot 101 | ENTRYPOINT ["matrix-reminder-bot", "/data/config.yaml"] 102 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - 'v[0-9]+.[0-9]+.[0-9]+' 9 | pull_request: 10 | 11 | env: 12 | GHCR_IMAGE: ghcr.io/anoadragon453/matrix-reminder-bot 13 | HUB_IMAGE: anoa/matrix-reminder-bot 14 | 15 | jobs: 16 | build-python-versions-matrix: 17 | strategy: 18 | matrix: 19 | version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 20 | arch: ['linux/amd64', 'linux/arm64'] 21 | fail-fast: false 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: read 25 | steps: 26 | - uses: actions/checkout@v6 27 | 28 | - name: Setup buildx 29 | uses: docker/setup-buildx-action@v3 30 | with: 31 | platforms: linux/amd64,linux/arm64 32 | 33 | - name: Docker build 34 | uses: docker/build-push-action@v6 35 | id: dockerBuild 36 | with: 37 | push: false 38 | context: . 39 | file: ./docker/Dockerfile 40 | platforms: ${{ matrix.arch }} 41 | build-args: | 42 | PYTHON_VERSION=${{ matrix.version }} 43 | 44 | build-push: 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: read 48 | packages: write 49 | outputs: 50 | docker-tag: ${{ steps.meta.outputs.version }} 51 | if: ${{ github.repository == 'anoadragon453/matrix-reminder-bot' }} 52 | steps: 53 | - uses: actions/checkout@v6 54 | 55 | - name: Generate Docker metadata 56 | id: meta 57 | uses: docker/metadata-action@v5 58 | with: 59 | images: | 60 | ${{ env.GHCR_IMAGE }} 61 | labels: | 62 | org.opencontainers.image.title=Matrix Reminder Bot 63 | org.opencontainers.image.description=A bot to remind you about stuff. Supports encrypted rooms. 64 | tags: | 65 | type=ref,event=tag,enable=true,priority=900 66 | type=raw,value=dev,enable={{is_default_branch}},priority=700 67 | type=ref,event=pr,enable=true,priority=600 68 | 69 | - name: Setup buildx 70 | uses: docker/setup-buildx-action@v3 71 | with: 72 | platforms: linux/amd64,linux/arm64 73 | 74 | - name: Login to ghcr.io 75 | uses: docker/login-action@v3 76 | with: 77 | registry: ghcr.io 78 | username: ${{ github.repository_owner }} 79 | password: ${{ secrets.GITHUB_TOKEN }} 80 | 81 | - name: Docker build and push 82 | uses: docker/build-push-action@v6 83 | id: dockerBuild 84 | with: 85 | push: true 86 | context: . 87 | file: ./docker/Dockerfile 88 | tags: ${{ steps.meta.outputs.tags }} 89 | labels: ${{ steps.meta.outputs.labels }} 90 | platforms: linux/amd64,linux/arm64 91 | 92 | mirror-dockerhub: 93 | runs-on: ubuntu-latest 94 | permissions: 95 | packages: read 96 | needs: [ build-push ] 97 | if: ${{ github.ref_type == 'tag' && github.repository == 'anoadragon453/matrix-reminder-bot' }} 98 | steps: 99 | - name: Generate Docker metadata 100 | id: meta 101 | uses: docker/metadata-action@v5 102 | with: 103 | images: | 104 | ${{ env.HUB_IMAGE }} 105 | tags: | 106 | type=ref,event=tag,enable=true,priority=900 107 | type=raw,value=dev,enable={{is_default_branch}},priority=700 108 | type=ref,event=pr,enable=true,priority=600 109 | 110 | - name: Login to Docker Hub 111 | uses: docker/login-action@v3 112 | with: 113 | username: ${{ secrets.DOCKERHUB_USERNAME }} 114 | password: ${{ secrets.DOCKERHUB_TOKEN }} 115 | 116 | - name: Docker pull-tag-push 117 | run: | 118 | docker pull ${{ env.GHCR_IMAGE }}:${{ needs.build-push.outputs.docker-tag }} 119 | IFS=$'\n' # bourne shell syntax splits string by space 120 | tags="${{ steps.meta.outputs.tags }}" 121 | for new_tag in $tags 122 | do 123 | docker tag ${{ env.GHCR_IMAGE }}:${{ needs.build-push.outputs.docker-tag }} $new_tag 124 | docker push $new_tag 125 | done 126 | -------------------------------------------------------------------------------- /matrix_reminder_bot/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import asyncio 3 | import logging 4 | import sys 5 | from time import sleep 6 | 7 | from aiohttp import ClientConnectionError, ServerDisconnectedError 8 | from apscheduler.schedulers import SchedulerAlreadyRunningError 9 | from nio import ( 10 | AsyncClient, 11 | AsyncClientConfig, 12 | InviteMemberEvent, 13 | LocalProtocolError, 14 | LoginError, 15 | MegolmEvent, 16 | RoomMessageText, 17 | ) 18 | 19 | from matrix_reminder_bot.callbacks import Callbacks 20 | from matrix_reminder_bot.config import CONFIG 21 | from matrix_reminder_bot.reminder import SCHEDULER 22 | from matrix_reminder_bot.storage import Storage 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | async def main(): 28 | # Read config file 29 | # A different config file path can be specified as the first command line arg 30 | if len(sys.argv) > 1: 31 | config_filepath = sys.argv[1] 32 | else: 33 | config_filepath = "config.yaml" 34 | CONFIG.read_config(config_filepath) 35 | 36 | # Configure the python job scheduler 37 | SCHEDULER.configure({"apscheduler.timezone": CONFIG.timezone}) 38 | 39 | # Configuration options for the AsyncClient 40 | client_config = AsyncClientConfig( 41 | max_limit_exceeded=0, 42 | max_timeouts=0, 43 | store_sync_tokens=True, 44 | encryption_enabled=True, 45 | ) 46 | 47 | # Initialize the matrix client 48 | client = AsyncClient( 49 | CONFIG.homeserver_url, 50 | CONFIG.user_id, 51 | device_id=CONFIG.device_id, 52 | store_path=CONFIG.store_path, 53 | config=client_config, 54 | ) 55 | 56 | # Configure the database 57 | store = Storage(client) 58 | 59 | # Set up event callbacks 60 | callbacks = Callbacks(client, store) 61 | client.add_event_callback(callbacks.message, (RoomMessageText,)) 62 | client.add_event_callback(callbacks.invite, (InviteMemberEvent,)) 63 | client.add_event_callback(callbacks.decryption_failure, (MegolmEvent,)) 64 | 65 | # Keep trying to reconnect on failure (with some time in-between) 66 | while True: 67 | try: 68 | # Try to log in with the configured username/password 69 | try: 70 | login_response = await client.login( 71 | password=CONFIG.user_password, 72 | device_name=CONFIG.device_name, 73 | ) 74 | 75 | # Check if login failed. Usually incorrect password 76 | if type(login_response) is LoginError: 77 | logger.error("Failed to login: %s", login_response.message) 78 | logger.warning("Trying again in 15s...") 79 | 80 | # Sleep so we don't bombard the server with login requests 81 | sleep(15) 82 | continue 83 | except LocalProtocolError as e: 84 | # There's an edge case here where the user hasn't installed the correct C 85 | # dependencies. In that case, a LocalProtocolError is raised on login. 86 | logger.fatal( 87 | "Failed to login. Have you installed the correct dependencies? " 88 | "https://github.com/poljar/matrix-nio#installation " 89 | "Error: %s", 90 | e, 91 | ) 92 | return False 93 | 94 | # Login succeeded! 95 | 96 | logger.info(f"Logged in as {CONFIG.user_id}") 97 | logger.info("Startup complete") 98 | 99 | # Allow jobs to fire 100 | try: 101 | SCHEDULER.start() 102 | except SchedulerAlreadyRunningError: 103 | pass 104 | 105 | await client.sync_forever(timeout=30000, full_state=True) 106 | 107 | except (ClientConnectionError, ServerDisconnectedError, TimeoutError): 108 | logger.warning("Unable to connect to homeserver, retrying in 15s...") 109 | 110 | # Sleep so we don't bombard the server with login requests 111 | sleep(15) 112 | except Exception: 113 | logger.exception("Unknown exception occurred:") 114 | logger.warning("Restarting in 15s...") 115 | 116 | # Sleep so we don't bombard the server with login requests 117 | sleep(15) 118 | finally: 119 | # Make sure to close the client connection on disconnect 120 | await client.close() 121 | 122 | 123 | if __name__ == "__main__": 124 | asyncio.get_event_loop().run_until_complete(main()) 125 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | The docker image will run matrix-reminder-bot with a SQLite database and 4 | end-to-end encryption dependencies included. For larger deployments, a 5 | connection to a Postgres database backend is recommended. 6 | 7 | A pre-built image for amd64 and arm64 platforms is provided by our CI on both 8 | - https://hub.docker.com/r/anoa/matrix-reminder-bot 9 | - https://github.com/anoadragon453/matrix-reminder-bot/pkgs/container/matrix-reminder-bot. 10 | 11 | You can always also build the image yourself, see below for instructions. 12 | 13 | ## Setup 14 | 15 | ### The `/data` volume 16 | 17 | The docker container expects the `config.yaml` file to exist at 18 | `/data/config.yaml`. To easily configure this, it is recommended to create a 19 | directory on your filesystem, and mount it as `/data` inside the container: 20 | 21 | ``` 22 | mkdir data 23 | ``` 24 | 25 | We'll later mount this directory into the container so that its contents 26 | persist across container restarts. 27 | 28 | ### Creating a config file 29 | 30 | Copy `sample.config.yaml` to a file named `config.yaml` inside of your newly 31 | created `data` directory. Fill it out as you normally would, with a few minor 32 | differences: 33 | 34 | * The bot store directory should reside inside of the data directory so that it 35 | is not wiped on container restart. Change it from the default to `/data/store`. 36 | There is no need to create this directory yourself, matrix-reminder-bot will 37 | create it on startup if it does not exist. 38 | 39 | * Choose whether you want to use SQLite or Postgres as your database backend. If 40 | using SQLite, ensure your database file is stored inside the `/data` directory: 41 | 42 | ``` 43 | database: "sqlite:///data/bot.db" 44 | ``` 45 | 46 | If using postgres, point to your postgres instance instead: 47 | 48 | ``` 49 | database: "postgres://username:password@postgres/matrix-reminder-bot?sslmode=disable" 50 | ``` 51 | 52 | **Note:** a postgres container is defined in `docker-compose.yaml` for your convenience. 53 | If you would like to use it, set your database connection string to: 54 | 55 | ``` 56 | database: "postgres://postgres:matrixreminderbot@postgres/postgres?sslmode=disable" 57 | ``` 58 | 59 | Change any other config values as necessary. For instance, you may also want to 60 | store log files in the `/data` directory. 61 | 62 | ## Running 63 | 64 | First, create a volume for the data directory created in the above section: 65 | 66 | ``` 67 | docker volume create \ 68 | --opt type=none \ 69 | --opt o=bind \ 70 | --opt device="/path/to/data/dir" data_volume 71 | ``` 72 | 73 | If you want to use the postgres container defined in `docker-compose.yaml`, start that 74 | first: 75 | 76 | ``` 77 | docker-compose up -d postgres 78 | ``` 79 | 80 | Start the bot with: 81 | 82 | ``` 83 | docker-compose up matrix-reminder-bot 84 | ``` 85 | 86 | This will run the bot and log the output to the terminal. You can instead run 87 | the container detached with the `-d` flag: 88 | 89 | ``` 90 | docker-compose up -d matrix-reminder-bot 91 | ``` 92 | 93 | (Logs can later be accessed with the `docker logs` command). 94 | 95 | This will use the `matrix-reminder-bot:latest` tag from 96 | [Docker Hub](https://hub.docker.com/r/anoa/matrix-reminder-bot). 97 | 98 | If you would rather pull the image from GHCR instead, you can use: 99 | 100 | ``` 101 | docker-compose up -d matrix-reminder-bot-ghcr 102 | ``` 103 | 104 | If you would rather run from the checked out code, you can use: 105 | 106 | ``` 107 | docker-compose up local-checkout 108 | ``` 109 | 110 | This will build an optimized, production-ready container. If you are developing 111 | on matrix-reminder-bot and would like a development container for testing local 112 | changes, use the `start-dev.sh` script and consult [CONTRIBUTING.md](../CONTRIBUTING.md). 113 | 114 | **Note:** If you are trying to connect to a Synapse instance running on the 115 | host, you need to allow the IP address of the docker container to connect. This 116 | is controlled by `bind_addresses` in the `listeners` section of Synapse's 117 | config. If present, either add the docker internal IP address to the list, or 118 | remove the option altogether to allow all addresses. 119 | 120 | ## Updating 121 | 122 | To update the container, navigate to the bot's `docker` directory and run: 123 | 124 | ``` 125 | docker-compose pull matrix-reminder-bot 126 | ``` 127 | 128 | Then restart the bot. 129 | 130 | ## Systemd 131 | 132 | A systemd service file is provided for your convenience at 133 | [matrix-reminder-bot.service](matrix-reminder-bot.service). The service uses 134 | `docker-compose` to start and stop the bot. 135 | 136 | Copy the file to `/etc/systemd/system/matrix-reminder-bot.service` and edit to 137 | match your setup. You can then start the bot with: 138 | 139 | ``` 140 | systemctl start matrix-reminder-bot 141 | ``` 142 | 143 | and stop it with: 144 | 145 | ``` 146 | systemctl stop matrix-reminder-bot 147 | ``` 148 | 149 | To run the bot on system startup: 150 | 151 | ``` 152 | systemctl enable matrix-reminder-bot 153 | ``` 154 | 155 | ## Building the image 156 | 157 | To build the image from source, use the following `docker build` command from 158 | the repo's root: 159 | 160 | ``` 161 | docker build -t anoa/matrix-reminder-bot:latest -f docker/Dockerfile . 162 | ``` 163 | -------------------------------------------------------------------------------- /matrix_reminder_bot/functions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Callable, Optional 3 | 4 | from markdown import markdown 5 | from nio import AsyncClient, SendRetryError 6 | 7 | from matrix_reminder_bot.config import CONFIG 8 | from matrix_reminder_bot.errors import CommandSyntaxError 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | async def send_text_to_room( 14 | client: AsyncClient, 15 | room_id: str, 16 | message: str, 17 | notice: bool = True, 18 | markdown_convert: bool = True, 19 | reply_to_event_id: Optional[str] = None, 20 | mentions_room: bool = False, 21 | mentions_user_ids: Optional[list[str]] = None, 22 | ): 23 | """Send text to a matrix room. 24 | 25 | Args: 26 | client: The client to communicate to matrix with. 27 | 28 | room_id: The ID of the room to send the message to. 29 | 30 | message: The message content. 31 | 32 | notice: Whether the message should be sent with an "m.notice" message type 33 | (will not ping users). 34 | 35 | markdown_convert: Whether to convert the message content to markdown. 36 | Defaults to true. 37 | 38 | reply_to_event_id: Whether this message is a reply to another event. The event 39 | ID this is message is a reply to. 40 | 41 | mentions_room: Whether or not this message mentions the whole room. 42 | Defaults to false. 43 | 44 | mentions_user_ids: An optional list of MXIDs this message mentions. 45 | """ 46 | 47 | # Determine whether to ping room members or not 48 | msgtype = "m.notice" if notice else "m.text" 49 | 50 | content = { 51 | "msgtype": msgtype, 52 | "format": "org.matrix.custom.html", 53 | "body": message, 54 | "m.mentions": {}, 55 | } 56 | 57 | if markdown_convert: 58 | content["formatted_body"] = markdown(message) 59 | 60 | if reply_to_event_id: 61 | content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}} 62 | 63 | if mentions_room: 64 | content["m.mentions"]["room"] = True 65 | 66 | if mentions_user_ids is not None: 67 | content["m.mentions"]["user_ids"] = mentions_user_ids 68 | 69 | try: 70 | await client.room_send( 71 | room_id, 72 | "m.room.message", 73 | content, 74 | ignore_unverified_devices=True, 75 | ) 76 | except SendRetryError: 77 | logger.exception(f"Unable to send message response to {room_id}") 78 | 79 | 80 | def command_syntax(syntax: str): 81 | """Defines the syntax for a function, and informs the user if it is violated 82 | 83 | This function is intended to be used as a decorator, allowing command-handler 84 | functions to define the syntax that the user is supposed to use for the 85 | command arguments. 86 | 87 | The command function, passed to `outer`, can signal that this syntax has been 88 | violated by raising a CommandSyntaxError exception. This will then catch that 89 | exception and inform the user of the correct syntax for that command. 90 | 91 | Args: 92 | syntax: The syntax for the command that the user should follow 93 | """ 94 | 95 | def outer(command_func: Callable): 96 | async def inner(self, *args, **kwargs): 97 | try: 98 | # Attempt to execute the command function 99 | await command_func(self, *args, **kwargs) 100 | except CommandSyntaxError: 101 | # The function indicated that there was a command syntax error 102 | # Inform the user of the correct syntax 103 | # 104 | # Grab the bot's configured command prefix, and the current 105 | # command's name from the `self` object passed to the command 106 | text = ( 107 | f"Invalid syntax. Please use " 108 | f"`{CONFIG.command_prefix}{self.command} {syntax}`." 109 | ) 110 | await send_text_to_room(self.client, self.room.room_id, text) 111 | 112 | return inner 113 | 114 | return outer 115 | 116 | 117 | def make_pill(user_id: str, displayname: str = None) -> str: 118 | """Convert a user ID (and optionally a display name) to a formatted user 'pill' 119 | 120 | Args: 121 | user_id: The MXID of the user. 122 | displayname: An optional displayname. Clients like Element will figure out the 123 | correct display name no matter what, but other clients may not. 124 | 125 | Returns: 126 | The formatted user pill. 127 | """ 128 | if not displayname: 129 | # Use the user ID as the displayname if not provided 130 | displayname = user_id 131 | 132 | return f'{displayname}' 133 | 134 | 135 | def is_allowed_user(user_id: str) -> bool: 136 | """Returns if the bot is allowed to interact with the given user 137 | 138 | Args: 139 | user_id: The MXID of the user. 140 | 141 | Returns: 142 | True, if the bot is allowed to interact with the given user. 143 | """ 144 | allowed = not CONFIG.allowlist_enabled 145 | 146 | if CONFIG.allowlist_enabled: 147 | for regex in CONFIG.allowlist_regexes: 148 | if regex.fullmatch(user_id): 149 | allowed = True 150 | break 151 | 152 | if CONFIG.blocklist_enabled: 153 | for regex in CONFIG.blocklist_regexes: 154 | if regex.fullmatch(user_id): 155 | allowed = False 156 | break 157 | 158 | return allowed 159 | -------------------------------------------------------------------------------- /matrix_reminder_bot/callbacks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import List 4 | 5 | from nio import ( 6 | AsyncClient, 7 | InviteMemberEvent, 8 | JoinError, 9 | MatrixRoom, 10 | MegolmEvent, 11 | RoomMessageText, 12 | ) 13 | 14 | from matrix_reminder_bot.bot_commands import Command 15 | from matrix_reminder_bot.config import CONFIG 16 | from matrix_reminder_bot.errors import CommandError 17 | from matrix_reminder_bot.functions import is_allowed_user, send_text_to_room 18 | from matrix_reminder_bot.storage import Storage 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class Callbacks(object): 24 | """Callback methods that fire on certain matrix events 25 | 26 | Args: 27 | client: nio client used to interact with matrix 28 | store: Bot storage 29 | """ 30 | 31 | def __init__(self, client: AsyncClient, store: Storage): 32 | self.client = client 33 | self.store = store 34 | 35 | @staticmethod 36 | def str_strip(s: str, phrases: List[str]) -> str: 37 | """ 38 | Strip instances of a string in leading and trailing positions around another string. 39 | Like str.rstrip but with strings instead of individual characters. 40 | Also runs str.strip on s. 41 | 42 | Args: 43 | s: The string to strip. 44 | phrases: A list of strings to strip from s. 45 | """ 46 | # Strip the string of whitespace 47 | s = s.strip() 48 | 49 | for phrase in phrases: 50 | # Use a regex to strip leading strings from another string 51 | # 52 | # We use re.S to treat the input text as one line (aka not strip leading 53 | # phrases from every line of the message. 54 | match = re.match(f"({phrase})*(.*)", s, flags=re.S) 55 | 56 | # Extract the text between the parentheses in the pattern above 57 | # Note that the above pattern is guaranteed to find a match, even with an empty str 58 | s = match.group(2) 59 | 60 | # Now attempt to strip trailing strings. 61 | match = re.match(f"(.*)({phrase})$", s, flags=re.S) 62 | if match: 63 | s = match.group(1) 64 | 65 | # After attempting to strip leading and trailing phrases from the string, return it 66 | return s 67 | 68 | async def message(self, room: MatrixRoom, event: RoomMessageText): 69 | """Callback for when a message event is received""" 70 | # Ignore messages from ourselves 71 | if event.sender == self.client.user: 72 | return 73 | 74 | # Ignore messages from the past 75 | join_time = 0 76 | state = await self.client.room_get_state(room.room_id) 77 | for membership in state.events: 78 | if ( 79 | membership.get("type") == "m.room.member" 80 | and membership.get("state_key") == self.client.user_id 81 | ): 82 | join_time = membership.get("origin_server_ts", 0) 83 | if join_time > event.server_timestamp: 84 | return 85 | 86 | # Ignore messages from disallowed users 87 | if not is_allowed_user(event.sender): 88 | logger.debug( 89 | f"Ignoring event {event.event_id} in room {room.room_id} as the sender {event.sender} is not allowed." 90 | ) 91 | return 92 | 93 | # Ignore broken events 94 | if not event.body: 95 | return 96 | 97 | # We do some stripping just to remove any surrounding formatting 98 | formatting_chars = ["

", "\\n", "

"] 99 | body = self.str_strip(event.body, formatting_chars) 100 | formatted_body = ( 101 | self.str_strip(event.formatted_body, formatting_chars) 102 | if event.formatted_body 103 | else None 104 | ) 105 | 106 | # Use the formatted message text, or the basic text if no formatting is available 107 | msg = formatted_body or body 108 | if not msg: 109 | logger.info("No msg!") 110 | return 111 | 112 | # Check whether this is a command 113 | # 114 | # We use event.body here as formatted bodies can start with

instead of the 115 | # command prefix 116 | if not body.startswith(CONFIG.command_prefix): 117 | return 118 | 119 | logger.debug("Command received: %s", msg) 120 | 121 | # Assume this is a command and attempt to process 122 | command = Command(self.client, self.store, msg, room, event) 123 | 124 | try: 125 | await command.process() 126 | except CommandError as e: 127 | # An expected error occurred. Inform the user 128 | msg = f"Error: {e.msg}" 129 | await send_text_to_room(self.client, room.room_id, msg) 130 | 131 | # Print traceback 132 | logger.exception("CommandError while processing command:") 133 | except Exception as e: 134 | # An unknown error occurred. Inform the user 135 | msg = f"An unknown error occurred: {e}" 136 | await send_text_to_room(self.client, room.room_id, msg) 137 | 138 | # Print traceback 139 | logger.exception("Unknown error while processing command:") 140 | 141 | async def invite(self, room: MatrixRoom, event: InviteMemberEvent): 142 | """Callback for when an invite is received. Join the room specified in the invite""" 143 | logger.debug(f"Got invite to {room.room_id} from {event.sender}.") 144 | 145 | # Don't respond to invites from disallowed users 146 | if not is_allowed_user(event.sender): 147 | logger.debug(f"{event.sender} is not allowed, not responding to invite.") 148 | return 149 | 150 | # Attempt to join 3 times before giving up 151 | for attempt in range(3): 152 | result = await self.client.join(room.room_id) 153 | if type(result) is JoinError: 154 | logger.error( 155 | f"Error joining room {room.room_id} (attempt %d): %s", 156 | attempt, 157 | result.message, 158 | ) 159 | else: 160 | logger.info(f"Joined {room.room_id}") 161 | break 162 | 163 | async def decryption_failure(self, room: MatrixRoom, event: MegolmEvent): 164 | """Callback for when an event fails to decrypt. Inform the user""" 165 | logger.error( 166 | f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'!" 167 | f"\n\n" 168 | f"Tip: try using a different device ID in your config file and restart." 169 | f"\n\n" 170 | f"If all else fails, delete your store directory and let the bot recreate " 171 | f"it (your reminders will NOT be deleted, but the bot may respond to existing " 172 | f"commands a second time)." 173 | ) 174 | 175 | user_msg = ( 176 | "Unable to decrypt this message. " 177 | "Check whether you've chosen to only encrypt to trusted devices." 178 | ) 179 | 180 | await send_text_to_room( 181 | self.client, 182 | room.room_id, 183 | user_msg, 184 | reply_to_event_id=event.event_id, 185 | ) 186 | -------------------------------------------------------------------------------- /matrix_reminder_bot/reminder.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta 3 | from typing import Dict, Optional, Tuple 4 | 5 | import pytz 6 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 7 | from apscheduler.triggers.cron import CronTrigger 8 | from apscheduler.triggers.date import DateTrigger 9 | from apscheduler.triggers.interval import IntervalTrigger 10 | from apscheduler.util import timedelta_seconds 11 | from nio import AsyncClient 12 | 13 | from matrix_reminder_bot.config import CONFIG 14 | from matrix_reminder_bot.functions import make_pill, send_text_to_room 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | # The object that runs callbacks at a certain time 19 | SCHEDULER = AsyncIOScheduler() 20 | 21 | # How often an alarm should sound after the reminder it's attached to 22 | ALARM_TIMEDELTA = timedelta(minutes=5) 23 | 24 | 25 | class Reminder(object): 26 | """An object containing information about a reminder, when it should go off, 27 | whether it is recurring, etc. 28 | 29 | Args: 30 | client: The matrix client 31 | store: A Storage object 32 | room_id: The ID of the room the reminder should appear in 33 | start_time: When the reminder should first go off 34 | timezone: The database name of the timezone this reminder should act within 35 | reminder_text: The text to include in the reminder message 36 | recurse_timedelta: Optional. How often to repeat the reminder 37 | target_user: Optional. A user ID of a specific user to mention in the room while 38 | reminding 39 | alarm: Whether this reminder is an alarm. Alarms are reminders that fire every 5m 40 | after they go off normally, until they are silenced. 41 | """ 42 | 43 | def __init__( 44 | self, 45 | client: AsyncClient, 46 | store, 47 | room_id: str, 48 | reminder_text: str, 49 | start_time: Optional[datetime] = None, 50 | timezone: Optional[str] = None, 51 | recurse_timedelta: Optional[timedelta] = None, 52 | cron_tab: Optional[str] = None, 53 | target_user: Optional[str] = None, 54 | alarm: bool = False, 55 | ): 56 | self.client = client 57 | self.store = store 58 | self.room_id = room_id 59 | self.timezone = timezone 60 | self.start_time = start_time 61 | self.reminder_text = reminder_text 62 | self.cron_tab = cron_tab 63 | self.recurse_timedelta = recurse_timedelta 64 | self.target_user = target_user 65 | self.alarm = alarm 66 | 67 | # Schedule the reminder 68 | 69 | # Determine how the reminder is triggered 70 | if cron_tab: 71 | # Set up a cron trigger 72 | trigger = CronTrigger.from_crontab(cron_tab, timezone=timezone) 73 | elif recurse_timedelta: 74 | # Use an interval trigger (runs multiple times) 75 | 76 | # If the start_time of this reminder was in daylight savings for this timezone, 77 | # and we are no longer in daylight savings, alter the start_time by the 78 | # appropriate offset. 79 | # TODO: Ideally this would be done dynamically instead of on reminder construction 80 | tz = pytz.timezone(timezone) 81 | start_time = tz.localize(start_time) 82 | now = tz.localize(datetime.now()) 83 | if start_time.dst() != now.dst(): 84 | start_time += start_time.dst() 85 | 86 | trigger = IntervalTrigger( 87 | # timedelta.seconds does NOT give you the timedelta converted to seconds 88 | # Use a method from apscheduler instead 89 | seconds=int(timedelta_seconds(recurse_timedelta)), 90 | start_date=start_time, 91 | ) 92 | else: 93 | # Use a date trigger (runs only once) 94 | trigger = DateTrigger(run_date=start_time, timezone=timezone) 95 | 96 | # Note down the job for later manipulation 97 | self.job = SCHEDULER.add_job(self._fire, trigger=trigger) 98 | 99 | self.alarm_job = None 100 | 101 | async def _fire(self): 102 | """Called when a reminder fires""" 103 | logger.debug("Reminder in room %s fired: %s", self.room_id, self.reminder_text) 104 | 105 | # Build the reminder message 106 | target = make_pill(self.target_user) if self.has_target() else "@room" 107 | message = f"{target} {self.reminder_text}" 108 | 109 | # If this reminder has an alarm attached... 110 | if self.alarm: 111 | # Inform the user that an alarm will go off 112 | message += ( 113 | f"\n\n(This reminder has an alarm. You will be reminded again in 5m. " 114 | f"Use the `{CONFIG.command_prefix}silence` command to stop)." 115 | ) 116 | 117 | # Check that an alarm is not already ongoing from a previous run 118 | if not (self.room_id, self.reminder_text.upper()) in ALARMS: 119 | # Start alarming 120 | self.alarm_job = SCHEDULER.add_job( 121 | self._fire_alarm, 122 | trigger=IntervalTrigger( 123 | # timedelta.seconds does NOT give you the timedelta converted to 124 | # seconds. Use a method from apscheduler instead 125 | seconds=int(timedelta_seconds(ALARM_TIMEDELTA)), 126 | ), 127 | ) 128 | ALARMS[(self.room_id, self.reminder_text.upper())] = self 129 | 130 | # Send the message to the room 131 | await send_text_to_room( 132 | self.client, 133 | self.room_id, 134 | message, 135 | notice=False, 136 | mentions_room=not self.has_target(), 137 | mentions_user_ids=[self.target_user] if self.has_target() else None, 138 | ) 139 | 140 | # If this was a one-time reminder, cancel and remove from the reminders dict 141 | if not self.recurse_timedelta and not self.cron_tab: 142 | # We set cancel_alarm to False here else the associated alarms wouldn't even 143 | # fire 144 | self.cancel(cancel_alarm=False) 145 | 146 | async def _fire_alarm(self): 147 | logger.debug("Alarm in room %s fired: %s", self.room_id, self.reminder_text) 148 | 149 | # Build the alarm message 150 | target = make_pill(self.target_user) if self.has_target() else "@room" 151 | message = ( 152 | f"Alarm: {target} {self.reminder_text} " 153 | f"(Use `{CONFIG.command_prefix}silence [reminder text]` to silence)." 154 | ) 155 | 156 | # Send the message to the room 157 | await send_text_to_room( 158 | self.client, 159 | self.room_id, 160 | message, 161 | notice=False, 162 | mentions_user_ids=[self.target_user] if self.has_target() else None, 163 | mentions_room=not self.has_target(), 164 | ) 165 | 166 | def cancel(self, cancel_alarm: bool = True): 167 | """Cancels a reminder and all recurring instances 168 | 169 | Args: 170 | cancel_alarm: Whether to also cancel alarms of this reminder 171 | """ 172 | logger.debug( 173 | "Cancelling reminder in room %s: %s", self.room_id, self.reminder_text 174 | ) 175 | 176 | # Remove from the in-memory reminder and alarm dicts 177 | REMINDERS.pop((self.room_id, self.reminder_text.upper()), None) 178 | 179 | # Delete the reminder from the database 180 | self.store.delete_reminder(self.room_id, self.reminder_text) 181 | 182 | # Delete any ongoing jobs 183 | if self.job and SCHEDULER.get_job(self.job.id): 184 | self.job.remove() 185 | 186 | # Cancel alarms of this reminder if required 187 | if cancel_alarm: 188 | ALARMS.pop((self.room_id, self.reminder_text.upper()), None) 189 | 190 | if self.alarm_job and SCHEDULER.get_job(self.alarm_job.id): 191 | self.alarm_job.remove() 192 | 193 | def has_target(self) -> bool: 194 | """Returns whether the reminder has a target user.""" 195 | return self.target_user is not None 196 | 197 | 198 | # Global dictionaries 199 | # 200 | # Both feature (room_id, reminder_text) tuples as keys 201 | # 202 | # reminder_text should be accessed and stored as uppercase in order to 203 | # allow for case-insensitive matching when carrying out user actions 204 | REMINDERS: Dict[Tuple[str, str], Reminder] = {} 205 | ALARMS: Dict[Tuple[str, str], Reminder] = {} 206 | -------------------------------------------------------------------------------- /matrix_reminder_bot/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import sys 5 | from typing import Any, List 6 | 7 | import yaml 8 | 9 | from matrix_reminder_bot.errors import ConfigError 10 | 11 | logger = logging.getLogger() 12 | logging.getLogger("peewee").setLevel( 13 | logging.INFO 14 | ) # Prevent debug messages from peewee lib 15 | 16 | 17 | class DatabaseConfig: 18 | def __init__(self): 19 | # The type of database. Supported types are 'sqlite' and 'postgres' 20 | self.type: str = "" 21 | self.connection_string: str = "" 22 | 23 | 24 | class Config: 25 | def __init__(self): 26 | """ 27 | Args: 28 | filepath: Path to the config file 29 | """ 30 | # TODO: Add some comments for each of these 31 | # TODO: Also ensure that this commit diff is sane. Did I replace config everywhere? 32 | self.database: DatabaseConfig = DatabaseConfig() 33 | self.store_path: str = "" 34 | 35 | self.user_id: str = "" 36 | self.user_password: str = "" 37 | self.device_id: str = "" 38 | self.device_name: str = "" 39 | self.homeserver_url: str = "" 40 | 41 | self.command_prefix: str = "" 42 | 43 | self.timezone: str = "" 44 | 45 | self.allowlist_enabled: bool = False 46 | self.allowlist_regexes: list[re.Pattern] = [] 47 | 48 | self.blocklist_enabled: bool = False 49 | self.blocklist_regexes: list[re.Pattern] = [] 50 | 51 | def read_config(self, filepath: str): 52 | if not os.path.isfile(filepath): 53 | raise ConfigError(f"Config file '{filepath}' does not exist") 54 | 55 | # Load in the config file at the given filepath 56 | with open(filepath) as file_stream: 57 | self.config = yaml.safe_load(file_stream.read()) 58 | 59 | # Logging setup 60 | formatter = logging.Formatter( 61 | "%(asctime)s | %(name)s [%(levelname)s] %(message)s" 62 | ) 63 | 64 | log_level = self._get_cfg(["logging", "level"], default="INFO") 65 | logger.setLevel(log_level) 66 | 67 | file_logging_enabled = self._get_cfg( 68 | ["logging", "file_logging", "enabled"], default=False 69 | ) 70 | file_logging_filepath = self._get_cfg( 71 | ["logging", "file_logging", "filepath"], default="bot.log" 72 | ) 73 | if file_logging_enabled: 74 | handler = logging.FileHandler(file_logging_filepath) 75 | handler.setFormatter(formatter) 76 | logger.addHandler(handler) 77 | 78 | console_logging_enabled = self._get_cfg( 79 | ["logging", "console_logging", "enabled"], default=True 80 | ) 81 | if console_logging_enabled: 82 | handler = logging.StreamHandler(sys.stdout) 83 | handler.setFormatter(formatter) 84 | logger.addHandler(handler) 85 | 86 | # Storage setup 87 | database_path = self._get_cfg(["storage", "database"], required=True) 88 | 89 | # We support both SQLite and Postgres backends 90 | # Determine which one the user intends 91 | sqlite_scheme = "sqlite://" 92 | postgres_scheme = "postgres://" 93 | if database_path.startswith(sqlite_scheme): 94 | self.database.type = "sqlite" 95 | self.database.connection_string = database_path[len(sqlite_scheme) :] 96 | elif database_path.startswith(postgres_scheme): 97 | self.database.type = "postgres" 98 | self.database.connection_string = database_path 99 | else: 100 | raise ConfigError("Invalid connection string for storage.database") 101 | 102 | self.store_path = self._get_cfg(["storage", "store_path"], default="store") 103 | 104 | # Create the store folder if it doesn't exist 105 | if not os.path.isdir(self.store_path): 106 | if not os.path.exists(self.store_path): 107 | os.mkdir(self.store_path) 108 | else: 109 | raise ConfigError( 110 | f"storage.store_path '{self.store_path}' is not a directory" 111 | ) 112 | 113 | # Matrix bot account setup 114 | user_id = self._get_cfg(["matrix", "user_id"], required=True) 115 | if not re.match("@.*:.*", user_id): 116 | raise ConfigError("matrix.user_id must be in the form @name:domain") 117 | self.user_id = user_id 118 | 119 | user_password = self._get_cfg(["matrix", "user_password"], required=True) 120 | if len(user_password) <= 0: 121 | raise ConfigError("please supply a password to log in!") 122 | self.user_password = user_password 123 | self.device_id = self._get_cfg(["matrix", "device_id"], required=True) 124 | self.device_name = self._get_cfg( 125 | ["matrix", "device_name"], default="nio-template" 126 | ) 127 | self.homeserver_url = self._get_cfg(["matrix", "homeserver_url"], required=True) 128 | 129 | self.command_prefix = self._get_cfg(["command_prefix"], default="!") 130 | 131 | # Reminder configuration 132 | self.timezone = self._get_cfg(["reminders", "timezone"], default="Etc/UTC") 133 | 134 | # Allowlist configuration 135 | allowlist_enabled = self._get_cfg(["allowlist", "enabled"], required=True) 136 | if not isinstance(allowlist_enabled, bool): 137 | raise ConfigError("allowlist.enabled must be a boolean value") 138 | self.allowlist_enabled = allowlist_enabled 139 | 140 | self.allowlist_regexes = self._compile_regexes( 141 | ["allowlist", "regexes"], required=True 142 | ) 143 | 144 | # Blocklist configuration 145 | blocklist_enabled = self._get_cfg(["blocklist", "enabled"], required=True) 146 | if not isinstance(blocklist_enabled, bool): 147 | raise ConfigError("blocklist.enabled must be a boolean value") 148 | self.blocklist_enabled = blocklist_enabled 149 | 150 | self.blocklist_regexes = self._compile_regexes( 151 | ["blocklist", "regexes"], required=True 152 | ) 153 | 154 | def _compile_regexes( 155 | self, path: list[str], required: bool = True 156 | ) -> list[re.Pattern]: 157 | """Compile a config option containing a list of strings into re.Pattern objects. 158 | 159 | Args: 160 | path: The path to the config option. 161 | required: True, if the config option is mandatory. 162 | 163 | Returns: 164 | A list of re.Pattern objects. 165 | 166 | Raises: 167 | ConfigError: 168 | - If required is specified, but the config option does not exist. 169 | - If the config option is not a list of strings. 170 | - If the config option contains an invalid regular expression. 171 | """ 172 | 173 | readable_path = ".".join(path) 174 | regex_strings = self._get_cfg(path, required=required) # raises ConfigError 175 | 176 | if not isinstance(regex_strings, list) or ( 177 | isinstance(regex_strings, list) 178 | and any(not isinstance(x, str) for x in regex_strings) 179 | ): 180 | raise ConfigError(f"{readable_path} must be a list of strings") 181 | 182 | compiled_regexes = [] 183 | for regex in regex_strings: 184 | try: 185 | compiled_regexes.append(re.compile(regex)) 186 | except re.error as e: 187 | raise ConfigError( 188 | f"'{e.pattern}' contained in {readable_path} is not a valid regular expression" 189 | ) 190 | 191 | return compiled_regexes 192 | 193 | def _get_cfg( 194 | self, 195 | path: List[str], 196 | default: Any = None, 197 | required: bool = True, 198 | ) -> Any: 199 | """Get a config option from a path and option name, specifying whether it is 200 | required. 201 | 202 | Raises: 203 | ConfigError: If required is specified and the object is not found 204 | (and there is no default value provided), this error will be raised 205 | """ 206 | # Sift through the config until we reach our option 207 | config = self.config 208 | for name in path: 209 | config = config.get(name) 210 | 211 | # If at any point we don't get our expected option... 212 | if config is None: 213 | # Raise an error if it was required 214 | if required and not default: 215 | raise ConfigError(f"Config option {'.'.join(path)} is required") 216 | 217 | # or return the default value 218 | return default 219 | 220 | # We found the option. Return it 221 | return config 222 | 223 | 224 | CONFIG: Config = Config() 225 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Matrix Reminder Bot 2 | 3 | 4 | ![example of interacting with the bot](screenshot.png) 5 | 6 | A short bot written with [nio-template](https://github.com/anoadragon453/nio-template). 7 | 8 | ## Features 9 | 10 | * Set reminders 11 | * Have the bot remind you or the whole room 12 | * Reminders persist between bot restarts 13 | * Alarms - persistent notifications for a reminder until silenced 14 | * Supports end-to-end encrypted rooms 15 | 16 | ## Install 17 | 18 | matrix-reminder-bot requires 19 | [matrix-nio](https://github.com/matrix-org/matrix-nio), which supports 20 | participation in end-to-end encryption rooms! To do so, it makes use of the 21 | [libolm](https://gitlab.matrix.org/matrix-org/olm) C library. This library 22 | must be installed to allow for end-to-end encryption functionality, and 23 | unfortunately it is *also* required for functional message polling, so it is 24 | practically a hard required for this program. 25 | 26 | Unfortunately, installation of this library can be non-trivial on some 27 | platforms. However, with the power of docker, dependencies can be handled with 28 | little fuss, and it is thus the recommended method of installing 29 | `matrix-reminder-bot`. Native installation instructions are also provided, but 30 | be aware that they are more complex. 31 | 32 | ### Docker 33 | 34 | **Recommended.** Follow the docker [installation instructions](docker/README.md#setup). 35 | 36 | #### Matrix Docker Ansible Deploy 37 | 38 | The [matrix-docker-ansible-deploy](project) supports setting up a whole Matrix stack 39 | including lots of addons mostly automatically, including Matrix Reminder Bot. 40 | 41 | ### Native installation 42 | 43 | #### Install libolm 44 | 45 | You can install [libolm](https://gitlab.matrix.org/matrix-org/olm) from source, 46 | or alternatively, check your system's package manager. Version `3.0.0` or 47 | greater is required. Version `3.2.16` or greater is required with Python 3.12. 48 | 49 | **(Optional) postgres development headers** 50 | 51 | By default, matrix-reminder-bot uses SQLite as its storage backend. This is 52 | fine for a few hundred users, but if you plan to support a much higher volume 53 | of requests, you may consider using Postgres as a database backend instead. 54 | 55 | If you want to use postgres as a database backend, you'll need to install 56 | postgres development headers: 57 | 58 | Debian/Ubuntu: 59 | 60 | ``` 61 | sudo apt install libpq-dev libpq5 62 | ``` 63 | 64 | Arch: 65 | 66 | ``` 67 | sudo pacman -S postgresql-libs 68 | ``` 69 | 70 | #### Install Python dependencies 71 | 72 | Create and activate a Python 3 virtual environment: 73 | 74 | ``` 75 | python3 -m venv env 76 | source env/bin/activate 77 | ``` 78 | 79 | Install python dependencies: 80 | 81 | ``` 82 | pip install matrix-reminder-bot 83 | ``` 84 | 85 | (Optional) If you want to use postgres as a database backend, use the following 86 | command to install postgres dependencies alongside those that are necessary: 87 | 88 | ``` 89 | pip install "matrix-reminder-bot[postgres]" 90 | ``` 91 | 92 | ## Configuration 93 | 94 | Copy the sample configuration file to a new `config.yaml` file. 95 | 96 | ``` 97 | cp sample.config.yaml config.yaml 98 | ``` 99 | 100 | Edit the config file. The `matrix` section must be modified at least. 101 | 102 | #### (Optional) Set up a Postgres database 103 | 104 | Create a postgres user and database for matrix-reminder-bot: 105 | 106 | ``` 107 | sudo -u postgresql psql createuser matrix-reminder-bot -W # prompts for a password 108 | sudo -u postgresql psql createdb -O matrix-reminder-bot matrix-reminder-bot 109 | ``` 110 | 111 | Edit the `storage.database` config option, replacing the `sqlite://...` string with `postgres://...`. The syntax is: 112 | 113 | ``` 114 | database: "postgres://username:password@localhost/dbname?sslmode=disable" 115 | ``` 116 | 117 | See also the comments in `sample.config.yaml`. 118 | 119 | ## Running 120 | 121 | ### Docker 122 | 123 | Refer to the docker [run instructions](docker/README.md#running). 124 | 125 | ### Native installation 126 | 127 | Make sure to source your python environment if you haven't already: 128 | 129 | ``` 130 | source env/bin/activate 131 | ``` 132 | 133 | Then simply run the bot with: 134 | 135 | ``` 136 | matrix-reminder-bot 137 | ``` 138 | 139 | By default, the bot will run with the config file at `./config.yaml`. However, an 140 | alternative relative or absolute filepath can be specified after the command: 141 | 142 | ``` 143 | matrix-reminder-bot other-config.yaml 144 | ``` 145 | 146 | ## Usage 147 | 148 | Invite the bot to a room and it should accept the invite and join. 149 | 150 | ### Setting a reminder 151 | 152 | Have the bot ping you in the room about something: 153 | 154 | ``` 155 | !remindme