Introduction

Welcome to the maubot docs!

This book contains usage and developer documentation for maubot. The book is built using mdBook. The source code is available in the mautrix/docs repo.

Discussion

Matrix room: #maubot:maunium.net

Setup

Requirements

  • Python 3.10 or higher with pip and virtualenv
  • (For dev setup) Node.js and Yarn

Production setup

  1. Create a directory (mkdir maubot) and enter it (cd maubot). Do not clone the repository. If you want to use a specific version from git rather than PyPI, use the development setup instructions.
  2. Set up a virtual environment.
    1. Create with virtualenv -p /usr/bin/python3 . (note the dot at the end)
      • You should not use a subdirectory for the virtualenv in this production setup. The pip install step places some required files at the root of the environment.
    2. Activate with source ./bin/activate
  3. Install with pip install --upgrade maubot
  4. Copy example-config.yaml to config.yaml and update to your liking.
  5. Create the log directory and all directories used in plugin_directories (usually mkdir plugins trash logs).
  6. Start with python3 -m maubot.
  7. The management interface should now be available at http://localhost:29316/_matrix/maubot or whatever you configured.

Upgrading

  1. Run the install command again (step #3).
  2. Restart maubot.

Development setup

  1. Clone the repository.
  2. Optional, but strongly recommended: Set up a virtual environment.
    1. Create with virtualenv -p /usr/bin/python3 .venv
    2. Activate with source .venv/bin/activate
  3. Install with pip install --editable . (note the dot at the end)
  4. Build the frontend:
    1. cd maubot/management/frontend
    2. Install dependencies with yarn
    3. Build with yarn build
  5. Optional: Configure debug file open so that you can open files in your IDE by clicking on stack trace lines in the frontend log viewer.
  6. Continue from step 4 of the production setup. Note that the example config to copy will be inside the maubot directory, not in the repo root.

Upgrading

  1. Pull changes from Git.
  2. Update dependencies with pip install --upgrade -r requirements.txt.
  3. Restart maubot.

Setup with Docker

Docker images are hosted on dock.mau.dev

  1. Create a directory (mkdir maubot) and enter it (cd maubot).
  2. Pull the docker image with docker pull dock.mau.dev/maubot/maubot:<version>. Replace <version> with the version you want to run (e.g. latest)
  3. Run the container for the first time, so it can create a config file for you:
    docker run --rm -v $PWD:/data:z dock.mau.dev/maubot/maubot:<version>
    
  4. Update the config to your liking.
  5. Run maubot:
    docker run --restart unless-stopped -p 29316:29316 -v $PWD:/data:z dock.mau.dev/maubot/maubot:<version>
    
  6. The management interface should now be available at http://localhost:29316/_matrix/maubot or whatever you configured.

Upgrading

  1. Pull the new version (setup step 1).
  2. Restart the container.

Reverse proxying

The maubot management interface has a log viewer which uses a websocket to get real-time logs from the backend. If you're using a reverse proxy (as you should) in most cases, you probably need to configure it to allow websockets for the /_matrix/maubot/v1/logs endpoint.

Caddy

Caddy 2 supports websockets out of the box with no additional configuration.

example.com {
    reverse_proxy /_matrix/maubot http://localhost:29316
}

Nginx

server {
    listen 443 ssl;
    ...
    location /_matrix/maubot/v1/logs {
        proxy_pass http://localhost:29316;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header X-Forwarded-For $remote_addr;
    }

    location /_matrix/maubot {
        proxy_pass http://localhost:29316;
        proxy_set_header X-Forwarded-For $remote_addr;
    }
    ...
}

Basic usage

After setting up maubot, you can use the web management interface to make it do things. The default location of the management interface is http://localhost:29316/_matrix/maubot.

Logging into the interface happens with the credentials configured in the admins section in the config. Note that the root user can't have a password, so you must use a different username for logging in.

Uploading plugins

Plugins contain the actual code for a bot, so the first step is to upload a plugin. There's currently no central location to download plugins, but there's a list of plugins at plugins.mau.bot.

Common places to get plugins:

After you have the .mbp file, click the + button next to the "Plugins" header and drop your .mbp file in the upload box.

Creating clients

Each Matrix account you want to use as a bot needs to be added as a client. You could have one bot account that does everything, or many bot accounts with different purposes. To create a client, click the + button next to the "Clients" header and fill in the form.

  • The homeserver dropdown gets values from the config's homeservers section, but you can also type a full URL in that box.
  • The access token and device ID are acquired by logging into the account. There are several ways to do that, including the mbc auth command included in maubot.
    • Note that encryption won't work if you reuse the token from an e2ee-capable client like Element, so make sure you get a fresh token if you want to use the bot in encrypted rooms.
  • The avatar URL can be left empty, you can upload an image after creating the client.
  • Both the display name and avatar URL can be set to disable if you don't want maubot to change them. If left empty, maubot will remove the displayname and avatar on Matrix.

Creating instances

Instances are how plugins are linked to clients. After you have uploaded a plugin and created a client, simply click the + button next to the "Instances" header, choose your client and plugin in the relevant dropdowns, invent an instance ID and click "Create".

You may have to refresh the management UI if the plugin and client you created don't show up in the dropdowns.

If the plugin has any configuration options, the config editor will show up under the created instance.

Encryption

Dependencies

To enable encryption, you must first have maubot installed with the encryption optional dependency. To do this, you can either add [encryption] at the end of the package in the pip install command, e.g. pip install --upgrade maubot[encryption]. Alternatively, you can install the dependencies manually (python-olm, pycryptodome and unpaddedbase64). The Docker image has all optional dependencies installed by default.

Note that installing python-olm requires libolm3 with dev headers, Python dev headers, and a C compiler. This means libolm-dev, python3-dev and build-essential on Debian 11+ and Ubuntu 19.10+.

If you want to avoid the dev headers, you can install the libolm3 package without -dev and get a pre-compiled python-olm from gitlab.matrix.org's PyPI registry. However, this method has not been tested properly, so it might not work at all.

pip install python-olm --extra-index-url https://gitlab.matrix.org/api/v4/projects/27/packages/pypi/simple

To install python-olm on macOS, you can use libolm from homebrew like this:

brew install libolm
pip3 install python-olm --global-option="build_ext" --global-option="--include-dirs="`brew --prefix libolm`"/include" --global-option="--library-dirs="`brew --prefix libolm`"/lib"

Getting a fresh device ID

When using maubot with encryption, you must have an access token and a device ID that haven't been used in an e2ee-capable client. In other words, you can't take the access token from Element, you have to log in manually. The easiest way to do that is to use mbc auth.

Actually enabling encryption

After installing dependencies, put the device ID in the maubot client, either using the web UI or just the --update-client flag with mbc auth.

maubot cli

Maubot includes the mbc (maubot CLI) tool for some management tasks.

When you install maubot, mbc will be available inside the virtual environment automatically. You can install maubot locally without actually running the server to get the mbc command.

To get started with mbc, first log into your maubot instance using mbc login.

mbc login

The mbc login command logs you into your maubot instance to enable most other mbc commands like uploading plugins (mbc upload) or logging into bot accounts (mbc auth).

Running mbc login without arguments will prompt you to provide the instance details interactively. Alternatively, you can use the CLI flags to log in non-interactively. See mbc login --help for the list of flags.

mbc auth

The mbc auth command can be used to log into Matrix accounts.

To log in with mbc auth, first make sure you have your homeserver listed in the homeservers section in the maubot config (the secret can be empty). If you haven't used the mbc tool before, log into your maubot instance with mbc login. Finally, run mbc auth and fill in the parameters:

  • The homeserver is the dictionary key, i.e. server name (not URL) from the homeservers config.
  • The username can be either the username or full user ID, that doesn't matter.
  • The password is the password.

If the command says "Registration target server not found", it means you didn't add the server to homeservers properly or didn't enter the correct name in mbc.

If you want to register an account, you must pass --register as a parameter. This also requires the secret to be set in the config.

Single sign-on is also supported by using the --sso flag. When that flag is passed, the command will open the login page in a browser instead of prompting for username and password.

Additionally, there's a --update-client parameter that tells maubot to store the created access token as a client instance so you don't have to do it manually in the web interface.

mbc build

The mbc build command zips plugin files into a .mbp file that can be uploaded to the maubot server.

The simplest way to use it is to run mbc build in the directory that contains maubot.yaml, and it will output the matching .mbp file in the same directory.

There are also some other options available:

  • mbc build <directory> to build the plugin from a different directory.
  • mbc build -o <path> to output the .mbp file to a different location.
  • mbc build -u to upload the plugin directly to a server instead of saving it to a local file.

The -u/--upload flag is especially useful when developing plugins, as the server will reload all instances so you can test the changes immediately.

Management API

Most of the API is simple HTTP+JSON and has OpenAPI documentation (see spec.yaml, rendered). However, some parts of the API aren't documented in the OpenAPI document.

Matrix API proxy

The full Matrix API can be accessed for each client with a request to /_matrix/maubot/v1/proxy/<user>/<path>. <user> is the Matrix user ID of the user to access the API as and <path> is the whole API path to access (e.g. /_matrix/client/r0/whoami).

The body, headers, query parameters, etc are sent to the Matrix server as-is, with a few exceptions:

  • The Authorization header will be replaced with the access token for the Matrix user from the maubot database.
  • The access_token query parameter will be removed.

Log viewing

  1. Open websocket to /_matrix/maubot/v1/logs.
  2. Send authentication token as a plain string.
  3. Server will respond with {"auth_success": true} and then with {"history": ...} where ... is a list of log entries.
  4. Server will send new log entries as JSON.

Log entry object format

Log entries are a JSON-serialized form of Python log records.

Log entries will always have:

  • id - A string that should uniquely identify the row. Currently uses the relativeCreated field of Python logging records.
  • msg - The text in the entry.
  • time - The ISO date when the log entry was created.

Log entries should also always have:

  • levelname - The log level (e.g. DEBUG or ERROR).
  • levelno - The integer log level.
  • name - The name of the logger. Common values:
    • maubot.client.<mxid> - Client loggers (Matrix HTTP requests)
    • maubot.instance.<id> - Plugin instance loggers
    • maubot.loader.zip - The zip plugin loader (plugins don't have their own logs)
  • module - The Python module name where the log call happened.
  • pathname - The full path of the file where the log call happened.
  • filename - The file name portion of pathname
  • lineno - The line in code where the log call happened.
  • funcName - The name of the function where the log call happened.

Log entries might have:

  • exc_info - The formatted exception info if an exception was logged.
  • matrix_http_request - The info about a Matrix HTTP request. Subfields:
    • method - The HTTP method used.
    • path - The API path used.
    • content - The content sent.
    • user - The appservice user who the request was ran as.

Debug file open

For debug and development purposes, the API and frontend support clicking on lines in stack traces to open that line in the selected editor.

Configuration

First, the directory where maubot is run from must have a .dev-open-cfg.yaml file. The file should contain the following fields:

  • editor - The command to run to open a file.
    • $path is replaced with the full file path.
    • $line is replaced with the line number.
  • pathmap - A list of find-and-replaces to execute on paths. These are needed to map things like .mbp files to the extracted sources on disk. Each pathmap entry should have:
    • find - The regex to match.
    • replace - The replacement. May insert capture groups with Python syntax (e.g. \1)

Example file:

editor: pycharm --line $line $path
pathmap:
- find: "maubot/plugins/xyz\\.maubot\\.(.+)-v.+(?:-ts[0-9]+)?.mbp"
  replace: "mbplugins/\\1"
- find: "maubot/.venv/lib/python3.6/site-packages/mautrix"
  replace: "mautrix-python/mautrix"

API

Clients can GET /_matrix/maubot/v1/debug/open to check if the file open endpoint has been set up. The response is a JSON object with a single field enabled. If the value is true, the endpoint can be used.

To open files, clients can POST /_matrix/maubot/v1/debug/open with a JSON body containing

  • path - The full file path to open
  • line - The line number to open

Standalone mode

The normal mode in maubot is very dynamic: the config file doesn't really contain any runtime details other than a general web server, database, etc. Everything else is set up at runtime using the web management interface or the management API directly. This dynamicness is very useful for developing bots and works fine for deploying it on personal servers, but it's not optimal for larger production deployments.

The solution is standalone mode: a separate entrypoint that runs a single maubot plugin with a predefined Matrix account.

Additionally, standalone mode supports using appservice transactions to receive events instead of /sync, which is often useful for huge production instances with lots of traffic.

Basic usage

  1. Set up a virtual environment.
    1. Create with virtualenv -p /usr/bin/python3 .venv
    2. Activate with source .venv/bin/activate
  2. Install maubot into the virtualenv with pip install --upgrade maubot[all]
    • [all] at the end will install all optional dependencies! The e2ee optional dependency requires libolm3 to be installed natively.
    • Alternatively install the latest git version with pip install --upgrade maubot[all]@git+https://github.com/maubot/maubot.git
  3. Extract the plugin you want to run into the directory
    • .mbp files can be extracted with unzip.
    • You can also just clone the plugin repository and use it directly.
    • After extracting, you should have maubot.yaml and some Python modules in the directory.
  4. Install any dependencies that the plugin has into the virtualenv manually (they should be listed in maubot.yaml).
  5. Copy the standalone example config to the same directory as config.yaml and fill it out.
    • If the plugin has a config, you should copy the contents from the plugin's base-config.yaml into the plugin_config object in the standalone config, then fill it out as needed.
  6. Run the bot with python -m maubot.standalone

Getting started

This guide assumes you already have a maubot instance set up.

A maubot plugin file (.mbp) is a zip file that contains maubot.yaml and some Python modules. The maubot.yaml file contains metadata for maubot, such as the plugin's ID and what Python modules it contains.

The Plugin metadata page documents all options available in maubot.yaml. A minimal meta file looks like this:

maubot: 0.1.0
id: com.example.examplebot
version: 1.0.0
license: MIT
modules:
  - examplebot
main_class: ExampleBot

The file above will tell maubot to load the examplebot Python module and find the ExampleBot class inside it. Note that Python modules are currently loaded into the global context, so they must be unique. The class must inherit the Plugin class from maubot. A simple bot that does nothing would therefore look like this:

from maubot import Plugin


class ExampleBot(Plugin):
  pass

A bot that does nothing is a bit boring, so let's make it do something. The maubot.handlers module contains decorators that can be used to define event handlers, chat commands and HTTP endpoints. Most bots use commands, so let's start with that.

The simplest command handlers are simply methods that take one parameter (the event) and are decorated with @command.new(). The method name will be used as the command.

from maubot import Plugin, MessageEvent
from maubot.handlers import command


class ExampleBot(Plugin):
  @command.new()
  async def hello_world(self, evt: MessageEvent) -> None:
    await evt.reply("Hello, World!")

With the maubot.yaml meta file above and this Python file saved as examplebot/__init__.py, you can build the plugin and try it out. To build plugins, you can either use mbc build or just zip it yourself (zip -9r plugin.mbp * in the directory with maubot.yaml). After you have the .mbp file, upload it to your maubot instance (see Basic usage), then try to use the !hello-world command.

If you make any changes, you can use mbc build --upload to build and upload the plugin directly to the server. Any plugin instances will be reloaded automatically so you can try out your changes immediately after uploading.

Python tips

This page collects various details about Python and how maubot uses modern Python features. It is not yet ready, but the info below might already be of some use.

asyncio

maubot and mautrix-python use the asyncio library in Python 3. This means that everything is single-threaded, but still asynchronous.

When writing a plugin that interacts with some network service, it is strongly recommended to use asyncio libraries for that interaction. If you use a traditional synchronous library, it will block everything else running on the same maubot instance.

For example, if you need to make HTTP requests, use aiohttp instead of requests. Plugins have convenient access to an aiohttp client instance through the http property in the plugin base class.

If there are no asyncio libraries available for the thing you want to do, you can run the synchronous methods in a separate thread using asyncio's built-in run_in_executor with a ThreadPoolExecutor.

If you haven't used asyncio before, you may want to read a guide on the topic before developing maubot plugins. Some potentially good ones are:

Type hints

Most of the methods in maubot and mautrix-python have proper type hints. Even Matrix events are parsed into convenient type-hinted objects. Using an editor that provides autocompletion based on type hints is recommended.

API reference

This part of the docs contains method/field references for various classes in maubot. These are currently not autogenerated, as I haven't found a good Python API reference generator.

Plugin metadata

The maubot.yaml file can contain the following fields:

  • maubot - The minimum version of maubot that the plugin requires. Currently only v0.1.0 exists, so the field doesn't do anything yet.
  • id - An unique identifier for the plugin. It should follow Java package naming conventions (use your own domain, not xyz.maubot).
  • version - The version of the plugin in PEP 440 format.
  • license - The SPDX license identifier for the plugin. Optional, assumes all rights reserved if omitted.
  • modules - A list of Python modules that the plugin includes.
    • Python modules are either directories with an __init__.py file, or simply Python files.
    • Submodules that are imported by modules listed here don't need to be listed separately. However, top-level modules must always be listed even if they're imported by other modules.
    • Modules are loaded in the given order, which means that dependencies must be first, and usually the module containing your main class will be last.
    • Currently module names must be globally unique.
  • main_class - The main class of the plugin as module/ClassName.
    • If module/ is omitted, maubot will look for the class in the last module specified in the modules list.
    • Even if the module is not omitted, it must still be listed in the modules array.
  • extra_files - An instruction for the mbc build command to bundle additional files in the .mbp file. Used for things like example configs.
  • dependencies - A list of Python modules and their version ranges that the plugin requires. This is currently not used, but in the future maubot will offer to automatically install dependencies when uploading a plugin.
    • This should only include top-level dependencies of the plugin, i.e. things that you explicitly import. Don't specify transitive dependencies.
    • Core maubot dependencies should also not be specified. Specifically, don't include mautrix, aiohttp, yarl, asyncpg, aiosqlite, ruamel.yaml or attrs. Also obviously don't include maubot itself.
    • It's recommended to specify version ranges (e.g. based on semver), not exact versions.
  • soft_dependencies - Same as dependencies, but not required for the plugin to function.
  • config - Whether the plugin has a configuration
  • webapp - Whether the plugin registers custom HTTP handlers
  • database - Whether the plugin has a database

Plugin fields and lifecycle methods

The Plugin base class has various lifecycle methods and properties that may be useful for plugins.

Fields

  • client - The mautrix client instance for the bot, can be used to make arbitrary Matrix API requests.
  • http - An aiohttp client instance, can be used to make arbitrary HTTP requests.
  • id - The ID of the plugin instance.
  • log - A logger for the plugin instance.
  • loop - The asyncio event loop.
  • loader - The class used to load the plugin files. Can be used to read arbitrary files from the plugin's .mbp archive.
  • config - If the config is enabled, the data from the config (see the Configuration page).
  • database - If the database is enabled, the database engine (see the Database page).
  • webapp - If the HTTP handlers are enabled, the aiohttp UrlDispatcher for the plugin (see the HTTP handlers) page.
  • webapp_url - If the HTTP handlers are enabled, the public base URL where the endpoints are exposed.

Methods

  • register_handler_class(object) - Register another object where handlers are read from (see the Handlers page).

Lifecycle methods

These are methods that the plugin itself can override.

  • async def start() - Called when the plugin instance is starting.
  • async def stop() - Called when the plugin instance is stopping.
  • async def pre_start() - Called when the plugin instance is starting, before the handlers of the main class are registered.
  • async def pre_stop() - Called when the plugin instance is stopping, before any handlers are unregistered.
  • def get_config_class() -> Type[Config] - When the plugin has config, this must return the class used for parsing and updating the config.
  • def on_external_config_update() - Called when the plugin instance's config is updated from the API. By default, this will call self.config.load_and_update().

MessageEvent reference

These methods are available in maubot's MessageEvent class.

  • async react(key: str) -> EventID - React to the command with the given key. The key can be arbitrary unicode text, but usually reactions are emojis.
  • async mark_read() - Send a read receipt for the command event.
  • async respond(content) -> EventID - Respond to a message without replying. Optional parameters:
    • event_type (defaults to EventType.ROOM_MESSAGE) - The event type to send the response as.
    • markdown (defaults to True) - Whether the content should be parsed as markdown. Only applies if the content parameter is a string.
    • allow_html (defaults to True) - Whether the content should allow HTML tags. Only applies if the content parameter is a string.
    • edits (optional, event ID or MessageEvent, defaults to None) - The event that the response event should edit.
    • in_thread (optional) - Whether the response should be in a thread with the command. By default (None), the response is in a thread if the command is in a thread. If False, the response will never be in a thread. If True, the response will always be in a thread (creating a thread with the command as the root if necessary).
  • async reply(content) -> EventID - Reply to a message. Same parameters as respond(), except no edits option.
  • async edit(content) -> EventID - Edit the event. Same parameters as reply(). Note that while this won't throw an error for editing non-own messages, most clients won't render such edits.
  • content - Some useful methods are also inside the content property:
    • relates_to - A property containing the event's m.relates_to data wrapped in a RelatesTo object.
    • get_reply_to() -> EventID - Get the event ID the command is replying to.
    • get_edit() -> EventID - Get the event ID the command is editing.

Event fields

These are the fields in the MessageEvent type (and also all other room events) . They're a part of the Matrix spec, but maubot parses them into convenient objects.

  • room_id - The ID of the room where the event was sent.
  • event_id - The ID of the event.
  • sender - The ID of the user who sent the event.
  • timestamp - The Unix timestamp of the event with millisecond precision (this is called origin_server_ts in the protocol).
  • type - The event type as an EventType enum instance.
  • content - The content of the event. This is parsed into specific classes depending on the event type and msgtype. If the type isn't recognized, this will be parsed into an Obj, which is just a dict that allows accessing fields with the dot notation.
  • unsigned - Additional information that's not signed in the federation protocol. Usually set by the local homeserver.
    • transaction_id - If the message was sent by the current user, the transaction ID that was used to send the event.

m.room.message content

These are the fields in the MessageEventContent type. As with event fields, they're a part of the Matrix spec.

  • body - The plaintext body.
  • msgtype - The message type as a MessageType enum instance.

For m.text, m.notice and m.emote (TextMessageEventContent):

  • format - The format for formatted_body as a Format enum instance.
  • formatted_body - The formatted body (usually HTML).

For m.image, m.video, m.audio and m.file (MediaMessageEventContent):

  • url - The mxc:// URL to the file (if unencrypted).
  • file - The URL and encryption keys for the file (if encrypted).
    • TODO: add methods to conveniently decrypt data using the EncryptedFile object.
  • info - Additional info about the file. Everything here is optional and set by the sender's client (i.e. not trustworthy).
    • mimetype - The mime type of the file.
    • size - The size of the file in bytes.
    • TODO: document msgtype-specific info fields.

For m.location (LocationMessageEventContent):

  • geo_uri - The coordinates the event is referring to as a geo:<latitude>,<longitude> URI.
  • info - Optional info containing a thumbnail (same as image thumbnails).

Handlers

Plugins can register various types of handlers using decorators from the maubot.handlers module.

Currently, there are three types of handlers:

  • command for conveniently defining command handlers.
  • event for handling raw Matrix events.
  • web for adding custom HTTP endpoints.

Splitting handlers into multiple files

By default, maubot will only scan the plugin's main class for decorated methods. If you want to split handlers into multiple files, you'll have to use the register_handler_class method.

second_handler.py:

from maubot import MessageEvent
from maubot.handlers import command


class SecondHandlerClass:
  @command.new()
  async def example(self, evt: MessageEvent) -> None:
    pass

__init__.py:

from maubot import Plugin
from .second_handler import SecondHandlerClass


class SplitBot(Plugin):
  async def start(self) -> None:
    handler = SecondHandlerClass()
    self.register_handler_class(handler)

Command handlers

Commands are the most common way of interacting with bots. The command system in maubot lets you define commands, subcommands, argument parsing and more.

The simplest command handler is just a method that takes a MessageEvent and is decorated with @command.new(). The MessageEvent object contains all the info about the event (sender, room ID, timestamp, etc) and some convenience methods, like replying to the command (evt.reply). See the MessageEvent reference for more details on that.

from maubot import Plugin, MessageEvent
from maubot.handlers import command


class SimpleBot(Plugin):
  @command.new()
  async def command(self, evt: MessageEvent) -> None: ...

@command.new() parameters

The @command.new() decorator has various parameters which can be used to change how the command itself works:

name

The name of the command. This defaults to the name of the method. The parameter can either be a string, or a function. The function can take either zero or one argument (the Plugin instance). The latter case is meant for making the name configurable.

A command defined like this would be ran with !hello-world:

from maubot import Plugin, MessageEvent
from maubot.handlers import command


class CustomNameBot(Plugin):
  @command.new(name="hello-world")
  async def hmm(self, evt: MessageEvent) -> None: ...

Here get_command_name is a function that takes one argument, self. It then gets the command_prefix config field and returns that as the command prefix. See the Configuration page for details on how to have a config for the plugin.

from maubot import Plugin, MessageEvent
from maubot.handlers import command


class RenamingBot(Plugin):
  def get_command_name(self) -> str:
    return self.config["command_prefix"]

  @command.new(name=get_command_name)
  async def hmm(self, evt: MessageEvent) -> None: ...

help

A short help text for the command. This essentially just adds some metadata to the function that contains the help text. The metadata is currently only used for subcommands (for commands that require a subcommand, no arguments will produce a help message), but it's theoretically possible to use it for other purposes too.

aliases

This defines additional names for the command. Aliases can be used to trigger the command, but they won't show up in help texts or other such things. The parameter is similar to name: it can either be a tuple/list/set, or a function.

The function takes one or two parameters (either just the command, or the plugin instance and the command) and returns a boolean to indicate whether the given parameter is a valid alias for the command.

If the parameter is a function, it must return True for the primary command name. If it's a list, it doesn't have to include the primary name.

from maubot import Plugin, MessageEvent
from maubot.handlers import command


class RenamingAliasBot(Plugin):
  def is_match(self, command: str) -> bool:
    return command == "hmm" or command in self.config["command_aliases"]

  @command.new(aliases=is_match)
  async def hmm(self, evt: MessageEvent) -> None: ...

event_type and msgtypes

Command handlers are fundamentally just wrappers for raw event handlers. The event_type and msgtype parameters can be set to change what event and message types the command handler reacts to.

event_type is a single EventType, defaulting to EventType.ROOM_MESSAGE. Multiple event types for a command handler are currently not supported. msgtypes is any iterable (list, tuple, etc) of allowed MessageTypes, it defaults to only allowing MessageType.TEXT.

require_subcommand

This parameter makes the command always output a help message if a subcommand wasn't used. This defaults to true, but it only affects commands that have at least one subcommand defined. You should change this to False if your top-level command has its own functionality. For example, the XKCD bot has !xkcd <number> as a top-level command with one argument, and some subcommands like !xkcd search <query>.

arg_fallthrough

For commands with both subcommands and top-level arguments, this parameter can be used to make the top-level arguments fall through to the subcommand handlers. The command will be parsed as !<command> <top-level arguments> <subcommand> <subcommand arguments>

must_consume_args

Whether the command definition has to consume all arguments that the user provides. If this is true (the default), then any arguments that can't be parsed will cause the bot to send a help message instead of executing the command handler.

Subcommands

For more complicated bots, it's often useful to have multiple distinct command handlers for different tasks. While it's technically possible to use separate commands, it might be nicer to have everything under a single command. To do this, maubot allows you to define subcommands.

Subcommands are defined by using the .subcommand() decorator of a top-level command that was defined earlier:

from maubot import Plugin, MessageEvent
from maubot.handlers import command


class SimpleBot(Plugin):
  @command.new(name="hello", require_subcommand=True)
  async def base_command(self, evt: MessageEvent) -> None:
    # When you require a subcommand, the base command handler
    # doesn't have to do anything.
    pass

  @base_command.subcommand(help="Do subcommand things")
  async def subcommand(self, evt: MessageEvent) -> None:
    await evt.react("subcommand!")

In this case, the subcommand handler would be triggered by !hello subcommand. If you send just !hello, it will respond with a help page. The help argument from the subcommand decorator is how the help page is populated.

The subcommand decorator has the same parameters as top-level commands, except it can't define the event_type and msgtypes that the handler catches. You can also nest subcommands as deep as you like (i.e. in the above example, @subcommand.subcommand() is a valid decorator for a third method).

Passive command handlers

Passive commands are command handlers that don't follow a strict syntax like !command <arguments>, but instead just match some regular expressions anywhere in a message.

Passive commands are created using the @command.passive(regex) decorator. The regex is a required argument. It can either be a string (in which case maubot will compile it) or a pre-re.compile'd Pattern object.

from typing import Tuple
from maubot import Plugin, MessageEvent
from maubot.handlers import command


class PassiveBot(Plugin):
  @command.passive("cat")
  async def command(self, evt: MessageEvent, match: Tuple[str]) -> None:
    await evt.react("🐈ïļ")

Handler parameters

Like command handlers, the first parameter is the MessageEvent object. In addition to that, passive commands always have a second parameter. By default, the parameter is a tuple where the first element is the full match, and the following elements are individual capture groups. If the multiple flag is set, then the parameter is an array containing one or more such tuples.

@command.passive() parameters

field

This defines which field to run the regex against in the event content. The parameter is a function that takes a single argument, the event object. The default value will return evt.content.body (i.e. the plaintext body of the message).

multiple

...

event_type and msgtypes

These work the same way as in @command.new()

case_insensitive, multiline and dot_all

These parameters enable regex flags. They only apply if you pass a string instead of a compiled pattern as the regex. They set the re.IGNORECASE , re.MULTILINE and re.DOTALL flags respectively.

Raw event handlers

Raw event handlers can be used to handle any incoming Matrix events. They don't have any kind of filters, so make sure to ignore echo events from the bot itself if necessary.

from maubot import Plugin
from maubot.handlers import event
from mautrix.types import EventType, StateEvent


class RawEventHandlingBot(Plugin):
  @event.on(EventType.ROOM_TOMBSTONE)
  async def handle_tombstone(self, evt: StateEvent) -> None:
    self.log.info(f"{evt.room_id} was upgraded into "
                  f"{evt.content.replacement_room}")
    await self.client.send_text(evt.room_id, "ðŸŠĶ")

If you want to handle custom events, you'll have to construct an EventType instance first:

from maubot import Plugin
from maubot.handlers import event
from mautrix.types import EventType, GenericEvent

custom_event = EventType.find("com.example.custom",
                              t_class=EventType.Class.MESSAGE)


class CustomEventHandlingBot(Plugin):
  @event.on(custom_event)
  async def handle_custom_event(self, evt: GenericEvent) -> None:
    self.log.info("Custom event data: %s", evt.content)

The t_class field defines what type of event you want. For normal room events, it's either MESSAGE or STATE. If you set it to STATE, your handler won't receive any non-state events, which is meant to protect from a fairly common class of bugs.

HTTP handlers

Plugins can define custom HTTP endpoints that are published under the main maubot web server. The endpoints can do anything from JSON APIs to fully fledged websites.

Web handlers are disabled by default. You must set webapp: true in your plugin's maubot.yaml to enable them. After that, simply decorate an aiohttp handler method with @web.method(path) where method is the HTTP method, e.g. get or post:

from maubot import Plugin
from maubot.handlers import web
from aiohttp.web import Request, Response, json_response


class WebsiteBot(Plugin):
  @web.get("/id")
  async def get_id(self, req: Request) -> Response:
    return json_response({"id": self.id})

  @web.post("/data/{id}")
  async def post_data(self, req: Request) -> Response:
    data_id = req.match_info["id"]
    data = await req.text()
    self.log.debug(f"Received data with ID {data_id}: {data}")
    return Response(status=200)

The defined endpoints are reachable at http://your.maubot.instance/_matrix/maubot/plugin/<instance ID>/<path>. Plugins can find their own public web base URL in self.webapp_url.

The web server itself is aiohttp. Refer to the aiohttp server docs for more info on how to make request handler methods, responses and other such things. The maubot.handlers.web module is designed to work like aiohttp's RouteTableDef, but there may be minor differences.

Configuration

Plugins can have instance-specific YAML config files. When a plugin has a config, the maubot admin panel will have a config editor for each instance of the plugin, which can be used to conveniently modify the config at runtime.

Quick start

To add configuration to your plugin, first create a file called base-config.yaml with the default config. Let's use a simple example with a user ID whitelist and a customizable command prefix:

# Who is allowed to use the bot?
whitelist:
  - "@user:example.com"
# The prefix for the main command without the !
command_prefix: hello-world

After that, add config and extra_files to your maubot.yaml:

config: true
extra_files:
  - base-config.yaml

Finally, define a Config class and implement the get_config_class method:

from typing import Type
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
from maubot import Plugin, MessageEvent
from maubot.handlers import command


class Config(BaseProxyConfig):
  def do_update(self, helper: ConfigUpdateHelper) -> None:
    helper.copy("whitelist")
    helper.copy("command_prefix")


class ConfigurableBot(Plugin):
  async def start(self) -> None:
    self.config.load_and_update()

  def get_command_name(self) -> str:
    return self.config["command_prefix"]

  @command.new(name=get_command_name)
  async def hmm(self, evt: MessageEvent) -> None:
    if evt.sender in self.config["whitelist"]:
      await evt.reply("You're whitelisted 🎉")

  @classmethod
  def get_config_class(cls) -> Type[BaseProxyConfig]:
    return Config

Config updates

The do_update method is called whenever the user modifies the config. The purpose of the method is to copy values from the user-provided config into the base config bundled with the plugin. The result of that update is then stored as the actual plugin config.

The purpose of this is to ensure that the config data is always what the plugin expects. If you make a config schema change, simply implement a migration in do_update, and old configs will be automatically updated.

For example, say you wanted a more granular permission system than a whitelist. First, you'd change the base config to only have your new schema:

# Who is allowed to use the bot?
permissions:
  "@user:example.com": 100
# The prefix for the main command without the !
command_prefix: hello-world

Then, you'd implement a migration in the do_update method by checking if the old field is present. When updating the config, self is the old or user-specified config and helper.base is the base config that the values should be copied to.

from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper


class Config(BaseProxyConfig):
  def do_update(self, helper: ConfigUpdateHelper) -> None:
    if "whitelist" in self:
      # Give everyone in the old whitelist level 100
      helper.base["permissions"] = {user_id: 100
                                    for user_id in self["whitelist"]}
    else:
      # Config is already using new system, just copy permissions like usual
      helper.copy("permissions")
    helper.copy("command_prefix")

Helper methods

The ConfigUpdateHelper contains two methods and properties:

  • base - The contents of base-config.yaml.
  • source - The old or user-specified config. This is the same as self in the do_update method.
  • copy(from_path, to_path) - Copy a value from the source to the base. from_path is the path in the source (old/user-specified) config, while to_path is the path in the base config. If not specified, to_path will be the same as from_path. This will check if the value is present in the source config before copying it.
  • copy_dict(from_path, to_path, override) - Copy entries in a dict from the source to the base. If override is set to false, keys that are in the base config but not in the source config will be kept in the end result, which is useful if you have a set of keys that must be defined.

Modifying the config from the plugin

The config can also be modified by the plugin itself. Simply store whatever values you want in the self.config dict, then call self.config.save(). The config updater will not be ran when you save the config, but it will be ran when the plugin is next reloaded, so make sure the updater won't remove your changes.

Database

Plugins can request an instance-specific SQL database.

Maubot supports two types of databases: SQLAlchemy (legacy) and asyncpg/aiosqlite (new). The SQLAlchemy mode is deprecated and not documented.

Note that the new asyncpg system requires maubot 0.3.0 or higher.

Quick start

Update maubot.yaml to tell the server that your plugin wants a database:

database: true
database_type: asyncpg

The database connector will be provided in the database property. The type is mautrix.util.async_db.Database, which emulates asyncpg's pool interface. For legacy plugins (with database_type unset or set to sqlalchemy), the database property contains an SQLAlchemy engine.

For details on the methods available in the Database class, see Database API reference.

You can also find an example plugin in the maubot repo: examples/database/

Schema upgrades

mautrix-python includes a simple framework for versioning the schema. The migrations are simply Python functions which are registered in an UpgradeTable. By default, the migrations are registered in order and each one bumps the version by 1. The example below would result in the version table containing 2.

from mautrix.util.async_db import UpgradeTable, Scheme

upgrade_table = UpgradeTable()

@upgrade_table.register(description="Initial revision")
async def upgrade_v1(conn: Connection, scheme: Scheme) -> None:
    if scheme == Scheme.SQLITE:
        await conn.execute(
            """CREATE TABLE foo (
                test INTEGER PRIMARY KEY
            )"""
        )
    else:
        await conn.execute(
            """CREATE TABLE foo (
                test INTEGER GENERATED ALWAYS AS IDENTITY
            )"""
        )

@upgrade_table.register(description="Add text column")
async def upgrade_v2(conn: Connection) -> None:
    await conn.execute("ALTER TABLE foo ADD COLUMN text TEXT DEFAULT 'hi'")

After you have an UpgradeTable, you need to expose it from your plugin class so maubot knows to use it:

from mautrix.util.async_db import UpgradeTable
from maubot import Plugin

from .migrations import upgrade_table

class DatabasefulBot(Plugin):
    @classmethod
    def get_db_upgrade_table(cls) -> UpgradeTable:
        return upgrade_table

Maubot will then run the migrations always before starting your bot.

Database API reference

You can also view the Sphinx docs for the database at https://docs.mau.fi/python/latest/api/mautrix.util/async_db.html, but as of writing, they aren't ready yet.

Database

acquire is an async context manager that returns a Connection:

async with self.database.acquire() as conn:
    conn.execute(...)

The class also contains execute, executemany, fetch, fetchrow, fetchval and table_exists as convenience methods, which simply acquire a connection and run the single method using it (see reference below).

Connection

Records returned by the fetch methods are either asyncpg.Record or the stdlib's sqlite3.Row. Both work mostly the same way (can access fields by both column index and name).

  • execute(query: str, *args: Any) -> str - Execute a query without reading the response:
    await conn.execute("INSERT INTO foo (text) VALUES ($1)", "hello world")
    
  • executemany(query: str, *args: Any) -> str - Execute a query multiple times:
    await conn.execute(
      "INSERT INTO foo (text) VALUES ($1)",
      [("hello world 1",), ("hello world 2",), ("hello world 3",)],
    )
    
  • fetch(query: str, *args: Any) -> list[Row | Record] - Execute a query and get a list of rows in response:
    rows = await conn.fetch("SELECT text FROM foo")
    for row in rows:
      print(row["text"])
    
  • fetchrow(query: str, *args: Any) -> Row | Record | None - Execute a query and get the first rows (or None, if there are no rows):
    row = await conn.fetchrow("SELECT text FROM foo WHERE test=1")
    if row:
      print("Found", row["text"])
    else:
      print("Row not found :(")
    
  • fetchval(query: str, *args: Any, column: int = 0) -> Any - Execute a query and get a single column from the first row (or None if there are no rows):
    text = await conn.fetchval("SELECT text FROM foo WHERE test=1")
    print(text)
    
  • table_exists(name: str) -> bool - Check if a table exists in the database.
  • Postgres only: copy_records_to_table(table_name: str, *, records: list[tuple[Any, ...]], columns: tuple[str, ...]) -> None - Efficiently insert multiple rows into the database:
    await conn.copy_records_to_table(
      table_name="foo",
      records=[("hello world 1",), ("hello world 2",), ("hello world 3",)],
      columns=("text",),
    )