Sfoglia il codice sorgente

Add support for Meowlnir

Meowlnir (https://github.com/maunium/meowlnir) is a Matrix moderation
bot which speaks the same policy-list protocol as Mjolnir and Draupnir,
but runs as an appservice and can override individual policies coming
from ban lists you do not control.

Bots and their management rooms live only in Meowlnir's own database —
nothing in its configuration file can declare one — so the role
provisions them through the management API from a declarative roster
(matrix_bot_meowlnir_bots_custom), applied under the
ensure-matrix-users-created tag. Management rooms may be declared or
created for you; bots and rooms no longer declared get pruned.

Wrapper scripts for driving the management API by hand are installed
to /matrix/meowlnir/bin.

Meowlnir re-runs its configuration upgrader in memory on every start,
so a literal `generate` value yields a new secret per restart. All
secrets are therefore rendered explicitly, validation rejects
`generate`, and the configuration directory is mounted read-only.

Draupnir and Meowlnir both want synapse-http-antispam, which the
playbook wires up to a single consumer. The wiring prefers Draupnir,
and both roles fail the run when each claims it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull/5517/head
Slavi Pantaleev 1 giorno fa
parent
commit
f9222dc70c
36 ha cambiato i file con 2270 aggiunte e 7 eliminazioni
  1. +13
    -0
      CHANGELOG.md
  2. +1
    -0
      README.md
  3. +3
    -1
      docs/configuring-playbook-bot-draupnir.md
  4. +381
    -0
      docs/configuring-playbook-bot-meowlnir.md
  5. +2
    -0
      docs/configuring-playbook-bot-mjolnir.md
  6. +2
    -0
      docs/configuring-playbook.md
  7. +1
    -0
      docs/container-images.md
  8. +119
    -6
      group_vars/matrix_servers
  9. +545
    -0
      roles/custom/matrix-bot-meowlnir/defaults/main.yml
  10. +36
    -0
      roles/custom/matrix-bot-meowlnir/tasks/main.yml
  11. +35
    -0
      roles/custom/matrix-bot-meowlnir/tasks/setup_bots.yml
  12. +127
    -0
      roles/custom/matrix-bot-meowlnir/tasks/setup_install.yml
  13. +29
    -0
      roles/custom/matrix-bot-meowlnir/tasks/setup_uninstall.yml
  14. +81
    -0
      roles/custom/matrix-bot-meowlnir/tasks/util/ensure_bot_created.yml
  15. +57
    -0
      roles/custom/matrix-bot-meowlnir/tasks/util/ensure_management_room_created.yml
  16. +38
    -0
      roles/custom/matrix-bot-meowlnir/tasks/util/ensure_management_room_registered.yml
  17. +41
    -0
      roles/custom/matrix-bot-meowlnir/tasks/util/prune_bot.yml
  18. +33
    -0
      roles/custom/matrix-bot-meowlnir/tasks/util/prune_bot_management_rooms.yml
  19. +50
    -0
      roles/custom/matrix-bot-meowlnir/tasks/util/prune_bots.yml
  20. +38
    -0
      roles/custom/matrix-bot-meowlnir/tasks/util/prune_management_room.yml
  21. +29
    -0
      roles/custom/matrix-bot-meowlnir/tasks/util/read_meowlnir_state.yml
  22. +90
    -0
      roles/custom/matrix-bot-meowlnir/tasks/util/validate_bot.yml
  23. +25
    -0
      roles/custom/matrix-bot-meowlnir/tasks/util/validate_bot_management_room.yml
  24. +83
    -0
      roles/custom/matrix-bot-meowlnir/tasks/validate_config.yml
  25. +55
    -0
      roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-api.j2
  26. +3
    -0
      roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-api.j2.license
  27. +21
    -0
      roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-bots.j2
  28. +3
    -0
      roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-bots.j2.license
  29. +90
    -0
      roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-create-management-room.j2
  30. +3
    -0
      roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-create-management-room.j2.license
  31. +84
    -0
      roles/custom/matrix-bot-meowlnir/templates/config.yaml.j2
  32. +78
    -0
      roles/custom/matrix-bot-meowlnir/templates/labels.j2
  33. +54
    -0
      roles/custom/matrix-bot-meowlnir/templates/systemd/matrix-bot-meowlnir.service.j2
  34. +3
    -0
      roles/custom/matrix-bot-meowlnir/templates/systemd/matrix-bot-meowlnir.service.j2.license
  35. +16
    -0
      roles/custom/matrix-bot-meowlnir/vars/main.yml
  36. +1
    -0
      setup.yml

+ 13
- 0
CHANGELOG.md Vedi File

@@ -1,3 +1,16 @@
# 2026-08-13

## Support for Meowlnir

The playbook can now install [Meowlnir](https://github.com/maunium/meowlnir), an opinionated Matrix moderation bot, optimized for Synapse.

Meowlnir joins [Draupnir](docs/configuring-playbook-bot-draupnir.md) and [Mjolnir](docs/configuring-playbook-bot-mjolnir.md) as a 3rd moderation option, speaking the same [policy list](https://the-draupnir-project.github.io/draupnir-documentation/concepts/policy-lists) protocol so that it can subscribe to the same community ban lists. Unlike the others, it can override individual policies coming from lists you do not control (by way of an unban policy in a list of your own which is ordered ahead of other policy lists).

You can run it alongside Draupnir while evaluating it — see the documentation for the caveats, the main one being that both want the [synapse-http-antispam](https://github.com/maunium/synapse-http-antispam) module, which only reports to a single consumer.

To get started, see the [Setting up Meowlnir](docs/configuring-playbook-bot-meowlnir.md) documentation page.


# 2026-08-06

## (Backward Compatibility Break) Jitsi no longer uses Colibri WebSockets


+ 1
- 0
README.md Vedi File

@@ -151,6 +151,7 @@ Bots provide various additional functionality to your installation.
| [Honoroit](https://github.com/etkecc/honoroit) | ❌ | Helpdesk bot | [Link](docs/configuring-playbook-bot-honoroit.md) |
| [Mjolnir](https://github.com/matrix-org/mjolnir) | ❌ | Moderation tool for Matrix | [Link](docs/configuring-playbook-bot-mjolnir.md) |
| [Draupnir](https://github.com/the-draupnir-project/Draupnir) | ❌ | Moderation tool for Matrix (Fork of Mjolnir) | [Link](docs/configuring-playbook-bot-draupnir.md) (for [appservice mode](docs/configuring-playbook-appservice-draupnir-for-all.md))|
| [Meowlnir](https://github.com/maunium/meowlnir) | ❌ | Moderation tool for Matrix, running in appservice mode | [Link](docs/configuring-playbook-bot-meowlnir.md) |
| [Buscarron](https://github.com/etkecc/buscarron) | ❌ | Web forms (HTTP POST) to Matrix | [Link](docs/configuring-playbook-bot-buscarron.md) |

### Administration


+ 3
- 1
docs/configuring-playbook-bot-draupnir.md Vedi File

@@ -16,7 +16,9 @@ See the project's [documentation](https://the-draupnir-project.github.io/draupni

This documentation page is about installing Draupnir in bot mode. As an alternative, you can run a multi-instance Draupnir deployment by installing [Draupnir in appservice mode](./configuring-playbook-appservice-draupnir-for-all.md) (called Draupnir-for-all) instead.

If your migrating from [Mjolnir](configuring-playbook-bot-mjolnir.md), skip to [this section](#migrating-from-mjolnir-only-required-if-migrating).
**Note**: you can also use the [Meowlnir](configuring-playbook-bot-meowlnir.md) or [Mjolnir](configuring-playbook-bot-mjolnir.md) bots, which speak the same [policy list](https://the-draupnir-project.github.io/draupnir-documentation/concepts/policy-lists) protocol.

When migrating from [Mjolnir](configuring-playbook-bot-mjolnir.md), skip to [this section](#migrating-from-mjolnir-only-required-if-migrating).

## Prerequisites



+ 381
- 0
docs/configuring-playbook-bot-meowlnir.md Vedi File

@@ -0,0 +1,381 @@
<!--
SPDX-FileCopyrightText: 2026 Slavi Pantaleev

SPDX-License-Identifier: AGPL-3.0-or-later
-->

# Setting up Meowlnir (optional)

The playbook can install and configure the [Meowlnir](https://github.com/maunium/meowlnir) moderation bot for you.

See the project's [documentation](https://docs.mau.fi/meowlnir/) to learn what it does and why it might be useful to you.

Meowlnir is an alternative to [Draupnir](configuring-playbook-bot-draupnir.md) and [Mjolnir](configuring-playbook-bot-mjolnir.md). It speaks the same [policy list](https://the-draupnir-project.github.io/draupnir-documentation/concepts/policy-lists) protocol, so it can subscribe to the same community ban lists, but it differs from them in a few ways that may matter to you:

- It runs as an **appservice** and hosts **multiple bots**, each with its own management room. They live in Meowlnir's database, not its configuration file, but you still [declare them in your `vars.yml` file](#declaring-bots).
- It can **override a policy coming from a list you do not control**, via unban policies combined with the ordering of your watched lists. See [Overriding a policy from someone else's list](#overriding-a-policy-from-someone-elses-list).
- It is written in Go and is optimized for Synapse, using its database and admin APIs directly.

Meowlnir and Draupnir can run side by side, but not usefully in the *same* room: whichever bot you are migrating away from still watches the same community lists, so it re-applies the very bans your unban policies remove. Migrate room by room. See [Trialling Meowlnir alongside another bot](#trialling-meowlnir-alongside-another-bot).

## Prerequisites

### Postgres

Meowlnir stores its state in a Postgres database. The playbook creates one for you automatically when using the integrated Postgres server.

### Adjusting DNS records

**No DNS changes are necessary.** Meowlnir is reached by the homeserver over the container network, and the paths it optionally serves publicly (abuse reports and the policy server) are routed on your existing `matrix.example.com` domain.

## Adjusting the playbook configuration

To enable Meowlnir, add the following configuration to your `inventory/host_vars/matrix.example.com/vars.yml` file:

```yaml
matrix_bot_meowlnir_enabled: true
```

### Declaring bots

That gets the service running, but Meowlnir does nothing until it has at least one bot. Declare the ones you want and the playbook creates them for you:

```yaml
matrix_bot_meowlnir_bots_custom:
- username: meowlnir_bot
displayname: Meowlnir
avatar_url: ""
management_room_auto_create: true
management_rooms: []
# Left out, the initial managers default to `matrix_bot_meowlnir_initial_managers`,
# which is defined in terms of `matrix_admin`.
# Uncomment to override that for this bot, or if neither variable is set.
# initial_managers:
# - "@alice:example.com"
# - "@bob:example.com"
```

| Field | Required | Notes |
|---|---|---|
| `username` | yes | The localpart of the bot's Matrix user. Awkward to change later, so choose it carefully. |
| `displayname` | yes | The name shown in rooms. Safe to change at any time. |
| `avatar_url` | yes | An `mxc://` URI, or `""` for no avatar. |
| `management_room_auto_create` | yes | Whether the playbook creates the bot's management room. Mutually exclusive with a non-empty `management_rooms`. |
| `management_rooms` | yes | Rooms you have created yourself, `[]` when auto-creating. See [Supplying your own management room](#supplying-your-own-management-room). |
| `initial_managers` | no | Who to invite to an auto-created room. Defaults to the instance-wide list below. |

Usernames have to start with `meowlnir_` (the value of `matrix_bot_meowlnir_user_prefix`), so that bots fall inside the user namespace the homeserver lets Meowlnir operate.

Changing `displayname` or `avatar_url` and re-running updates the bot in place. The comparison is against Meowlnir's own record, so profile edits made directly from a Matrix client are not reverted.

#### Initial managers

With `management_room_auto_create: true`, the playbook creates each bot's management room and invites its initial managers to it. You only need to accept the room invitation.

`matrix_admin` is a single playbook variable which affects all bridges and bots, so setting it is usually a better move than setting anything specific to this role — **if it is already configured in your `vars.yml` file, there is nothing to do here**. If neither `matrix_admin` nor `matrix_bot_meowlnir_initial_managers` are set, the playbook would tell you about it.

A per-bot `initial_managers` replaces the instance-wide list. Declaring it empty means nobody, which fails the run for a bot relying on `management_room_auto_create`.

The list is consulted only while the room is being created. Adding a name to it later invites nobody, because the room already exists — invite and promote further moderators from inside the room instead, as described in [Who can command a bot](#who-can-command-a-bot).

#### Rooms the playbook creates

The bot creates the room with the `trusted_private_chat` preset, which gives every invitee the standing to command it. This is an additional room creator on room versions supporting [MSC4289](https://github.com/matrix-org/matrix-spec-proposals/pull/4289) (like v12) and power level 100 on older room versions. Creator status cannot be revoked subsequently.

The room's encryption follows `matrix_bot_meowlnir_config_encryption_enable`, and its name and topic come from `matrix_bot_meowlnir_management_room_name` and `matrix_bot_meowlnir_management_room_topic`.

### Supplying your own management room

If you would rather own the room outright, create it yourself and declare it instead. The bot is then merely an administrator in a room you created:

```yaml
matrix_bot_meowlnir_bots_custom:
- username: meowlnir_bot
displayname: Meowlnir
avatar_url: ""
management_room_auto_create: false
management_rooms:
- id: "!qporfwt:example.com"
encrypted: false
```

An empty, invite-only room is fine. The order matters, because **each step depends on the one before it**:

1. Declare the room and run the playbook. Do not invite the bot beforehand.
2. The playbook creates the bot and tells Meowlnir about the room. Meowlnir would try to join right away, but will fail for invite-only rooms (a harmless error in the log).
3. Invite the bot. It accepts the invitation, because the room is already marked as a management room for it.
4. Give it power level 50 or more (ideally 100), so that it can store its protected rooms and watched lists there.

Meowlnir supports several management rooms per bot, and `encrypted` is set per room. Marking a room encrypted only means something when [End-to-End Encryption support](#end-to-end-encryption-support) is switched on, which it is not by default.

### Who can command a bot

**Management room membership alone is not enough**, which is different from what [Draupnir](configuring-playbook-bot-draupnir.md) does, where everyone in the management room can issue commands.

Meowlnir decides who may drive a bot from power levels in its management room: anyone who can send the `fi.mau.meowlnir.watched_lists` state event (power level 50 by default), plus the room's creators.

### Bots which are no longer declared

The bot list (`matrix_bot_meowlnir_bots_custom`) is authoritative. Removing entries from there will make the playbook unregister them with the Meowlnir instance.

Removal only adjusts Meowlnir's own records. A removed bot's Matrix user remains activated and stays in the rooms it had joined. A removed management room leaves the room and the bot's membership in it intact - it's just that Meowlnir stops taking commands there.

Removal happens under the same `ensure-matrix-users-created` tag that creates bots. It's one Ansible tag for "synchronizing the bots state" (creation, changes, and removal).

To turn removal off entirely, set `matrix_bot_meowlnir_bots_pruning_enabled: false`. As a safety measure, the playbook refuses to prune when *no* bots are declared at all; override that with `matrix_bot_meowlnir_bots_pruning_on_empty_roster_enabled: true`.

### Trialling Meowlnir alongside another bot

Meowlnir has a dry-run mode in which it does everything except take moderation actions:

```yaml
matrix_bot_meowlnir_config_meowlnir_dry_run: true
```

> [!WARNING]
> Dry run does not cover the [synapse-http-antispam](#enabling-synapse-http-antispam-support) integration. It suppresses actions Meowlnir takes itself (bans, server ACLs, rejecting pending invites), but the verdicts it hands back to Synapse still block invites and joins. Leave that integration off while trialling.

Do not expect dry run to preview what Meowlnir would do in rooms another policy-list bot already moderates. Meowlnir only acts on users who are *in* a room, and the other bot has already removed everyone its lists match, so the preview comes out empty. Dry run also skips the power level check described under [Protecting a room](#protecting-a-room), so it will not surface a permissions problem either.

### Abuse reports

Meowlnir can intercept the report endpoints of the client-server API, so that abuse reports are delivered to a management room. This requires integration with the reverse proxy in front of the homeserver, which the playbook sets up for you when using Traefik:

```yaml
matrix_bot_meowlnir_config_reporting_enabled: true

# The management room that receives the reports.
matrix_bot_meowlnir_config_meowlnir_report_room: "!qporfwt:example.com"
```

Only the `v3` report endpoints are routed to Meowlnir. Requests to the legacy `r0` endpoints continue to reach the homeserver, because Meowlnir does not serve them.

### Enabling synapse-http-antispam support

Meowlnir can block invites and joins before they happen. This requires the [synapse-http-antispam](https://github.com/maunium/synapse-http-antispam) module, which the playbook can enable for you:

```yaml
matrix_bot_meowlnir_synapse_http_antispam_enabled: true

# The management room whose policies the module consults.
matrix_bot_meowlnir_synapse_http_antispam_management_room_id: "!qporfwt:example.com"
```

> [!WARNING]
> The playbook wires the module up to a single consumer, so this cannot be enabled at the same time as `matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled`. The playbook fails the run if both are enabled.

With the module in place, you can also block invitations to specific users outright, which is useful for accounts that attract spam:

```yaml
matrix_bot_meowlnir_config_antispam_block_invites_to_custom:
- "@alice:example.com"
```

Such an invitation can still be let through case by case with the `!allow-invite` command.

### End-to-End Encryption support

To let Meowlnir's bots participate in encrypted rooms:

```yaml
matrix_bot_meowlnir_config_encryption_enable: true
```

When using Synapse, the playbook turns on the experimental features this depends on (`msc2409_to_device_messages_enabled` and `msc3202_transaction_extensions`) for you.

### Policy server (MSC4284)

Meowlnir can act as a [policy server](https://github.com/matrix-org/matrix-spec-proposals/pull/4284), letting rooms ask it to vet events before they are accepted:

```yaml
matrix_bot_meowlnir_policy_server_enabled: true
```

This exposes `/_matrix/policy` on your Matrix federation endpoint, so that other servers participating in a room can reach it.

That only stands the policy server up, though — no room is put behind it until you say so from the management room:

```
!policyserver enable !qporfwt:example.com
```

Given no room, `enable` applies to every protected room. Rooms which are not protected are skipped, with `Skipped ... as it is not a protected room`, so [protect a room](#protecting-a-room) before enabling it here. `!policyserver` on its own reports whether the policy server is available and prints its public key, and `!policyserver disable` reverses the change.

The playbook derives a stable signing key for you from `matrix_homeserver_generic_secret_key`. If you would rather use an independently generated one, produce it with the command below and set it as `matrix_bot_meowlnir_config_policy_server_signing_key`:

```sh
python3 -c "import os, base64; print('ed25519 policy_server ' + base64.b64encode(os.urandom(32)).decode().rstrip('='))"
```

### Synapse admin API access (optional)

A few of Meowlnir's features go through Synapse's admin API, not the client-server API — suspending or deactivating users, and deleting rooms during a takedown. Those calls require the caller to be a Synapse **server admin**, which bots are not by default, so they come back as `M_FORBIDDEN` ("You are not a server admin"). Nothing else is affected: bans, server ACLs, protecting rooms and watching policy lists all go through the client-server API, where a sufficient power level is the only requirement.

To grant that access, point each bot at a token belonging to a server admin:

```yaml
matrix_bot_meowlnir_config_meowlnir_admin_tokens:
"@meowlnir_bot:example.com": "ADMIN_TOKEN_HERE"
```

The key is the bot the token is used for; the token itself belongs to an administrator account, not to the bot.

If you have more than one management room, note that room bans are only processed in the one named by `matrix_bot_meowlnir_config_meowlnir_room_ban_room`, and ignored elsewhere.

Where the token comes from depends on how your homeserver authenticates. Ordinarily you [obtain an access token](obtaining-access-tokens.md) for an account which is a Synapse server admin. When [Matrix Authentication Service](configuring-playbook-matrix-authentication-service.md) is enabled, Synapse no longer decides who is an admin, so the token has to be issued by MAS with admin privileges:

```sh
/matrix/matrix-authentication-service/bin/mas-cli manage issue-compatibility-token --yes-i-want-to-grant-synapse-admin-privileges alice
```

Note that bot users are created by the appservice and are not known to Matrix Authentication Service, so the token cannot be issued for the bot itself — use an administrator account.

### Access to the Synapse database (optional)

Some room takedown features rely on Meowlnir reading room IDs directly from the Synapse database. Upstream expects a user with read-only permissions, which the playbook does not create. Enabling the integration below hands Meowlnir the same credentials Synapse itself uses, which also grant write access:

```yaml
matrix_bot_meowlnir_synapse_database_integration_enabled: true
```

If you would rather not do that, create a read-only Postgres user yourself and point Meowlnir at it with `matrix_bot_meowlnir_synapse_database_uri`.

### Adopting an existing Meowlnir installation

If you already run Meowlnir outside the playbook and want to bring it under this role, two things need attention before the first run.

Your bots exist in Meowlnir's database but not in your `vars.yml` file, and [pruning](#bots-which-are-no-longer-declared) is on by default, so the first run would remove them. Declare them in `matrix_bot_meowlnir_bots_custom` — with `management_room_auto_create: false` and their existing rooms under `management_rooms` — or set `matrix_bot_meowlnir_bots_pruning_enabled: false`.

If the installation uses encryption, also copy the `pickle_key` from its old configuration file into `matrix_bot_meowlnir_config_encryption_pickle_key`. The crypto store cannot be read with a different key than it was written with, so leaving the playbook's default in place costs your bots their existing encryption sessions.

### Extending the configuration

There are some additional things you may wish to configure about the bot.

Take a look at:

- `roles/custom/matrix-bot-meowlnir/defaults/main.yml` for some variables that you can customize via your `vars.yml` file. You can override settings (even those that don't have dedicated playbook variables) using the `matrix_bot_meowlnir_configuration_extension_yaml` variable

> [!WARNING]
> Do not set any of Meowlnir's secrets to the literal value `generate`. Meowlnir re-runs its configuration upgrader on every start, so a `generate` placeholder would produce a brand new secret on every restart. The playbook derives stable values for you, and fails the run if it finds a `generate` placeholder.

## Installing

After configuring the playbook, run it with [playbook tags](playbook-tags.md) as below:

```sh
ansible-playbook -i inventory/hosts setup.yml --tags=setup-all,ensure-matrix-users-created,start
```

With `management_room_auto_create`, you then have an invitation waiting for you — accept it and start sending commands. If you supplied the management room yourself, carry on from step 3 of [Supplying your own management room](#supplying-your-own-management-room): invite the bot, then give it power level 50 or more.

**Notes**:

- The `ensure-matrix-users-created` tag is what creates the bots declared in `matrix_bot_meowlnir_bots_custom`, registers their management rooms, and removes the ones you no longer declare. It deliberately does not run as part of `setup-all`, so that installing onto a server whose database you are about to restore from a backup does not write anything.

- Re-running is safe and idempotent, so adding a bot later is a matter of extending the list and running the same command again.

- The shortcut commands with the [`just` program](just.md) are also available: `just install-all` or `just setup-all`

## Usage

You can refer to the upstream [documentation](https://docs.mau.fi/meowlnir/) for a more detailed usage guide.

Below is a **non-exhaustive quick-start guide** for the impatient.

### Inspecting and driving Meowlnir directly

The playbook drives Meowlnir's management API for you based on `matrix_bot_meowlnir_bots_custom`, which is the recommended way. For anything it does not cover, helper scripts are installed under `/matrix/meowlnir/bin`, which find the management secret and reach the API inside the container:

```sh
# Show the bots, their management rooms, protected rooms and watched policy lists
/matrix/meowlnir/bin/meowlnir-bots

# Call any endpoint: meowlnir-api <METHOD> <PATH> [JSON body]
/matrix/meowlnir/bin/meowlnir-api GET /_meowlnir/v1/bots

# Create another management room for an existing bot, with the given users able to command it there
/matrix/meowlnir/bin/meowlnir-create-management-room meowlnir_bot @alice:example.com
```

`meowlnir-create-management-room` prints the new room's ID, which you then register with `meowlnir-api PUT /_meowlnir/v1/management_room/<room ID>`.

See the upstream [bot creation documentation](https://docs.mau.fi/meowlnir/bot-create.html) for the full set of endpoints. Bear in mind that bots you create this way are not declared in your `vars.yml` file, so the next playbook run will remove them again (see [Bots which are no longer declared](#bots-which-are-no-longer-declared)).

If you have enabled encryption, each bot also needs verifying once. That step is left manual because it returns a recovery key you need to store somewhere safe:

```sh
/matrix/meowlnir/bin/meowlnir-api POST /_meowlnir/v1/bot/meowlnir_bot/verify '{"generate": true}'
```

### Protecting a room

Invite the bot to a room, give it a power level high enough to act (see below), and then tell it to protect the room by sending this command to its management room:

```
!rooms protect !qporfwt:example.com
```

Meowlnir refuses to protect a room unless its power level reaches that room's own `ban` and `redact` levels (50 in a default room). That is only enough for user bans, though: writing `m.room.server_acl` usually requires 100, and without it the server rules in your watched lists have no effect — which is most of what a list like [CME](https://matrix.to/#/%23community-moderation-effort-bl:neko.dev) carries. **Give the bot power level 100** unless you only care about user bans.

Set the power level *before* protecting the room. Meowlnir re-sends server ACLs when it starts and when a watched list changes, but not when its own power level goes up subsequently, so raising it afterwards leaves the room without ACLs until you restart the bot (`systemctl restart matrix-bot-meowlnir` or via the playbook's Ansible `start` tag).

### Subscribing to a policy list

Policy lists are maintained in Matrix rooms. Popular public ones are:

- `#community-moderation-effort-bl:neko.dev`
- `#huginn-muninn-active-threats:feline.support`

Subscribe to one by sending the following command to the management room:

```
!lists subscribe #community-moderation-effort-bl:neko.dev cme
```

The last argument is a shortcode, which you use to refer to the list in later commands.

### Overriding a policy from someone else's list

This is the main capability Meowlnir has that Draupnir does not.

When several watched lists carry a policy for the same user, **the first match wins**, and "first" means the order in which the lists are watched. So to be able to override a community list's ban, your own list has to come before it.

`!lists subscribe` appends, which makes subscription order the precedence order. Subscribe to your own list first, and to community lists afterwards:

```
!lists create my-ban-exceptions
!lists subscribe #community-moderation-effort-bl:neko.dev cme
```

> [!NOTE]
> If you have already subscribed in the wrong order, fixing it means editing the `fi.mau.meowlnir.watched_lists` state event in the management room by hand. Newer Meowlnir releases (than `v0.2606.0`) add `!lists subscribe … --insert-before <shortcode>`, which will make reordering a single command.

You can then publish an unban policy into your own list, which takes precedence over the community list's ban:

```
!add-unban my-bans @alice:example.com false-positive
```

> [!NOTE]
> Unlike `!ban`, the `!add-unban` command does not treat its reason as a trailing argument, so a reason containing spaces is discarded. Use a single word (or hyphenate) until that is fixed upstream.

> [!IMPORTANT]
> An unban policy stops a ban from being **re-applied**; it does not undo one that is already in place unless Meowlnir applied it itself and still has it on record. A ban placed by a human moderator, or by the Draupnir or Mjolnir you are migrating away from, stays. Unban such a user once by hand — from then on the policy keeps them unbanned, while you remain subscribed to the list that banned them.

Use `!match @alice:example.com` to see which policies currently apply to a user and which list each came from.

> [!NOTE]
> Unban policies use a Meowlnir-specific recommendation (`fi.mau.meowlnir.unban`) which is not part of the Matrix specification. If you publish your policy list for other people to subscribe to, subscribers running Draupnir or Mjolnir will ignore your unban policies.

There is also a blunter, server-wide escape hatch for policies that are too wide to tolerate at all — `matrix_bot_meowlnir_config_meowlnir_hacky_rule_filter_custom`, which makes Meowlnir ignore any policy matching the listed entities.

## Troubleshooting

As with all other services, you can find the logs in [systemd-journald](https://www.freedesktop.org/software/systemd/man/systemd-journald.service.html) by running a command like `journalctl -fu matrix-bot-meowlnir`.

If bots appear to do nothing, check that they have an Administrator power level in the rooms they are meant to protect, and that the room has been added with `!rooms protect`.

If commands in a management room get no reply at all, check the bot's power level *there* too — it needs at least 50 to record its own configuration.

A bare `!lists` returns nothing in a management room which has never had a policy list. That is an upstream bug and it clears as soon as you subscribe to one; `!lists subscribe` works from the start.

+ 2
- 0
docs/configuring-playbook-bot-mjolnir.md Vedi File

@@ -15,6 +15,8 @@ The playbook can install and configure the [Mjolnir](https://github.com/matrix-o

See the project's [documentation](https://github.com/matrix-org/mjolnir/blob/main/README.md) to learn what it does and why it might be useful to you.

**Note**: you can also use the [Draupnir](configuring-playbook-bot-draupnir.md) or [Meowlnir](configuring-playbook-bot-meowlnir.md) bots, which speak the same [policy list](https://the-draupnir-project.github.io/draupnir-documentation/concepts/policy-lists) protocol.

## Prerequisites

### Register the bot account


+ 2
- 0
docs/configuring-playbook.md Vedi File

@@ -208,6 +208,8 @@ Bots provide various additional functionality to your installation.

- [Setting up Draupnir for all/D4A](configuring-playbook-appservice-draupnir-for-all.md) — like the [Draupnir bot](configuring-playbook-bot-draupnir.md) mentioned above, but running in appservice mode and supporting multiple instances

- [Setting up Meowlnir](configuring-playbook-bot-meowlnir.md) — a moderation tool/bot which speaks the same policy list protocol as Draupnir and Mjolnir, but runs in appservice mode and can override policies coming from lists you do not control

- [Setting up Buscarron](configuring-playbook-bot-buscarron.md) — a bot you can use to send any form (HTTP POST, HTML) to a (encrypted) Matrix room

### Administration


+ 1
- 0
docs/container-images.md Vedi File

@@ -125,6 +125,7 @@ Bots provide various additional functionality to your installation.
| [Honoroit](configuring-playbook-bot-honoroit.md) | [etke.cc/honoroit](https://github.com/etkecc/honoroit/container_registry) | ❌ | Helpdesk bot |
| [Mjolnir](configuring-playbook-bot-mjolnir.md) | [matrixdotorg/mjolnir](https://hub.docker.com/r/matrixdotorg/mjolnir) | ❌ | Moderation tool for Matrix |
| [Draupnir](configuring-playbook-bot-draupnir.md) | [gnuxie/draupnir](https://hub.docker.com/r/gnuxie/draupnir) | ❌ | Moderation tool for Matrix (Fork of Mjolnir) |
| [Meowlnir](configuring-playbook-bot-meowlnir.md) | [maunium/meowlnir](https://mau.dev/maunium/meowlnir/container_registry) | ❌ | Moderation tool for Matrix, running in appservice mode |
| [Buscarron](configuring-playbook-bot-buscarron.md) | [etke.cc/buscarron](https://ghcr.io/etkecc/buscarron) | ❌ | Web forms (HTTP POST) to Matrix |

## Administration


+ 119
- 6
group_vars/matrix_servers Vedi File

@@ -98,6 +98,8 @@ matrix_homeserver_container_extra_arguments_auto: |
+
(['--mount type=bind,src=' + matrix_appservice_draupnir_for_all_config_path + '/draupnir-for-all-registration.yaml,dst=/matrix-appservice-draupnir-for-all-registration.yaml,ro'] if matrix_appservice_draupnir_for_all_enabled else [])
+
(['--mount type=bind,src=' + matrix_bot_meowlnir_config_path + '/registration.yaml,dst=/matrix-bot-meowlnir-registration.yaml,ro'] if matrix_bot_meowlnir_enabled else [])
+
(['--mount type=bind,src=' + matrix_appservice_double_puppet_config_path + '/registration.yaml,dst=/matrix-appservice-double-puppet-registration.yaml,ro'] if matrix_appservice_double_puppet_enabled else [])
+
(['--mount type=bind,src=' + matrix_bridge_appservice_irc_config_path + '/registration.yaml,dst=/matrix-appservice-irc-registration.yaml,ro'] if matrix_bridge_appservice_irc_enabled else [])
@@ -161,6 +163,8 @@ matrix_homeserver_app_service_config_files_auto: |
+
(['/matrix-appservice-draupnir-for-all-registration.yaml'] if matrix_appservice_draupnir_for_all_enabled else [])
+
(['/matrix-bot-meowlnir-registration.yaml'] if matrix_bot_meowlnir_enabled else [])
+
(['/matrix-appservice-double-puppet-registration.yaml'] if matrix_appservice_double_puppet_enabled else [])
+
(['/matrix-appservice-irc-registration.yaml'] if matrix_bridge_appservice_irc_enabled else [])
@@ -357,6 +361,13 @@ devture_systemd_service_manager_services_list_auto: |
'groups': ['matrix', 'bots', 'draupnir', 'bot-draupnir'],
}] if matrix_bot_draupnir_enabled else [])
+
([{
'name': 'matrix-bot-meowlnir.service',
'priority': 4000,
'restart_necessary': (matrix_bot_meowlnir_restart_necessary | bool),
'groups': ['matrix', 'bots', 'meowlnir', 'bot-meowlnir'],
}] if matrix_bot_meowlnir_enabled else [])
+
([{
'name': 'matrix-appservice-discord.service',
'priority': 2000,
@@ -3350,6 +3361,91 @@ matrix_bot_draupnir_config_web_synapseHTTPAntispam_authorization: "{{ (matrix_ho
######################################################################


######################################################################
#
# matrix-bot-meowlnir
#
######################################################################

# We don't enable bots by default.
matrix_bot_meowlnir_enabled: false

matrix_bot_meowlnir_container_http_host_bind_port: "{{ (matrix_playbook_service_host_bind_interface_prefix ~ '8772') if matrix_playbook_service_host_bind_interface_prefix else '' }}"

matrix_bot_meowlnir_systemd_required_services_list_auto: |
{{
matrix_addons_homeserver_systemd_services_list
+
([postgres_identifier ~ '.service'] if (postgres_enabled and matrix_bot_meowlnir_database_hostname == postgres_connection_hostname) else [])
}}

matrix_bot_meowlnir_container_image_registry_prefix_upstream: "{{ matrix_container_global_registry_prefix_override if matrix_container_global_registry_prefix_override else matrix_bot_meowlnir_container_image_registry_prefix_upstream_default }}"

matrix_bot_meowlnir_container_image_self_build: "{{ matrix_architecture not in ['amd64', 'arm64'] }}"

matrix_bot_meowlnir_container_network: "{{ matrix_addons_container_network }}"

matrix_bot_meowlnir_container_additional_networks_auto: |-
{{
(
([] if matrix_addons_homeserver_container_network == '' else
[matrix_addons_homeserver_container_network])
+
([matrix_playbook_reverse_proxyable_services_additional_network] if
(matrix_playbook_reverse_proxyable_services_additional_network and matrix_bot_meowlnir_container_labels_traefik_enabled) else [])
+
([postgres_container_network] if (postgres_enabled and matrix_bot_meowlnir_database_hostname == postgres_connection_hostname and matrix_bot_meowlnir_container_network != postgres_container_network) else [])
) | unique
}}

matrix_bot_meowlnir_config_homeserver_address: "{{ matrix_addons_homeserver_client_api_url }}"
matrix_bot_meowlnir_config_homeserver_domain: "{{ matrix_domain }}"

# Only consulted by bots configured with `management_room_auto_create`, and only as the
# default for those which do not carry their own `initial_managers` list.
# May legitimately be empty, in which case such bots fail validation and ask to be told who
# their initial managers should be.
matrix_bot_meowlnir_initial_managers: "{{ [matrix_admin] if matrix_admin else [] }}"

matrix_bot_meowlnir_appservice_token: "{{ (matrix_homeserver_generic_secret_key + ':meowlnir.as.token') | hash('sha512') | to_uuid }}"
matrix_bot_meowlnir_homeserver_token: "{{ (matrix_homeserver_generic_secret_key + ':meowlnir.hs.token') | hash('sha512') | to_uuid }}"
matrix_bot_meowlnir_config_meowlnir_management_secret: "{{ (matrix_homeserver_generic_secret_key + ':meowlnir.management') | hash('sha512') | to_uuid }}"
matrix_bot_meowlnir_config_meowlnir_data_secret: "{{ (matrix_homeserver_generic_secret_key + ':meowlnir.data') | hash('sha512') | to_uuid }}"
matrix_bot_meowlnir_config_antispam_secret: "{{ (matrix_homeserver_generic_secret_key + ':meowlnir.antispam') | hash('sha512') | to_uuid }}"

# Meowlnir expects a Synapse-style ed25519 private key: `<algorithm> <key ID> <unpadded-base64 32-byte seed>`.
#
# Only the seed is stored in this format — Meowlnir derives the actual keypair from it
# via `ed25519.NewKeyFromSeed`, so no elliptic curve arithmetic happens here. The 32
# hexadecimal characters sliced out of the hash below serve as the seed's 32 bytes.
#
# The key ID is cosmetic: Meowlnir always signs with the hardcoded `ed25519:policy_server`
# (see `PolicyServerKeyID` upstream), so we use a matching name to avoid confusion.
#
# Note that slicing hex text gives the seed 128 bits of entropy, not a full 256.
# That matches ed25519's own security level, so it is not a practical weakness, but you can
# override this variable with an independently generated key if you would prefer one.
matrix_bot_meowlnir_config_policy_server_signing_key: "ed25519 policy_server {{ ((matrix_homeserver_generic_secret_key + ':meowlnir.policy_server.signing_key') | hash('sha512'))[:32] | b64encode | regex_replace('=+$', '') }}"

matrix_bot_meowlnir_database_hostname: "{{ postgres_connection_hostname if postgres_enabled else '' }}"
matrix_bot_meowlnir_database_password: "{{ (matrix_homeserver_generic_secret_key + ':bot.meowlnir.db') | hash('sha512') | to_uuid }}"

matrix_bot_meowlnir_synapse_database_uri: "{{ ('postgresql://' + matrix_synapse_database_user + ':' + matrix_synapse_database_password + '@' + matrix_synapse_database_host + ':' + (matrix_synapse_database_port | string) + '/' + matrix_synapse_database_database + '?sslmode=disable') if (matrix_bot_meowlnir_synapse_database_integration_enabled and matrix_synapse_enabled) else '' }}"

matrix_bot_meowlnir_container_labels_traefik_enabled: "{{ (matrix_bot_meowlnir_config_reporting_enabled or matrix_bot_meowlnir_policy_server_enabled) and matrix_playbook_reverse_proxy_type in ['playbook-managed-traefik', 'other-traefik-container'] }}"
matrix_bot_meowlnir_container_labels_traefik_docker_network: "{{ matrix_playbook_reverse_proxyable_services_additional_network }}"
matrix_bot_meowlnir_container_labels_reporting_traefik_entrypoints: "{{ traefik_entrypoint_primary }}"
matrix_bot_meowlnir_container_labels_reporting_traefik_tls_certResolver: "{{ traefik_certResolver_primary }}" # noqa var-naming
matrix_bot_meowlnir_container_labels_policy_server_traefik_entrypoints: "{{ traefik_entrypoint_primary }}"
matrix_bot_meowlnir_container_labels_policy_server_traefik_tls_certResolver: "{{ traefik_certResolver_primary }}" # noqa var-naming

######################################################################
#
# /matrix-bot-meowlnir
#
######################################################################


######################################################################
#
# matrix-appservice-draupnir-for-all
@@ -4226,6 +4322,12 @@ postgres_managed_databases_auto: |
'password': matrix_appservice_draupnir_for_all_database_password,
}] if (matrix_appservice_draupnir_for_all_enabled and matrix_appservice_draupnir_for_all_database_hostname == postgres_connection_hostname) else [])
+
([{
'name': matrix_bot_meowlnir_database_name,
'username': matrix_bot_meowlnir_database_username,
'password': matrix_bot_meowlnir_database_password,
}] if (matrix_bot_meowlnir_enabled and matrix_bot_meowlnir_database_engine == 'postgres' and matrix_bot_meowlnir_database_hostname == postgres_connection_hostname) else [])
+
([{
'name': matrix_bridge_appservice_irc_database_name,
'username': matrix_bridge_appservice_irc_database_username,
@@ -5023,12 +5125,18 @@ matrix_synapse_app_service_config_files_auto: "{{ matrix_homeserver_app_service_
# Disable creation of media repository Synapse worker when using media-repo
matrix_synapse_ext_media_repo_enabled: "{{ matrix_media_repo_enabled }}"

matrix_synapse_ext_synapse_http_antispam_enabled: "{{ matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled }}"
matrix_synapse_ext_synapse_http_antispam_config_base_url: "{{ matrix_bot_draupnir_synapse_http_antispam_config_base_url if matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled else '' }}"
matrix_synapse_ext_synapse_http_antispam_config_authorization: "{{ matrix_bot_draupnir_config_web_synapseHTTPAntispam_authorization if matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled else '' }}"
matrix_synapse_ext_synapse_http_antispam_config_enabled_callbacks: "{{ matrix_bot_draupnir_synapse_http_antispam_config_enabled_callbacks if matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled else [] }}"
matrix_synapse_ext_synapse_http_antispam_config_fail_open: "{{ matrix_bot_draupnir_synapse_http_antispam_config_fail_open if matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled else {} }}"
matrix_synapse_ext_synapse_http_antispam_config_async: "{{ matrix_bot_draupnir_synapse_http_antispam_config_async if matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled else {} }}"
# The synapse-http-antispam module reports to a single consumer, so at most one of
# Draupnir and Meowlnir may claim it. Each role's validation fails the run if both do.
matrix_synapse_ext_synapse_http_antispam_enabled: "{{ matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled or matrix_bot_meowlnir_synapse_http_antispam_enabled }}"
matrix_synapse_ext_synapse_http_antispam_config_base_url: "{{ matrix_bot_draupnir_synapse_http_antispam_config_base_url if matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled else (matrix_bot_meowlnir_synapse_http_antispam_config_base_url if matrix_bot_meowlnir_synapse_http_antispam_enabled else '') }}"
matrix_synapse_ext_synapse_http_antispam_config_authorization: "{{ matrix_bot_draupnir_config_web_synapseHTTPAntispam_authorization if matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled else (matrix_bot_meowlnir_config_antispam_secret if matrix_bot_meowlnir_synapse_http_antispam_enabled else '') }}"
matrix_synapse_ext_synapse_http_antispam_config_enabled_callbacks: "{{ matrix_bot_draupnir_synapse_http_antispam_config_enabled_callbacks if matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled else (matrix_bot_meowlnir_synapse_http_antispam_config_enabled_callbacks if matrix_bot_meowlnir_synapse_http_antispam_enabled else []) }}"
matrix_synapse_ext_synapse_http_antispam_config_fail_open: "{{ matrix_bot_draupnir_synapse_http_antispam_config_fail_open if matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled else (matrix_bot_meowlnir_synapse_http_antispam_config_fail_open if matrix_bot_meowlnir_synapse_http_antispam_enabled else {}) }}"
matrix_synapse_ext_synapse_http_antispam_config_async: "{{ matrix_bot_draupnir_synapse_http_antispam_config_async if matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled else (matrix_bot_meowlnir_synapse_http_antispam_config_async if matrix_bot_meowlnir_synapse_http_antispam_enabled else {}) }}"

# Meowlnir's bots need these to support end-to-end encryption.
matrix_synapse_experimental_features_msc2409_to_device_messages_enabled: "{{ matrix_bot_meowlnir_enabled and matrix_bot_meowlnir_config_encryption_enable }}"
matrix_synapse_experimental_features_msc3202_transaction_extensions_enabled: "{{ matrix_bot_meowlnir_enabled and matrix_bot_meowlnir_config_encryption_enable }}"

# Enable Synapse statistics reporting when using synapse-usage-exporter
matrix_synapse_report_stats: "{{ matrix_synapse_usage_exporter_enabled }}"
@@ -5171,6 +5279,11 @@ matrix_ketesa_config_asManagedUsers_auto: |
'^@'+(matrix_appservice_draupnir_for_all_user_prefix | default('') | regex_escape) +'_[a-zA-Z0-9]+:'+(matrix_domain | regex_escape)+'$',
] if matrix_appservice_draupnir_for_all_enabled else [])
+
([
matrix_bot_meowlnir_user_regex,
matrix_bot_meowlnir_appservice_sender_regex,
] if matrix_bot_meowlnir_enabled else [])
+
([
'^@'+(matrix_bot_baibot_config_user_mxid_localpart | default('') | regex_escape) +':'+(matrix_domain | regex_escape)+'$',
] if matrix_bot_baibot_enabled else [])


+ 545
- 0
roles/custom/matrix-bot-meowlnir/defaults/main.yml Vedi File

@@ -0,0 +1,545 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---
# An opinionated Matrix moderation bot
# Project source code URL: https://github.com/maunium/meowlnir

matrix_bot_meowlnir_enabled: true

# renovate: datasource=docker depName=dock.mau.dev/maunium/meowlnir
matrix_bot_meowlnir_version: v0.2606.0

matrix_bot_meowlnir_container_image_self_build: false
matrix_bot_meowlnir_container_image_self_build_repo: "https://github.com/maunium/meowlnir.git"

matrix_bot_meowlnir_container_image: "{{ matrix_bot_meowlnir_container_image_registry_prefix }}{{ matrix_bot_meowlnir_container_image_registry_namespace_identifier }}:{{ matrix_bot_meowlnir_version }}"
matrix_bot_meowlnir_container_image_registry_namespace_identifier: "maunium/meowlnir"
matrix_bot_meowlnir_container_image_registry_prefix: "{{ 'localhost/' if matrix_bot_meowlnir_container_image_self_build else matrix_bot_meowlnir_container_image_registry_prefix_upstream }}"
matrix_bot_meowlnir_container_image_registry_prefix_upstream: "{{ matrix_bot_meowlnir_container_image_registry_prefix_upstream_default }}"
matrix_bot_meowlnir_container_image_registry_prefix_upstream_default: "dock.mau.dev/"

matrix_bot_meowlnir_base_path: "{{ matrix_base_data_path }}/meowlnir"
matrix_bot_meowlnir_config_path: "{{ matrix_bot_meowlnir_base_path }}/config"
matrix_bot_meowlnir_data_path: "{{ matrix_bot_meowlnir_base_path }}/data"
matrix_bot_meowlnir_bin_path: "{{ matrix_bot_meowlnir_base_path }}/bin"
matrix_bot_meowlnir_container_src_files_path: "{{ matrix_bot_meowlnir_base_path }}/docker-src"

matrix_bot_meowlnir_container_network: ""

matrix_bot_meowlnir_container_additional_networks: "{{ matrix_bot_meowlnir_container_additional_networks_auto + matrix_bot_meowlnir_container_additional_networks_custom }}"
matrix_bot_meowlnir_container_additional_networks_auto: []
matrix_bot_meowlnir_container_additional_networks_custom: []

# Controls whether the matrix-bot-meowlnir container exposes its HTTP port.
#
# Takes an "<ip>:<port>" or "<port>" value (e.g.
# "127.0.0.1:29339"), or empty string to not expose.
matrix_bot_meowlnir_container_http_host_bind_port: ''

# A list of extra arguments to pass to the container
matrix_bot_meowlnir_container_extra_arguments: []

# List of systemd services that matrix-bot-meowlnir.service depends on
matrix_bot_meowlnir_systemd_required_services_list: "{{ matrix_bot_meowlnir_systemd_required_services_list_default + matrix_bot_meowlnir_systemd_required_services_list_auto + matrix_bot_meowlnir_systemd_required_services_list_custom }}"
matrix_bot_meowlnir_systemd_required_services_list_default: "{{ [devture_systemd_docker_base_docker_service_name] if devture_systemd_docker_base_docker_service_name else [] }}"
matrix_bot_meowlnir_systemd_required_services_list_auto: []
matrix_bot_meowlnir_systemd_required_services_list_custom: []

# List of systemd services that matrix-bot-meowlnir.service wants
matrix_bot_meowlnir_systemd_wanted_services_list: []


########################################################################
# #
# Appservice registration #
# #
########################################################################

# Meowlnir runs as an appservice.
# Unlike Draupnir in bot mode, it does not log in with an access token — the homeserver is told about it via a registration file.
#
# Bots themselves are created at runtime through Meowlnir's management API, within the user namespace declared below.
# See the role's documentation page for details.

matrix_bot_meowlnir_appservice_id: meowlnir

# The localpart of the appservice's own user.
# This user does not act as a moderation bot; it is the appservice's sender.
# Individual bots get their own users.
matrix_bot_meowlnir_appservice_sender_localpart: meowlnir

# The prefix for bot users created via the management API.
# Must line up with `matrix_bot_meowlnir_config_meowlnir4all_localpart_template`.
matrix_bot_meowlnir_user_prefix: "meowlnir_"


########################################################################
# #
# Managed bots #
# #
########################################################################

# Meowlnir hosts multiple bots, each with one or more management rooms, and creates them at runtime through its management API, not from its configuration file.
#
# The bots listed here are created (and kept up to date) by the playbook when running with the `ensure-matrix-users-created` tag, so that you do not need to call that API yourself.
#
# The fields below are required on every entry, so that each bot's setup can be understood without having to know what the playbook defaults to.
#
# - `username`: the localpart of the bot user. Must begin with `matrix_bot_meowlnir_user_prefix`, or the homeserver will not let Meowlnir operate it. Pick it carefully — it becomes the bot's Matrix user ID and is awkward to change later.
# - `displayname`: the name shown in rooms. Safe to change at any time.
# - `avatar_url`: an `mxc://` URI, or an empty string for no avatar.
# - `management_room_auto_create`: whether the playbook creates a management room for this bot. Mutually exclusive with `management_rooms` — see below.
# - `management_rooms`: the rooms you send commands to. A list, because Meowlnir supports several per bot. Each entry needs an `id` (a room ID, not an alias) and an `encrypted` flag saying whether that room is encrypted.
#
# `management_room_auto_create` and `management_rooms` are mutually exclusive:
#
# - `management_room_auto_create: false` requires at least one entry in `management_rooms`. You create the room yourself, and invite the bot after the first run.
# - `management_room_auto_create: true` requires `management_rooms` to be empty. The playbook creates the room, invites the bot's initial managers and gives them the power level needed to command the bot. The room's encryption follows `matrix_bot_meowlnir_config_encryption_enable`.
#
# One optional field is also recognized:
#
# - `initial_managers`: the users invited to a management room the playbook creates for this bot. Omit it to use `matrix_bot_meowlnir_initial_managers`. An explicitly empty list means nobody, which is an error for a bot relying on `management_room_auto_create`.
#
# Example:
# matrix_bot_meowlnir_bots_custom:
# - username: meowlnir_bot
# displayname: Meowlnir
# avatar_url: ""
# management_room_auto_create: false
# management_rooms:
# - id: "!qporfwt:example.com"
# encrypted: false
matrix_bot_meowlnir_bots: "{{ matrix_bot_meowlnir_bots_auto + matrix_bot_meowlnir_bots_custom }}"
matrix_bot_meowlnir_bots_auto: []
matrix_bot_meowlnir_bots_custom: []

# The users invited to management rooms that the playbook creates, and given the power level required to command the bot there.
# Full Matrix user IDs (`@alice:example.com`).
#
# Only consulted by bots with `management_room_auto_create: true`, and only as the default for those which do not carry their own `initial_managers` list.
# The playbook points this at `matrix_admin` by default.
matrix_bot_meowlnir_initial_managers: []

# Controls whether the bot list above is authoritative for this Meowlnir instance.
#
# When enabled, bots and management rooms which are registered with Meowlnir but no longer declared are removed, so that the list describes the whole installation, not merely additions to it.
#
# Note that removing a bot only makes Meowlnir forget it.
# The bot's Matrix user is not deactivated and stays in the rooms it had joined, so clean that up separately if you want it gone for good.
matrix_bot_meowlnir_bots_pruning_enabled: true

# Controls whether pruning also happens when no bots are declared at all.
#
# Disabled by default, because an empty list is far more often a mistake (a commented-out block, an unset variable) than a genuine instruction to remove every bot.
# With this off, the playbook refuses that particular run.
#
# Only relevant when `matrix_bot_meowlnir_bots_pruning_enabled` is enabled.
matrix_bot_meowlnir_bots_pruning_on_empty_roster_enabled: false

# How long to wait after starting the Meowlnir service and before creating bots.
# Only waited on when the service was not already running.
matrix_bot_meowlnir_bots_start_wait_time_seconds: 15

# The name and topic given to management rooms that the playbook creates.
# Only used by bots with `management_room_auto_create` enabled.
matrix_bot_meowlnir_management_room_name: Meowlnir management room
matrix_bot_meowlnir_management_room_topic: >-
Moderation commands for this Meowlnir bot are sent here.

# Regular expressions matching the users this appservice owns.
#
# Defined here, because they are used both in the appservice registration file below and when telling other components (Ketesa, for one) which users are appservice-managed.
# Writing them twice invites the two from drifting apart.
#
# The character class covers what the Matrix specification allows in a user ID localpart.
matrix_bot_meowlnir_user_regex: "^@{{ matrix_bot_meowlnir_user_prefix | regex_escape }}[a-zA-Z0-9._=/+-]+:{{ matrix_bot_meowlnir_config_homeserver_domain | regex_escape }}$"
matrix_bot_meowlnir_appservice_sender_regex: "^@{{ matrix_bot_meowlnir_appservice_sender_localpart | regex_escape }}:{{ matrix_bot_meowlnir_config_homeserver_domain | regex_escape }}$"

# Tokens shared between Meowlnir and the homeserver.
# Set these to strong, unique values.
# The playbook derives them from `matrix_homeserver_generic_secret_key` by default.
matrix_bot_meowlnir_appservice_token: ''
matrix_bot_meowlnir_homeserver_token: ''

matrix_bot_meowlnir_registration_yaml: |
id: {{ matrix_bot_meowlnir_appservice_id | to_json }}
as_token: {{ matrix_bot_meowlnir_appservice_token | to_json }}
hs_token: {{ matrix_bot_meowlnir_homeserver_token | to_json }}
url: {{ matrix_bot_meowlnir_appservice_url | to_json }}
sender_localpart: {{ matrix_bot_meowlnir_appservice_sender_localpart | to_json }}
rate_limited: false
namespaces:
users:
- exclusive: true
regex: {{ matrix_bot_meowlnir_user_regex | to_json }}
- exclusive: true
regex: {{ matrix_bot_meowlnir_appservice_sender_regex | to_json }}
{% if matrix_bot_meowlnir_config_encryption_enable %}
de.sorunome.msc2409.push_ephemeral: true
push_ephemeral: true
receive_ephemeral: true
org.matrix.msc3202: true
io.element.msc4190: true
{% endif %}

matrix_bot_meowlnir_registration: "{{ matrix_bot_meowlnir_registration_yaml | from_yaml }}"


########################################################################
# #
# Configuration: homeserver #
# #
########################################################################

# The address that Meowlnir can use to connect to the homeserver (client-server API).
matrix_bot_meowlnir_config_homeserver_address: ''

# The server name (base domain) of the homeserver.
matrix_bot_meowlnir_config_homeserver_domain: ''


########################################################################
# #
# Configuration: meowlnir #
# #
########################################################################

# The hostname and port that Meowlnir listens on, inside the container.
matrix_bot_meowlnir_config_meowlnir_hostname: 0.0.0.0
matrix_bot_meowlnir_config_meowlnir_port: 29339

# The address that the homeserver can use to connect to Meowlnir.
matrix_bot_meowlnir_appservice_url: "http://matrix-bot-meowlnir:{{ matrix_bot_meowlnir_config_meowlnir_port }}"

# Secret for the management API (`/_meowlnir/v1/...`), which is how bots and management rooms get created.
# Set to `disable` to turn the API off entirely.
# The playbook derives a value from `matrix_homeserver_generic_secret_key` by default.
matrix_bot_meowlnir_config_meowlnir_management_secret: ''

# Secret for the `/_meowlnir/data` API.
# The playbook derives a value from `matrix_homeserver_generic_secret_key` by default.
matrix_bot_meowlnir_config_meowlnir_data_secret: ''

# Whether the `/_meowlnir/mxauth` APIs are enabled, which allow querying certain data using any Matrix account.
# Needed by the (not yet released) management web interface.
matrix_bot_meowlnir_config_meowlnir_federation_auth: false

# When enabled, Meowlnir does everything except take actual moderation actions.
# Useful for trialling Meowlnir alongside an existing moderation bot.
matrix_bot_meowlnir_config_meowlnir_dry_run: false

# Whether all bots should be considered untrusted, which enables additional checks (e.g. preventing subscription to a policy list without being in the room).
matrix_bot_meowlnir_config_meowlnir_untrusted: false

# The ID of the management room that handles requests to the Matrix report API.
# Requires `matrix_bot_meowlnir_config_reporting_enabled` to be enabled to be of use.
matrix_bot_meowlnir_config_meowlnir_report_room: ''

# The ID of the management room in charge of deleting rooms from the server.
# Room bans are not processed in other management rooms.
matrix_bot_meowlnir_config_meowlnir_room_ban_room: ''

# Whether Meowlnir should load all room IDs from the Synapse database on startup.
# Requires `matrix_bot_meowlnir_synapse_database_uri` to be set.
matrix_bot_meowlnir_config_meowlnir_load_all_room_hashes: "{{ matrix_bot_meowlnir_synapse_database_uri != '' }}"

# Entities (user IDs or server names) whose policies are ignored entirely.
# A blunt safety net against overly-wide policies from subscribed lists.
#
# Meowlnir always ignores policies matching its own homeserver domain, so there is no need to list it here.
matrix_bot_meowlnir_config_meowlnir_hacky_rule_filter: "{{ matrix_bot_meowlnir_config_meowlnir_hacky_rule_filter_auto + matrix_bot_meowlnir_config_meowlnir_hacky_rule_filter_custom }}"
matrix_bot_meowlnir_config_meowlnir_hacky_rule_filter_auto: []
matrix_bot_meowlnir_config_meowlnir_hacky_rule_filter_custom: []

# Glob patterns matched against policy reasons.
# When a policy matches, all messages from the banned target are redacted automatically.
# The reason `spam` is implicit.
matrix_bot_meowlnir_config_meowlnir_hacky_redact_patterns: "{{ matrix_bot_meowlnir_config_meowlnir_hacky_redact_patterns_default + matrix_bot_meowlnir_config_meowlnir_hacky_redact_patterns_auto + matrix_bot_meowlnir_config_meowlnir_hacky_redact_patterns_custom }}"
matrix_bot_meowlnir_config_meowlnir_hacky_redact_patterns_default:
- spam
matrix_bot_meowlnir_config_meowlnir_hacky_redact_patterns_auto: []
matrix_bot_meowlnir_config_meowlnir_hacky_redact_patterns_custom: []

# Custom Synapse admin access tokens, per bot user ID.
#
# A few features (suspending or deactivating users, deleting rooms during a takedown) call Synapse's admin API, which requires the caller to be a server admin.
# Bots are not server admins, so those calls fail with `M_FORBIDDEN` until a token is supplied here. Nothing else is affected.
#
# When left empty, each bot falls back to the appservice token. That is fine for the client-server API, but carries no admin rights.
#
# The key is the bot the token is used for; the token itself belongs to an administrator account.
# Under Matrix Authentication Service it has to be issued by MAS, since Synapse no longer decides who is an admin. See the role's documentation page.
#
# Example:
# matrix_bot_meowlnir_config_meowlnir_admin_tokens:
# "@meowlnir_bot:example.com": "ADMIN_TOKEN_HERE"
matrix_bot_meowlnir_config_meowlnir_admin_tokens: {}


########################################################################
# #
# Configuration: meowlnir4all (provisioning bots for other people) #
# #
########################################################################

# None of this is relevant unless you offer moderation bots to other users.

# The management room ID that is allowed to use the `!provision` command.
matrix_bot_meowlnir_config_meowlnir4all_admin_room: ''

# The user ID template for newly provisioned bots.
matrix_bot_meowlnir_config_meowlnir4all_localpart_template: "{{ matrix_bot_meowlnir_user_prefix }}{{ '{{ uuidgen }}' }}"

matrix_bot_meowlnir_config_meowlnir4all_displayname: Administrator
matrix_bot_meowlnir_config_meowlnir4all_avatar_url: ''
matrix_bot_meowlnir_config_meowlnir4all_room_name: Meowlnir Management Room

# The policy lists that newly provisioned bots subscribe to by default.
#
# Note that the order of this list is meaningful: when several lists carry a policy for the same entity, the first match wins.
# Put your own list first if you want to be able to override policies from community lists with `!add-unban`.
matrix_bot_meowlnir_config_meowlnir4all_default_watched_lists: "{{ matrix_bot_meowlnir_config_meowlnir4all_default_watched_lists_auto + matrix_bot_meowlnir_config_meowlnir4all_default_watched_lists_custom }}"
matrix_bot_meowlnir_config_meowlnir4all_default_watched_lists_auto: []
matrix_bot_meowlnir_config_meowlnir4all_default_watched_lists_custom: []


########################################################################
# #
# Configuration: antispam (synapse-http-antispam integration) #
# #
########################################################################

# Certain Meowlnir features (blocking invites and joins before they happen) require the synapse-http-antispam module: https://github.com/maunium/synapse-http-antispam
#
# The module has a single consumer, so this cannot be enabled at the same time as `matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled`.
matrix_bot_meowlnir_synapse_http_antispam_enabled: false

# Secret shared between Meowlnir and the synapse-http-antispam module.
# The playbook derives a value from `matrix_homeserver_generic_secret_key` by default.
matrix_bot_meowlnir_config_antispam_secret: ''

# Whether Meowlnir should check local invites for spam too.
matrix_bot_meowlnir_config_antispam_filter_local_invites: false

# When set, Meowlnir uses this token to reject pending invites from users who get banned.
# This should be an appservice token with access to all local users — the double-puppeting appservice token works well here.
matrix_bot_meowlnir_config_antispam_auto_reject_invites_token: ''

# Whether the management room receives a notice about blocked invites.
matrix_bot_meowlnir_config_antispam_notify_management_room: false

# Inviting any of the users listed here is blocked by default, unless separately allowed with the `!allow-invite` command.
matrix_bot_meowlnir_config_antispam_block_invites_to: "{{ matrix_bot_meowlnir_config_antispam_block_invites_to_auto + matrix_bot_meowlnir_config_antispam_block_invites_to_custom }}"
matrix_bot_meowlnir_config_antispam_block_invites_to_auto: []
matrix_bot_meowlnir_config_antispam_block_invites_to_custom: []

# The ID of the management room whose policies the synapse-http-antispam module consults.
#
# Meowlnir can host several bots, each with its own management room, so the module needs to be told which one to ask.
# Required when `matrix_bot_meowlnir_synapse_http_antispam_enabled` is on.
matrix_bot_meowlnir_synapse_http_antispam_management_room_id: ''

# The URL that the synapse-http-antispam module targets in Meowlnir.
#
# Meowlnir serves the antispam API at `/_meowlnir/antispam/<management room ID>/<callback>`; the module appends the callback name to this base URL.
matrix_bot_meowlnir_synapse_http_antispam_config_base_url: "{{ matrix_bot_meowlnir_appservice_url }}/_meowlnir/antispam/{{ matrix_bot_meowlnir_synapse_http_antispam_management_room_id }}"

matrix_bot_meowlnir_synapse_http_antispam_config_enabled_callbacks:
- user_may_invite
- user_may_join_room

matrix_bot_meowlnir_synapse_http_antispam_config_fail_open:
user_may_invite: true
user_may_join_room: true

matrix_bot_meowlnir_synapse_http_antispam_config_async: {}


########################################################################
# #
# Configuration: policy server (MSC4284) #
# #
########################################################################

# Whether Meowlnir acts as a policy server, letting rooms ask it to vet events before they are accepted.
# Rooms opt in by pointing at this server.
#
# Enabling this exposes `/_matrix/policy/` on the Matrix federation endpoint, so that other servers in the room can reach it.
matrix_bot_meowlnir_policy_server_enabled: false

# Whether to always issue redactions for events blocked by the policy server.
matrix_bot_meowlnir_config_policy_server_always_redact: true

# The Synapse-style private signing key used by the policy server.
#
# Must NOT be left as the literal `generate`:
# Meowlnir re-runs its config upgrader on every start, so `generate` would mint a fresh key on each restart.
# The playbook derives a stable key from `matrix_homeserver_generic_secret_key` by default.
matrix_bot_meowlnir_config_policy_server_signing_key: ''


########################################################################
# #
# Configuration: reporting #
# #
########################################################################

# Meowlnir can intercept the report API endpoints of the client-server API, so that abuse reports land in a management room.
# This requires integration with the reverse proxy in front of the homeserver, which the playbook sets up for you when using Traefik.
#
# Also set `matrix_bot_meowlnir_config_meowlnir_report_room` to the management room that should receive the reports.
matrix_bot_meowlnir_config_reporting_enabled: false


########################################################################
# #
# Configuration: encryption #
# #
########################################################################

# Whether Meowlnir bots support end-to-end encryption.
#
# This requires MSC3202, MSC4190 and MSC4203 support on the homeserver, plus `experimental_features.msc2409_to_device_messages_enabled`.
# On Synapse, the playbook turns these on for you when this is enabled.
matrix_bot_meowlnir_config_encryption_enable: false

# Pickle key used for encrypting encryption keys in the crypto store.
#
# A fixed constant, like the mautrix bridge roles use, and for the same reason: the crypto store can't be read with a different key than it was written with, so a value that may change would cost the bots their encryption sessions.
#
# When migrating a Meowlnir installation that this playbook did not set up, set this to the `pickle_key` from its old configuration file.
matrix_bot_meowlnir_config_encryption_pickle_key: go.mau.fi/meowlnir


########################################################################
# #
# Configuration: databases #
# #
########################################################################

matrix_bot_meowlnir_database_engine: postgres

matrix_bot_meowlnir_database_username: matrix_bot_meowlnir
matrix_bot_meowlnir_database_password: ''
matrix_bot_meowlnir_database_hostname: ''
matrix_bot_meowlnir_database_port: 5432
matrix_bot_meowlnir_database_name: matrix_bot_meowlnir
matrix_bot_meowlnir_database_sslmode: disable

matrix_bot_meowlnir_database_connection_string: "postgresql://{{ matrix_bot_meowlnir_database_username }}:{{ matrix_bot_meowlnir_database_password }}@{{ matrix_bot_meowlnir_database_hostname }}:{{ matrix_bot_meowlnir_database_port }}/{{ matrix_bot_meowlnir_database_name }}?sslmode={{ matrix_bot_meowlnir_database_sslmode }}"

matrix_bot_meowlnir_config_database_max_open_conns: 20
matrix_bot_meowlnir_config_database_max_idle_conns: 2
matrix_bot_meowlnir_config_database_max_conn_idle_time: ''
matrix_bot_meowlnir_config_database_max_conn_lifetime: ''

# Access to the Synapse database.
# Optional: it powers room-hash lookups used by the room takedown features.
# Leave disabled to run without it.
#
# Upstream expects a user with read-only (SELECT) permissions.
# The playbook does not create one, so enabling the integration below hands Meowlnir the same credentials Synapse itself uses — which grant write access too.
# Enable it only if you are comfortable with that; otherwise set `matrix_bot_meowlnir_synapse_database_uri` yourself, pointing at a read-only user you have created.
matrix_bot_meowlnir_synapse_database_integration_enabled: false

# Only Postgres is supported.
# Example:
# matrix_bot_meowlnir_synapse_database_uri: postgresql://user:password@matrix-postgres:5432/synapse?sslmode=disable
matrix_bot_meowlnir_synapse_database_uri: ''

matrix_bot_meowlnir_config_synapse_db_max_open_conns: 2
matrix_bot_meowlnir_config_synapse_db_max_idle_conns: 1
matrix_bot_meowlnir_config_synapse_db_max_conn_idle_time: ''
matrix_bot_meowlnir_config_synapse_db_max_conn_lifetime: ''


########################################################################
# #
# Configuration: logging #
# #
########################################################################

matrix_bot_meowlnir_config_logging_min_level: info

matrix_bot_meowlnir_config_logging_writers:
- type: stdout
format: pretty-colored


########################################################################
# #
# Traefik labels #
# #
########################################################################

# Controls whether labels will be added that route certain paths to Meowlnir.
# Regardless of whether this is enabled, it may or may not take effect due to the value of other variables.
# See `matrix_synapse_container_labels_traefik_enabled` or `matrix_synapse_container_labels_matrix_related_labels_enabled`.
matrix_bot_meowlnir_container_labels_traefik_enabled: "{{ matrix_bot_meowlnir_config_reporting_enabled or matrix_bot_meowlnir_policy_server_enabled }}"
matrix_bot_meowlnir_container_labels_traefik_docker_network: "{{ matrix_bot_meowlnir_container_network }}"

# Reports (/_matrix/client/../rooms/../report and /_matrix/client/../users/../report)
#
# Only `v3` is matched on purpose:
# Meowlnir does not serve the legacy `r0` paths, so routing those here would break reporting for old clients.
matrix_bot_meowlnir_container_labels_reporting_traefik_hostname: "{{ matrix_synapse_container_labels_traefik_hostname }}"
matrix_bot_meowlnir_container_labels_reporting_traefik_path_regexp: "^/_matrix/client/v3/(rooms|users)/[^/]+/report(/.*)?$"
matrix_bot_meowlnir_container_labels_reporting_traefik_rule: "Host(`{{ matrix_bot_meowlnir_container_labels_reporting_traefik_hostname }}`) && PathRegexp(`{{ matrix_bot_meowlnir_container_labels_reporting_traefik_path_regexp }}`)"
matrix_bot_meowlnir_container_labels_reporting_traefik_priority: 0
matrix_bot_meowlnir_container_labels_reporting_traefik_entrypoints: "{{ matrix_synapse_container_labels_traefik_entrypoints }}"
matrix_bot_meowlnir_container_labels_reporting_traefik_tls: "{{ matrix_bot_meowlnir_container_labels_reporting_traefik_entrypoints != 'web' }}"
matrix_bot_meowlnir_container_labels_reporting_traefik_tls_certResolver: "{{ matrix_synapse_container_labels_traefik_tls_certResolver }}" # noqa var-naming

# Policy server (/_matrix/policy), served on the federation endpoint
matrix_bot_meowlnir_container_labels_policy_server_traefik_hostname: "{{ matrix_synapse_container_labels_public_federation_api_traefik_hostname }}"
matrix_bot_meowlnir_container_labels_policy_server_traefik_path_prefix: /_matrix/policy
matrix_bot_meowlnir_container_labels_policy_server_traefik_rule: "Host(`{{ matrix_bot_meowlnir_container_labels_policy_server_traefik_hostname }}`) && PathPrefix(`{{ matrix_bot_meowlnir_container_labels_policy_server_traefik_path_prefix }}`)"
matrix_bot_meowlnir_container_labels_policy_server_traefik_priority: 0
matrix_bot_meowlnir_container_labels_policy_server_traefik_entrypoints: "{{ matrix_synapse_container_labels_public_federation_api_traefik_entrypoints }}"
matrix_bot_meowlnir_container_labels_policy_server_traefik_tls: "{{ matrix_bot_meowlnir_container_labels_policy_server_traefik_entrypoints != 'web' }}"
matrix_bot_meowlnir_container_labels_policy_server_traefik_tls_certResolver: "{{ matrix_synapse_container_labels_traefik_tls_certResolver }}" # noqa var-naming

# matrix_bot_meowlnir_container_labels_traefik_labels_additional_labels contains a multiline string with additional labels to add to the container label file.
# See `../templates/labels.j2` for details.
#
# Example:
# matrix_bot_meowlnir_container_labels_traefik_labels_additional_labels: |
# my.label=1
# another.label="here"
matrix_bot_meowlnir_container_labels_traefik_labels_additional_labels: ''


########################################################################
# #
# Configuration assembly #
# #
########################################################################

# Default configuration template which covers the generic use case.
# You can customize it by controlling the various variables inside it.
#
# For a more advanced customization, you can extend the default (see `matrix_bot_meowlnir_configuration_extension_yaml`) or completely replace this variable with your own template.
matrix_bot_meowlnir_configuration_yaml: "{{ lookup('template', 'templates/config.yaml.j2') }}"

matrix_bot_meowlnir_configuration_extension_yaml: |
# Your custom YAML configuration goes here.
# This configuration extends the default starting configuration (`matrix_bot_meowlnir_configuration_yaml`).
#
# You can override individual variables from the default configuration, or introduce new ones.
#
# If you need something more special, you can take full control by completely redefining `matrix_bot_meowlnir_configuration_yaml`.

matrix_bot_meowlnir_configuration_extension: "{{ matrix_bot_meowlnir_configuration_extension_yaml | from_yaml if matrix_bot_meowlnir_configuration_extension_yaml | from_yaml is mapping else {} }}"

# Holds the final configuration (a combination of the default and its extension).
# You most likely don't need to touch this variable.
# Instead, see `matrix_bot_meowlnir_configuration_yaml`.
matrix_bot_meowlnir_configuration: "{{ matrix_bot_meowlnir_configuration_yaml | from_yaml | combine(matrix_bot_meowlnir_configuration_extension, recursive=True) }}"

# matrix_bot_meowlnir_restart_necessary controls whether the service will be restarted (when true) or merely started (when false) by the systemd service manager role (when conditional restart is enabled).
#
# This value is automatically computed during installation based on whether any configuration files, the systemd service file, or the container image changed.
# The default of `false` means "no restart needed" — appropriate when the role's installation tasks haven't run (e.g., due to --tags skipping them).
matrix_bot_meowlnir_restart_necessary: false

+ 36
- 0
roles/custom/matrix-bot-meowlnir/tasks/main.yml Vedi File

@@ -0,0 +1,36 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

- tags:
- setup-all
- setup-bot-meowlnir
- install-all
- install-bot-meowlnir
block:
- when: matrix_bot_meowlnir_enabled | bool
ansible.builtin.include_tasks: "{{ role_path }}/tasks/validate_config.yml"

- when: matrix_bot_meowlnir_enabled | bool
ansible.builtin.include_tasks: "{{ role_path }}/tasks/setup_install.yml"

- tags:
- setup-all
- setup-bot-meowlnir
block:
- when: not matrix_bot_meowlnir_enabled | bool
ansible.builtin.include_tasks: "{{ role_path }}/tasks/setup_uninstall.yml"

# Creating bots talks to Meowlnir's management API, which means starting the service and writing to its database.
# Like the matrix-user-creator role, this intentionally stays off the `setup-all` tag, so that an initial installation does not populate a database that is about to be replaced by an import.
- tags:
- ensure-matrix-users-created
- ensure-users-created
block:
# Runs when there are bots to create, but also when there are none and pruning is on: an emptied-out bot list is exactly the case the pruning safeguards need to see.
- when: >-
matrix_bot_meowlnir_enabled | bool
and (matrix_bot_meowlnir_bots | length > 0 or matrix_bot_meowlnir_bots_pruning_enabled | bool)
ansible.builtin.include_tasks: "{{ role_path }}/tasks/setup_bots.yml"

+ 35
- 0
roles/custom/matrix-bot-meowlnir/tasks/setup_bots.yml Vedi File

@@ -0,0 +1,35 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

# Validation lives in validate_config.yml so that it also runs on regular setup/install runs, catching mistakes long before anyone reaches for the `ensure-matrix-users-created` tag.
# It is repeated here because this tag can be run on its own, and creating a bot outside the appservice's user namespace fails in ways that are hard to trace back.
- name: Validate matrix-bot-meowlnir configuration before creating bots
ansible.builtin.include_tasks: "{{ role_path }}/tasks/validate_config.yml"

- name: Ensure matrix-bot-meowlnir is started before creating bots
ansible.builtin.service:
name: matrix-bot-meowlnir.service
state: started
daemon_reload: true
register: matrix_bot_meowlnir_start_result

- name: Wait a while, so that Meowlnir can manage to start before creating bots
ansible.builtin.pause:
seconds: "{{ matrix_bot_meowlnir_bots_start_wait_time_seconds }}"
when: matrix_bot_meowlnir_start_result.changed | bool

- name: Read which bots Meowlnir already has
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/read_meowlnir_state.yml"

- name: Ensure Meowlnir bots created
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/ensure_bot_created.yml"
with_items: "{{ matrix_bot_meowlnir_bots }}"
loop_control:
loop_var: bot

- name: Remove Meowlnir bots which are no longer declared
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/prune_bots.yml"
when: matrix_bot_meowlnir_bots_pruning_enabled | bool

+ 127
- 0
roles/custom/matrix-bot-meowlnir/tasks/setup_install.yml Vedi File

@@ -0,0 +1,127 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

- name: Ensure matrix-bot-meowlnir paths exist
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
mode: '0750'
owner: "{{ matrix_user_name }}"
group: "{{ matrix_group_name }}"
with_items:
- {path: "{{ matrix_bot_meowlnir_base_path }}", when: true}
- {path: "{{ matrix_bot_meowlnir_config_path }}", when: true}
- {path: "{{ matrix_bot_meowlnir_data_path }}", when: true}
- {path: "{{ matrix_bot_meowlnir_bin_path }}", when: true}
- {path: "{{ matrix_bot_meowlnir_container_src_files_path }}", when: "{{ matrix_bot_meowlnir_container_image_self_build }}"}
when: "item.when | bool"

- name: Ensure Meowlnir Docker image is pulled
community.docker.docker_image_pull:
name: "{{ matrix_bot_meowlnir_container_image }}"
pull: always
when: "not matrix_bot_meowlnir_container_image_self_build | bool"
register: matrix_bot_meowlnir_container_image_pull_result
retries: "{{ devture_playbook_help_container_retries_count }}"
delay: "{{ devture_playbook_help_container_retries_delay }}"
until: matrix_bot_meowlnir_container_image_pull_result is not failed

# A checkout owned by a different user (a uid change, an earlier clone by another user, etc.) would make the git task below fail on ownership or permissions.
- name: Ensure Meowlnir repository ownership is correct on self-build
ansible.builtin.file:
path: "{{ matrix_bot_meowlnir_container_src_files_path }}"
state: directory
owner: "{{ matrix_user_name }}"
group: "{{ matrix_group_name }}"
recurse: true
when: "matrix_bot_meowlnir_container_image_self_build | bool"

- name: Ensure Meowlnir repository is present on self-build
ansible.builtin.git:
repo: "{{ matrix_bot_meowlnir_container_image_self_build_repo }}"
dest: "{{ matrix_bot_meowlnir_container_src_files_path }}"
version: "{{ matrix_bot_meowlnir_container_image.split(':')[1] }}"
force: "yes"
become: true
become_user: "{{ matrix_user_name }}"
register: matrix_bot_meowlnir_git_pull_results
when: "matrix_bot_meowlnir_container_image_self_build | bool"

- name: Ensure Meowlnir Docker image is built
community.docker.docker_image_build:
name: "{{ matrix_bot_meowlnir_container_image }}"
dockerfile: Dockerfile.ci
path: "{{ matrix_bot_meowlnir_container_src_files_path }}"
pull: true
rebuild: "{{ 'always' if matrix_bot_meowlnir_git_pull_results.changed | bool else 'never' }}"
when: "matrix_bot_meowlnir_container_image_self_build | bool"
register: matrix_bot_meowlnir_container_image_build_result

- name: Ensure matrix-bot-meowlnir config installed
ansible.builtin.copy:
content: "{{ matrix_bot_meowlnir_configuration | to_nice_yaml(indent=2, width=999999) }}"
dest: "{{ matrix_bot_meowlnir_config_path }}/config.yaml"
mode: '0640'
owner: "{{ matrix_user_name }}"
group: "{{ matrix_group_name }}"
register: matrix_bot_meowlnir_config_result

- name: Ensure matrix-bot-meowlnir registration.yaml installed
ansible.builtin.copy:
content: "{{ matrix_bot_meowlnir_registration | to_nice_yaml(indent=2, width=999999) }}"
dest: "{{ matrix_bot_meowlnir_config_path }}/registration.yaml"
mode: '0640'
owner: "{{ matrix_user_name }}"
group: "{{ matrix_group_name }}"
register: matrix_bot_meowlnir_registration_result

- name: Ensure matrix-bot-meowlnir scripts installed
ansible.builtin.template:
src: "{{ role_path }}/templates/bin/{{ item }}.j2"
dest: "{{ matrix_bot_meowlnir_bin_path }}/{{ item }}"
mode: '0750'
owner: "{{ matrix_user_name }}"
group: "{{ matrix_group_name }}"
with_items:
- meowlnir-api
- meowlnir-bots
- meowlnir-create-management-room

- name: Ensure matrix-bot-meowlnir container network is created
when: matrix_bot_meowlnir_container_network != 'host'
community.general.docker_network:
enable_ipv6: "{{ devture_systemd_docker_base_ipv6_enabled }}"
name: "{{ matrix_bot_meowlnir_container_network }}"
driver: bridge
driver_options: "{{ devture_systemd_docker_base_container_networks_driver_options }}"

- name: Ensure matrix-bot-meowlnir container labels installed
ansible.builtin.template:
src: "{{ role_path }}/templates/labels.j2"
dest: "{{ matrix_bot_meowlnir_base_path }}/labels"
mode: '0640'
owner: "{{ matrix_user_name }}"
group: "{{ matrix_group_name }}"
register: matrix_bot_meowlnir_labels_result

- name: Ensure matrix-bot-meowlnir.service installed
ansible.builtin.template:
src: "{{ role_path }}/templates/systemd/matrix-bot-meowlnir.service.j2"
dest: "{{ devture_systemd_docker_base_systemd_path }}/matrix-bot-meowlnir.service"
mode: '0644'
register: matrix_bot_meowlnir_systemd_service_result

- name: Determine whether Meowlnir needs a restart
ansible.builtin.set_fact:
matrix_bot_meowlnir_restart_necessary: >-
{{
matrix_bot_meowlnir_config_result.changed | default(false)
or matrix_bot_meowlnir_registration_result.changed | default(false)
or matrix_bot_meowlnir_labels_result.changed | default(false)
or matrix_bot_meowlnir_systemd_service_result.changed | default(false)
or matrix_bot_meowlnir_container_image_pull_result.changed | default(false)
or matrix_bot_meowlnir_container_image_build_result.changed | default(false)
}}

+ 29
- 0
roles/custom/matrix-bot-meowlnir/tasks/setup_uninstall.yml Vedi File

@@ -0,0 +1,29 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

- name: Check existence of matrix-bot-meowlnir service
ansible.builtin.stat:
path: "{{ devture_systemd_docker_base_systemd_path }}/matrix-bot-meowlnir.service"
register: matrix_bot_meowlnir_service_stat

- when: matrix_bot_meowlnir_service_stat.stat.exists | bool
block:
- name: Ensure matrix-bot-meowlnir is stopped
ansible.builtin.service:
name: matrix-bot-meowlnir
state: stopped
enabled: false
daemon_reload: true

- name: Ensure matrix-bot-meowlnir.service doesn't exist
ansible.builtin.file:
path: "{{ devture_systemd_docker_base_systemd_path }}/matrix-bot-meowlnir.service"
state: absent

- name: Ensure matrix-bot-meowlnir paths don't exist
ansible.builtin.file:
path: "{{ matrix_bot_meowlnir_base_path }}"
state: absent

+ 81
- 0
roles/custom/matrix-bot-meowlnir/tasks/util/ensure_bot_created.yml Vedi File

@@ -0,0 +1,81 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

# The management API endpoints are idempotent PUTs, so re-running is safe.

# Meowlnir's own record of this bot, as fetched before the loop started.
# Empty when the bot does not exist yet.
- name: Look up what Meowlnir already knows about the bot - {{ bot.username | quote }}
ansible.builtin.set_fact:
matrix_bot_meowlnir_bot_live: >-
{{
(matrix_bot_meowlnir_live_bots | selectattr('username', 'equalto', bot.username) | list | first)
| default({}, true)
}}

# `PUT /_meowlnir/v1/bot/…` answers 200 whether it created the bot, updated it, or did nothing at all, so the response cannot tell us whether anything changed.
# Comparing against what Meowlnir already holds lets the request be skipped when it would be a no-op, which both avoids pointless calls and lets the task report an honest changed state.
#
# Note that this trusts Meowlnir's own record.
# Changing a bot's profile directly through a Matrix client goes behind its back and will not be corrected here.
- name: Determine whether the Meowlnir bot needs creating or updating - {{ bot.username | quote }}
ansible.builtin.set_fact:
matrix_bot_meowlnir_bot_needs_update: >-
{{
not matrix_bot_meowlnir_bot_live
or (matrix_bot_meowlnir_bot_live.displayname | default('', true)) != bot.displayname
or (matrix_bot_meowlnir_bot_live.avatar_url | default('', true)) != bot.avatar_url
}}
matrix_bot_meowlnir_bot_body: >-
{{
{
'displayname': bot.displayname,
'avatar_url': bot.avatar_url,
}
}}

- name: Ensure Meowlnir bot exists and is up to date - {{ bot.username | quote }}
when: matrix_bot_meowlnir_bot_needs_update | bool
ansible.builtin.command:
cmd: >-
{{ matrix_bot_meowlnir_bin_path }}/meowlnir-api
PUT /_meowlnir/v1/bot/{{ bot.username }}
{{ matrix_bot_meowlnir_bot_body | to_json | quote }}
register: matrix_bot_meowlnir_bot_create_result
# The task only runs when something needs changing, so a successful call is reported as a change.
# Deriving this from the status keeps a failed call from also claiming to have changed anything.
# Meowlnir answers 200 even when it could not apply the displayname or avatar (it only logs that), so this is what was asked for, not proof of what landed. A later run retries, since its stored record is unchanged.
changed_when: "matrix_bot_meowlnir_bot_create_result.stdout_lines | default([]) | last | default('') == '200'"
failed_when: false

- name: Fail if the Meowlnir bot could not be created - {{ bot.username | quote }}
ansible.builtin.fail:
msg: >-
Creating the Meowlnir bot `{{ bot.username }}` failed.
Meowlnir said: {{ matrix_bot_meowlnir_bot_create_result.stdout | default('') | trim }}
{{ matrix_bot_meowlnir_bot_create_result.stderr | default('') | trim }}
when: >-
matrix_bot_meowlnir_bot_needs_update | bool
and (
matrix_bot_meowlnir_bot_create_result.rc | default(1) != 0
or (matrix_bot_meowlnir_bot_create_result.stdout_lines | default([]) | length == 0)
or (matrix_bot_meowlnir_bot_create_result.stdout_lines | last != '200')
)

- name: Determine which management rooms Meowlnir already has for this bot - {{ bot.username | quote }}
ansible.builtin.set_fact:
matrix_bot_meowlnir_bot_live_rooms: >-
{{ matrix_bot_meowlnir_bot_live.management_rooms | default([], true) | map(attribute='room_id') | list }}

- name: Ensure Meowlnir management room created - {{ bot.username | quote }}
when: "bot.management_room_auto_create | bool and matrix_bot_meowlnir_bot_live_rooms | length == 0"
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/ensure_management_room_created.yml"

- name: Ensure declared Meowlnir management rooms registered - {{ bot.username | quote }}
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/ensure_management_room_registered.yml"
with_items: "{{ bot.management_rooms }}"
loop_control:
loop_var: management_room

+ 57
- 0
roles/custom/matrix-bot-meowlnir/tasks/util/ensure_management_room_created.yml Vedi File

@@ -0,0 +1,57 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

# Only reached for bots with `management_room_auto_create` which do not have a management room yet.
# Meowlnir's own record of the bot's rooms is what makes this idempotent, so no state is kept on the Ansible side.

# Recomputed here, because validation runs its own loop over all bots and would leave this holding the last one's value.
- name: Determine the effective initial managers for a Meowlnir bot - {{ bot.username | quote }}
ansible.builtin.set_fact:
matrix_bot_meowlnir_bot_initial_managers: "{{ bot.initial_managers | default(matrix_bot_meowlnir_initial_managers) }}"

- name: Create a Meowlnir management room - {{ bot.username | quote }}
ansible.builtin.command:
cmd: >-
{{ matrix_bot_meowlnir_bin_path }}/meowlnir-create-management-room
{{ bot.username | quote }}
{{ matrix_bot_meowlnir_bot_initial_managers | map('quote') | join(' ') }}
register: matrix_bot_meowlnir_room_creation_result
# The script prints the new room's ID on success, so that is what tells us a room was actually created, not merely attempted.
changed_when: >-
matrix_bot_meowlnir_room_creation_result.rc == 0
and (matrix_bot_meowlnir_room_creation_result.stdout | default('') | trim).startswith('!')
failed_when: false

- name: Fail if the Meowlnir management room could not be created - {{ bot.username | quote }}
ansible.builtin.fail:
msg: >-
Creating a management room for the Meowlnir bot `{{ bot.username }}` failed.
{{ matrix_bot_meowlnir_room_creation_result.stdout | default('') | trim }}
{{ matrix_bot_meowlnir_room_creation_result.stderr | default('') | trim }}
when: >-
matrix_bot_meowlnir_room_creation_result.rc != 0
or not (matrix_bot_meowlnir_room_creation_result.stdout | default('') | trim).startswith('!')

- name: Register the created Meowlnir management room - {{ bot.username | quote }}
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/ensure_management_room_registered.yml"
vars:
management_room:
id: "{{ matrix_bot_meowlnir_room_creation_result.stdout | trim }}"
encrypted: "{{ matrix_bot_meowlnir_config_encryption_enable }}"

- name: Report the created Meowlnir management room - {{ bot.username | quote }}
ansible.builtin.set_fact:
devture_playbook_runtime_messages_list: >-
{{
devture_playbook_runtime_messages_list | default([])
+
[
"Note: created a management room (" ~ (matrix_bot_meowlnir_room_creation_result.stdout | trim) ~
") for the Meowlnir bot " ~ bot.username ~ " and invited " ~
(matrix_bot_meowlnir_bot_initial_managers | join(", ")) ~
" to it. Accept the invitation to start commanding the bot."
]
}}

+ 38
- 0
roles/custom/matrix-bot-meowlnir/tasks/util/ensure_management_room_registered.yml Vedi File

@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

- name: Build request body for Meowlnir management room - {{ management_room.id | quote }}
ansible.builtin.set_fact:
matrix_bot_meowlnir_management_room_body: >-
{{
{
'bot_username': bot.username,
'encrypted': management_room.encrypted | bool,
}
}}

- name: Ensure Meowlnir management room registered - {{ management_room.id | quote }}
ansible.builtin.command:
cmd: >-
{{ matrix_bot_meowlnir_bin_path }}/meowlnir-api
PUT /_meowlnir/v1/management_room/{{ management_room.id }}
{{ matrix_bot_meowlnir_management_room_body | to_json | quote }}
register: matrix_bot_meowlnir_management_room_result
# 201 means the room was newly registered, 200 that it already was.
changed_when: "matrix_bot_meowlnir_management_room_result.stdout_lines | default([]) | last | default('') == '201'"
failed_when: false

- name: Fail if the Meowlnir management room could not be registered - {{ management_room.id | quote }}
ansible.builtin.fail:
msg: >-
Registering `{{ management_room.id }}` as a management room for the Meowlnir bot
`{{ bot.username }}` failed.
Meowlnir said: {{ matrix_bot_meowlnir_management_room_result.stdout | default('') | trim }}
{{ matrix_bot_meowlnir_management_room_result.stderr | default('') | trim }}
when: >-
matrix_bot_meowlnir_management_room_result.rc != 0
or (matrix_bot_meowlnir_management_room_result.stdout_lines | default([]) | length == 0)
or (matrix_bot_meowlnir_management_room_result.stdout_lines | last not in ['200', '201'])

+ 41
- 0
roles/custom/matrix-bot-meowlnir/tasks/util/prune_bot.yml Vedi File

@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

# Only reached for bots which are no longer declared, and only after their management rooms have been de-registered — Meowlnir refuses to delete a bot which is still in use.

- name: Remove Meowlnir bot - {{ live_bot.username | quote }}
ansible.builtin.command:
cmd: >-
{{ matrix_bot_meowlnir_bin_path }}/meowlnir-api
DELETE /_meowlnir/v1/bot/{{ live_bot.username }}
register: matrix_bot_meowlnir_bot_removal_result
changed_when: "matrix_bot_meowlnir_bot_removal_result.stdout_lines | default([]) | last | default('') == '200'"
failed_when: false

- name: Fail if the Meowlnir bot could not be removed - {{ live_bot.username | quote }}
ansible.builtin.fail:
msg: >-
Removing the Meowlnir bot `{{ live_bot.username }}` failed.
Meowlnir said: {{ matrix_bot_meowlnir_bot_removal_result.stdout | default('') | trim }}
{{ matrix_bot_meowlnir_bot_removal_result.stderr | default('') | trim }}
when: >-
matrix_bot_meowlnir_bot_removal_result.rc != 0
or (matrix_bot_meowlnir_bot_removal_result.stdout_lines | default([]) | length == 0)
or (matrix_bot_meowlnir_bot_removal_result.stdout_lines | last not in ['200', '404'])

- name: Report the removed Meowlnir bot - {{ live_bot.username | quote }}
ansible.builtin.set_fact:
devture_playbook_runtime_messages_list: >-
{{
devture_playbook_runtime_messages_list | default([])
+
[
"Note: the Meowlnir bot " ~ live_bot.username ~ " is no longer declared in matrix_bot_meowlnir_bots," ~
" so Meowlnir has been told to forget it. Its Matrix user is not deactivated and remains in the rooms" ~
" it had joined - clean that up separately if you want it gone for good."
]
}}
when: "matrix_bot_meowlnir_bot_removal_result.stdout_lines | default([]) | last | default('') == '200'"

+ 33
- 0
roles/custom/matrix-bot-meowlnir/tasks/util/prune_bot_management_rooms.yml Vedi File

@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

- name: Find the declaration for the Meowlnir bot - {{ live_bot.username | quote }}
ansible.builtin.set_fact:
matrix_bot_meowlnir_declared_bot: >-
{{
(matrix_bot_meowlnir_bots | selectattr('username', 'equalto', live_bot.username) | list | first)
| default({}, true)
}}

# Rooms created by the playbook are not declared anywhere, so bots which auto-create their management room are left alone.
# Bots which are no longer declared at all fall through to the empty declaration, meaning all of their rooms get removed — which is also what has to happen before the bot itself can be deleted.
- name: Determine which management rooms to remove - {{ live_bot.username | quote }}
ansible.builtin.set_fact:
matrix_bot_meowlnir_rooms_to_remove: >-
{{
[]
if (matrix_bot_meowlnir_declared_bot.management_room_auto_create | default(false) | bool)
else (
(live_bot.management_rooms | map(attribute='room_id') | list)
| difference(matrix_bot_meowlnir_declared_bot.management_rooms | default([], true) | map(attribute='id') | list)
)
}}

- name: Remove Meowlnir management rooms which are no longer declared - {{ live_bot.username | quote }}
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/prune_management_room.yml"
with_items: "{{ matrix_bot_meowlnir_rooms_to_remove }}"
loop_control:
loop_var: room_id_to_remove

+ 50
- 0
roles/custom/matrix-bot-meowlnir/tasks/util/prune_bots.yml Vedi File

@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

# Makes the declared bot list authoritative: anything Meowlnir still knows about but which is no longer declared gets removed.
# Meowlnir's own records are re-read first, because the provisioning that just ran will have changed them.

- name: Re-read Meowlnir's bots before pruning
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/read_meowlnir_state.yml"

- name: Determine which Meowlnir bots are no longer declared
ansible.builtin.set_fact:
matrix_bot_meowlnir_undeclared_bots: >-
{{
matrix_bot_meowlnir_live_bots
| rejectattr('username', 'in', matrix_bot_meowlnir_bots | map(attribute='username') | list)
| list
}}

- name: Fail if pruning would remove every Meowlnir bot
ansible.builtin.fail:
msg: >-
No bots are declared in `matrix_bot_meowlnir_bots`, but Meowlnir still has
{{ matrix_bot_meowlnir_undeclared_bots | length }} of them
({{ matrix_bot_meowlnir_undeclared_bots | map(attribute='username') | join(', ') }}).
Removing them would make Meowlnir forget them along with their management rooms, so the
playbook will not act on what is far more often a mistake (a commented-out block, an
unset variable) than an instruction.
If you really do want them all gone, set
`matrix_bot_meowlnir_bots_pruning_on_empty_roster_enabled: true`.
To stop the playbook managing bots at all, set
`matrix_bot_meowlnir_bots_pruning_enabled: false`.
when: >-
matrix_bot_meowlnir_bots | length == 0
and matrix_bot_meowlnir_undeclared_bots | length > 0
and not matrix_bot_meowlnir_bots_pruning_on_empty_roster_enabled | bool

- name: Remove management rooms which are no longer declared
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/prune_bot_management_rooms.yml"
with_items: "{{ matrix_bot_meowlnir_live_bots }}"
loop_control:
loop_var: live_bot

- name: Remove Meowlnir bots which are no longer declared
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/prune_bot.yml"
with_items: "{{ matrix_bot_meowlnir_undeclared_bots }}"
loop_control:
loop_var: live_bot

+ 38
- 0
roles/custom/matrix-bot-meowlnir/tasks/util/prune_management_room.yml Vedi File

@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

- name: Remove Meowlnir management room - {{ room_id_to_remove | quote }}
ansible.builtin.command:
cmd: >-
{{ matrix_bot_meowlnir_bin_path }}/meowlnir-api
DELETE /_meowlnir/v1/management_room/{{ room_id_to_remove }}
register: matrix_bot_meowlnir_room_removal_result
changed_when: "matrix_bot_meowlnir_room_removal_result.stdout_lines | default([]) | last | default('') == '200'"
failed_when: false

- name: Fail if the Meowlnir management room could not be removed - {{ room_id_to_remove | quote }}
ansible.builtin.fail:
msg: >-
De-registering the Meowlnir management room `{{ room_id_to_remove }}` failed.
Meowlnir said: {{ matrix_bot_meowlnir_room_removal_result.stdout | default('') | trim }}
{{ matrix_bot_meowlnir_room_removal_result.stderr | default('') | trim }}
when: >-
matrix_bot_meowlnir_room_removal_result.rc != 0
or (matrix_bot_meowlnir_room_removal_result.stdout_lines | default([]) | length == 0)
or (matrix_bot_meowlnir_room_removal_result.stdout_lines | last not in ['200', '404'])

- name: Report the removed Meowlnir management room - {{ room_id_to_remove | quote }}
ansible.builtin.set_fact:
devture_playbook_runtime_messages_list: >-
{{
devture_playbook_runtime_messages_list | default([])
+
[
"Note: the Meowlnir management room " ~ room_id_to_remove ~ " is no longer declared, so it has been de-registered." ~
" The room itself still exists and the bot is still in it - Meowlnir simply no longer takes commands there."
]
}}
when: "matrix_bot_meowlnir_room_removal_result.stdout_lines | default([]) | last | default('') == '200'"

+ 29
- 0
roles/custom/matrix-bot-meowlnir/tasks/util/read_meowlnir_state.yml Vedi File

@@ -0,0 +1,29 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

# Meowlnir's own database is the source of truth for which bots and management rooms exist, and it can be queried, so the playbook keeps no state of its own about them.

- name: Ask Meowlnir which bots currently exist
ansible.builtin.command:
cmd: "{{ matrix_bot_meowlnir_bin_path }}/meowlnir-api GET /_meowlnir/v1/bots"
register: matrix_bot_meowlnir_state_result
changed_when: false
failed_when: false

- name: Fail if Meowlnir could not be asked which bots exist
ansible.builtin.fail:
msg: >-
Could not read the list of bots from Meowlnir.
Meowlnir said: {{ matrix_bot_meowlnir_state_result.stdout | default('') | trim }}
{{ matrix_bot_meowlnir_state_result.stderr | default('') | trim }}
when: >-
matrix_bot_meowlnir_state_result.rc != 0
or (matrix_bot_meowlnir_state_result.stdout_lines | default([]) | length == 0)
or (matrix_bot_meowlnir_state_result.stdout_lines | last != '200')

- name: Determine which bots and management rooms Meowlnir knows about
ansible.builtin.set_fact:
matrix_bot_meowlnir_live_bots: "{{ (matrix_bot_meowlnir_state_result.stdout_lines[:-1] | join('\n') | from_json).bots | default([], true) }}"

+ 90
- 0
roles/custom/matrix-bot-meowlnir/tasks/util/validate_bot.yml Vedi File

@@ -0,0 +1,90 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

# These fields are required on every entry, so that a bot's setup can be read off the definition without knowing what the playbook would otherwise default to.
#
# The optional `initial_managers` is deliberately not among them: it only overrides an instance-wide default, and demanding it on every bot would defeat that default's purpose.
- name: Fail if a Meowlnir bot definition lacks required fields
ansible.builtin.fail:
msg: >-
A bot definition in `matrix_bot_meowlnir_bots` is missing the `{{ item }}` field.
Required on every entry: {{ matrix_bot_meowlnir_bot_required_fields | join(', ') }}.
Offending definition: {{ bot | to_json }}
when: "item not in bot"
with_items: "{{ matrix_bot_meowlnir_bot_required_fields }}"

- name: Fail if a Meowlnir bot username is empty
ansible.builtin.fail:
msg: "A bot definition in `matrix_bot_meowlnir_bots` has an empty `username`: {{ bot | to_json }}"
when: "not bot.username"

- name: Fail if Meowlnir bot username lacks the required prefix
ansible.builtin.fail:
msg: >-
The Meowlnir bot username `{{ bot.username }}` does not start with
`{{ matrix_bot_meowlnir_user_prefix }}`. Bot users need to fall within the user
namespace declared in Meowlnir's appservice registration file, or the homeserver
will refuse to let Meowlnir operate them.
Either rename the bot, or adjust `matrix_bot_meowlnir_user_prefix`.
when: "not bot.username.startswith(matrix_bot_meowlnir_user_prefix)"

- name: Fail if a Meowlnir bot mixes management room auto-creation with declared rooms
ansible.builtin.fail:
msg: >-
The Meowlnir bot `{{ bot.username }}` has `management_room_auto_create` enabled, but
also declares `management_rooms`. These are mutually exclusive: either let the
playbook create a management room, or declare the rooms yourself.
when: "bot.management_room_auto_create | bool and bot.management_rooms | length > 0"

- name: Fail if a Meowlnir bot has neither auto-created nor declared management rooms
ansible.builtin.fail:
msg: >-
The Meowlnir bot `{{ bot.username }}` declares no `management_rooms` and does not have
`management_room_auto_create` enabled, so there would be no room to command it from.
Either create a room yourself and declare it, or enable `management_room_auto_create`.
when: "not bot.management_room_auto_create | bool and bot.management_rooms | length == 0"

- name: Fail if a Meowlnir bot declares `initial_managers` which is not a list
ansible.builtin.fail:
msg: >-
The Meowlnir bot `{{ bot.username }}` declares an `initial_managers` value which is not
a list. It needs to be a list of full Matrix user IDs (`@alice:example.com`), even when
there is only one.
Offending definition: {{ bot | to_json }}
when: "'initial_managers' in bot and (bot.initial_managers is string or bot.initial_managers is mapping or bot.initial_managers is not iterable)"

# Omitting the key inherits the instance-wide default; declaring it empty means nobody, which is why `default()` is used here without its `boolean` argument.
- name: Determine the effective initial managers for a Meowlnir bot
ansible.builtin.set_fact:
matrix_bot_meowlnir_bot_initial_managers: "{{ bot.initial_managers | default(matrix_bot_meowlnir_initial_managers) }}"

- name: Fail if Meowlnir management room auto-creation lacks an initial manager
ansible.builtin.fail:
msg: >-
The Meowlnir bot `{{ bot.username }}` has `management_room_auto_create` enabled, but no
initial managers are set, so nobody would be able to command the bot in the room that
gets created.
Either give the bot its own `initial_managers` list, or set
`matrix_bot_meowlnir_initial_managers` (or the `matrix_admin` variable it follows by
default), or disable `management_room_auto_create` and declare `management_rooms`
yourself.
when: "bot.management_room_auto_create | bool and matrix_bot_meowlnir_bot_initial_managers | length == 0"

# Nothing downstream catches a bad value: the homeserver accepts invitations for users which do not exist, and on room versions supporting MSC4289 an invitee's standing in the room cannot be revoked afterwards.
# So a typo here is both silent and permanent.
- name: Fail if a Meowlnir initial manager is not a full Matrix user ID
ansible.builtin.fail:
msg: >-
`{{ item }}` is listed as an initial manager for the Meowlnir bot `{{ bot.username }}`,
but it is not a full Matrix user ID (`@alice:example.com`).
when: "not (item.startswith('@') and ':' in item)"
with_items: "{{ matrix_bot_meowlnir_bot_initial_managers }}"

- name: Validate Meowlnir bot management rooms
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/validate_bot_management_room.yml"
with_items: "{{ bot.management_rooms }}"
loop_control:
loop_var: management_room

+ 25
- 0
roles/custom/matrix-bot-meowlnir/tasks/util/validate_bot_management_room.yml Vedi File

@@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

- name: Fail if a Meowlnir management room definition lacks required fields
ansible.builtin.fail:
msg: >-
A `management_rooms` entry for the Meowlnir bot `{{ bot.username }}` is missing the
`{{ item }}` field. All fields are required on every entry.
Offending definition: {{ management_room | to_json }}
when: "item not in management_room"
with_items:
- id
- encrypted

- name: Fail if a Meowlnir management room ID does not look like a room ID
ansible.builtin.fail:
msg: >-
A `management_rooms` entry for the Meowlnir bot `{{ bot.username }}` has an `id` of
`{{ management_room.id }}`, which does not look like a room ID.
It needs to be a room ID (`!qporfwt:example.com`), not a room alias or a matrix.to URL.
You can find it in Element Web under the room's Settings -> Advanced.
when: "not management_room.id.startswith('!')"

+ 83
- 0
roles/custom/matrix-bot-meowlnir/tasks/validate_config.yml Vedi File

@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

- name: Fail if required matrix-bot-meowlnir variables are undefined
ansible.builtin.fail:
msg: "The `{{ item.name }}` variable must be defined and have a non-null value."
with_items:
- {'name': 'matrix_bot_meowlnir_container_network', when: true}
- {'name': 'matrix_bot_meowlnir_config_homeserver_address', when: true}
- {'name': 'matrix_bot_meowlnir_config_homeserver_domain', when: true}
- {'name': 'matrix_bot_meowlnir_appservice_token', when: true}
- {'name': 'matrix_bot_meowlnir_homeserver_token', when: true}
- {'name': 'matrix_bot_meowlnir_config_meowlnir_management_secret', when: true}
- {'name': 'matrix_bot_meowlnir_config_meowlnir_data_secret', when: true}
- {'name': 'matrix_bot_meowlnir_config_antispam_secret', when: true}
- {'name': 'matrix_bot_meowlnir_config_encryption_pickle_key', when: true}
- {'name': 'matrix_bot_meowlnir_database_hostname', when: true}
- {'name': 'matrix_bot_meowlnir_database_password', when: true}
- {'name': 'matrix_bot_meowlnir_config_policy_server_signing_key', when: "{{ matrix_bot_meowlnir_policy_server_enabled }}"}
- {'name': 'matrix_bot_meowlnir_synapse_http_antispam_management_room_id', when: "{{ matrix_bot_meowlnir_synapse_http_antispam_enabled }}"}
when: "item.when | bool and (lookup('vars', item.name, default='') == '' or lookup('vars', item.name, default='') is none)"

# Meowlnir re-runs its configuration upgrader in memory on every start, and `generate` is resolved to a fresh random value each time.
# A `generate` placeholder would therefore rotate the secret on every restart, invalidating the appservice registration or the encryption store.
- name: Fail if matrix-bot-meowlnir secrets are set to the literal "generate"
ansible.builtin.fail:
msg: >-
The `{{ item }}` variable is set to `generate`. Meowlnir would then mint a new
secret on every restart, because the playbook runs it with `--no-update` and its
configuration file is managed by Ansible. Set an explicit, stable value instead.
when: "lookup('vars', item, default='') == 'generate'"
with_items:
- matrix_bot_meowlnir_appservice_token
- matrix_bot_meowlnir_homeserver_token
- matrix_bot_meowlnir_config_meowlnir_management_secret
- matrix_bot_meowlnir_config_meowlnir_data_secret
- matrix_bot_meowlnir_config_antispam_secret
- matrix_bot_meowlnir_config_encryption_pickle_key
- matrix_bot_meowlnir_config_policy_server_signing_key

# Bots exist only in Meowlnir's database and are created through its management API, so the whole roster and pruning path depends on that API being reachable.
# Disabling it is only coherent for an installation whose bots were created some other way, which means an empty roster and pruning turned off.
- name: Fail if the Meowlnir management API is disabled while the playbook manages bots
ansible.builtin.fail:
msg: >-
`matrix_bot_meowlnir_config_meowlnir_management_secret` is set to `disable`, which
turns Meowlnir's management API off. The playbook creates, updates and removes bots
through that API, so it cannot manage them while it is disabled.
Either give the secret a real value, or - if you manage Meowlnir's bots yourself -
leave `matrix_bot_meowlnir_bots_custom` empty and set
`matrix_bot_meowlnir_bots_pruning_enabled` to `false`.
when: >-
matrix_bot_meowlnir_config_meowlnir_management_secret == 'disable'
and (matrix_bot_meowlnir_bots | length > 0 or matrix_bot_meowlnir_bots_pruning_enabled | bool)

- name: Fail if the bot user prefix and the localpart template disagree
ansible.builtin.fail:
msg: >-
`matrix_bot_meowlnir_config_meowlnir4all_localpart_template` must start with
`matrix_bot_meowlnir_user_prefix` ({{ matrix_bot_meowlnir_user_prefix }}), or the bots
Meowlnir creates will fall outside the user namespace declared in its appservice
registration file, and the homeserver will refuse to let it operate them.
when: "not matrix_bot_meowlnir_config_meowlnir4all_localpart_template.startswith(matrix_bot_meowlnir_user_prefix)"

- name: Validate Meowlnir bot definitions
ansible.builtin.include_tasks: "{{ role_path }}/tasks/util/validate_bot.yml"
with_items: "{{ matrix_bot_meowlnir_bots }}"
loop_control:
loop_var: bot

- name: Fail if Meowlnir and Draupnir both claim the synapse-http-antispam module
ansible.builtin.fail:
msg: >-
Both `matrix_bot_meowlnir_synapse_http_antispam_enabled` and
`matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled` are enabled.
The playbook wires the synapse-http-antispam module up to a single consumer,
so you need to pick one of the two.
when:
- matrix_bot_meowlnir_synapse_http_antispam_enabled | bool
- matrix_bot_draupnir_config_web_synapseHTTPAntispam_enabled | default(false) | bool

+ 55
- 0
roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-api.j2 Vedi File

@@ -0,0 +1,55 @@
#!/bin/sh
# Talks to Meowlnir's management API.
#
# The API is not published outside the container network, so requests are made from inside the container, which ships with curl.
# The management secret is read out of the live configuration file, so that it lives in exactly one place.
#
# Usage: meowlnir-api <METHOD> <PATH> [JSON_BODY]
# Example: meowlnir-api GET /_meowlnir/v1/bots
#
# Prints the response body, followed by the HTTP status code on its own final line.

set -eu

CONFIG_FILE='{{ matrix_bot_meowlnir_config_path }}/config.yaml'
CONTAINER_NAME='matrix-bot-meowlnir'
API_BASE='http://localhost:{{ matrix_bot_meowlnir_config_meowlnir_port }}'

if [ $# -lt 2 ]; then
echo "Usage: $(basename "$0") <METHOD> <PATH> [JSON_BODY]" >&2
echo "Example: $(basename "$0") GET /_meowlnir/v1/bots" >&2
exit 2
fi

method="$1"
api_path="$2"
body="${3:-}"

# The configuration file is generated by Ansible, so its layout is predictable.
secret="$(awk '$1 == "management_secret:" { print $2; exit }' "$CONFIG_FILE" | sed 's/^"//; s/"$//')"

if [ -z "$secret" ]; then
echo "Could not read management_secret from $CONFIG_FILE" >&2
exit 1
fi

if [ "$secret" = 'disable' ]; then
echo "Meowlnir's management API is disabled (management_secret is set to 'disable')" >&2
exit 1
fi

if [ -n "$body" ]; then
exec {{ devture_systemd_docker_base_host_command_docker }} exec "$CONTAINER_NAME" \
curl -sS -X "$method" \
-H "Authorization: Bearer $secret" \
-H 'Content-Type: application/json' \
-d "$body" \
-w '\n%{http_code}' \
"$API_BASE$api_path"
fi

exec {{ devture_systemd_docker_base_host_command_docker }} exec "$CONTAINER_NAME" \
curl -sS -X "$method" \
-H "Authorization: Bearer $secret" \
-w '\n%{http_code}' \
"$API_BASE$api_path"

+ 3
- 0
roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-api.j2.license Vedi File

@@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2026 Slavi Pantaleev

SPDX-License-Identifier: AGPL-3.0-or-later

+ 21
- 0
roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-bots.j2 Vedi File

@@ -0,0 +1,21 @@
#!/bin/sh
# Shows the bots Meowlnir knows about, together with their management rooms, protected rooms and watched policy lists.
#
# Usage: meowlnir-bots

set -eu

BIN_PATH='{{ matrix_bot_meowlnir_bin_path }}'
CONTAINER_NAME='matrix-bot-meowlnir'

response="$("$BIN_PATH/meowlnir-api" GET /_meowlnir/v1/bots)"
status="$(printf '%s\n' "$response" | tail -n 1)"
body="$(printf '%s\n' "$response" | sed '$d')"

if [ "$status" != '200' ]; then
echo "Meowlnir answered with HTTP $status:" >&2
echo "$body" >&2
exit 1
fi

printf '%s\n' "$body" | {{ devture_systemd_docker_base_host_command_docker }} exec -i "$CONTAINER_NAME" jq .

+ 3
- 0
roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-bots.j2.license Vedi File

@@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2026 Slavi Pantaleev

SPDX-License-Identifier: AGPL-3.0-or-later

+ 90
- 0
roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-create-management-room.j2 Vedi File

@@ -0,0 +1,90 @@
#!/bin/sh
# Creates a management room for a Meowlnir bot, with the given users able to command the bot there.
#
# The room is created by the bot itself, impersonated through the appservice token, so no human account's credentials are needed.
#
# The `trusted_private_chat` preset is what gives the invited users their standing, and it does the right thing on both old and new room versions: on rooms supporting MSC4289 every invitee becomes an additional creator, and on older ones each is given power level 100.
# Either way there is nothing for us to adjust afterwards.
#
# Usage: meowlnir-create-management-room <bot_localpart> <initial_manager_mxid>...
#
# Prints the created room's ID on success.

set -eu

CONFIG_FILE='{{ matrix_bot_meowlnir_config_path }}/config.yaml'
CONTAINER_NAME='matrix-bot-meowlnir'
HOMESERVER_ADDRESS='{{ matrix_bot_meowlnir_config_homeserver_address }}'
HOMESERVER_DOMAIN='{{ matrix_bot_meowlnir_config_homeserver_domain }}'
ROOM_NAME='{{ matrix_bot_meowlnir_management_room_name }}'
ROOM_TOPIC='{{ matrix_bot_meowlnir_management_room_topic | trim }}'
ENCRYPTED='{{ 'true' if matrix_bot_meowlnir_config_encryption_enable else 'false' }}'

if [ $# -lt 2 ]; then
echo "Usage: $(basename "$0") <bot_localpart> <initial_manager_mxid>..." >&2
exit 2
fi

bot_localpart="$1"
shift
bot_mxid="@$bot_localpart:$HOMESERVER_DOMAIN"

as_token="$(awk '$1 == "as_token:" { print $2; exit }' "$CONFIG_FILE" | sed 's/^"//; s/"$//')"

if [ -z "$as_token" ]; then
echo "Could not read as_token from $CONFIG_FILE" >&2
exit 1
fi

urlencode() {
printf '%s' "$1" | sed 's/%/%25/g; s/!/%21/g; s/:/%3A/g; s/@/%40/g; s/\$/%24/g; s/\//%2F/g'
}

jq_run() {
{{ devture_systemd_docker_base_host_command_docker }} exec -i "$CONTAINER_NAME" jq "$@"
}

if [ "$ENCRYPTED" = 'true' ]; then
initial_state='[{"type": "m.room.encryption", "state_key": "", "content": {"algorithm": "m.megolm.v1.aes-sha2"}}]'
else
initial_state='[]'
fi

# Matrix user IDs cannot contain newlines, so splitting on them is safe here.
invitees="$(printf '%s\n' "$@" | jq_run -R -s 'split("\n") | map(select(length > 0))')"

create_body="$(jq_run -n \
--arg name "$ROOM_NAME" \
--arg topic "$ROOM_TOPIC" \
--argjson invitees "$invitees" \
--argjson initial_state "$initial_state" \
'{preset: "trusted_private_chat", name: $name, topic: $topic, invite: $invitees, initial_state: $initial_state}')"

user_id_param="$(urlencode "$bot_mxid")"

# Runs curl inside the container, because the homeserver is only reachable over the container network.
# Prints the body, with the HTTP status code on the final line.
response="$({{ devture_systemd_docker_base_host_command_docker }} exec "$CONTAINER_NAME" \
curl -sS -X POST \
-H "Authorization: Bearer $as_token" \
-H 'Content-Type: application/json' \
-d "$create_body" \
-w '\n%{http_code}' \
"$HOMESERVER_ADDRESS/_matrix/client/v3/createRoom?user_id=$user_id_param")"

status="$(printf '%s\n' "$response" | tail -n 1)"

if [ "$status" != '200' ]; then
echo "Creating the management room failed with HTTP $status:" >&2
printf '%s\n' "$response" | sed '$d' >&2
exit 1
fi

room_id="$(printf '%s\n' "$response" | sed '$d' | jq_run -r '.room_id')"

if [ -z "$room_id" ] || [ "$room_id" = 'null' ]; then
echo 'The homeserver did not return a room ID' >&2
exit 1
fi

printf '%s\n' "$room_id"

+ 3
- 0
roles/custom/matrix-bot-meowlnir/templates/bin/meowlnir-create-management-room.j2.license Vedi File

@@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2026 Slavi Pantaleev

SPDX-License-Identifier: AGPL-3.0-or-later

+ 84
- 0
roles/custom/matrix-bot-meowlnir/templates/config.yaml.j2 Vedi File

@@ -0,0 +1,84 @@
{#
SPDX-FileCopyrightText: 2026 Slavi Pantaleev

SPDX-License-Identifier: AGPL-3.0-or-later
#}
{#
Note: this template is rendered, parsed as YAML, merged with
`matrix_bot_meowlnir_configuration_extension`, and dumped again.
Comments written here therefore do not reach the final configuration file.

Secrets must always be rendered explicitly. Meowlnir re-runs its configuration
upgrader in memory on every start, so a literal `generate` value would produce a
brand new secret on each restart.
#}
homeserver:
address: {{ matrix_bot_meowlnir_config_homeserver_address | to_json }}
domain: {{ matrix_bot_meowlnir_config_homeserver_domain | to_json }}

meowlnir:
id: {{ matrix_bot_meowlnir_appservice_id | to_json }}
as_token: {{ matrix_bot_meowlnir_appservice_token | to_json }}
hs_token: {{ matrix_bot_meowlnir_homeserver_token | to_json }}

address: {{ matrix_bot_meowlnir_appservice_url | to_json }}
hostname: {{ matrix_bot_meowlnir_config_meowlnir_hostname | to_json }}
port: {{ matrix_bot_meowlnir_config_meowlnir_port | int }}

management_secret: {{ matrix_bot_meowlnir_config_meowlnir_management_secret | to_json }}
data_secret: {{ matrix_bot_meowlnir_config_meowlnir_data_secret | to_json }}
federation_auth: {{ matrix_bot_meowlnir_config_meowlnir_federation_auth | to_json }}
dry_run: {{ matrix_bot_meowlnir_config_meowlnir_dry_run | to_json }}
untrusted: {{ matrix_bot_meowlnir_config_meowlnir_untrusted | to_json }}

report_room: {{ matrix_bot_meowlnir_config_meowlnir_report_room | to_json if matrix_bot_meowlnir_config_meowlnir_report_room else 'null' }}
room_ban_room: {{ matrix_bot_meowlnir_config_meowlnir_room_ban_room | to_json if matrix_bot_meowlnir_config_meowlnir_room_ban_room else 'null' }}
load_all_room_hashes: {{ matrix_bot_meowlnir_config_meowlnir_load_all_room_hashes | to_json }}

hacky_rule_filter: {{ matrix_bot_meowlnir_config_meowlnir_hacky_rule_filter | to_json }}
hacky_redact_patterns: {{ matrix_bot_meowlnir_config_meowlnir_hacky_redact_patterns | to_json }}

admin_tokens: {{ matrix_bot_meowlnir_config_meowlnir_admin_tokens | to_json }}

meowlnir4all:
admin_room: {{ matrix_bot_meowlnir_config_meowlnir4all_admin_room | to_json if matrix_bot_meowlnir_config_meowlnir4all_admin_room else 'null' }}
localpart_template: {{ matrix_bot_meowlnir_config_meowlnir4all_localpart_template | to_json }}
displayname: {{ matrix_bot_meowlnir_config_meowlnir4all_displayname | to_json if matrix_bot_meowlnir_config_meowlnir4all_displayname else 'null' }}
avatar_url: {{ matrix_bot_meowlnir_config_meowlnir4all_avatar_url | to_json if matrix_bot_meowlnir_config_meowlnir4all_avatar_url else 'null' }}
room_name: {{ matrix_bot_meowlnir_config_meowlnir4all_room_name | to_json }}
default_watched_lists: {{ matrix_bot_meowlnir_config_meowlnir4all_default_watched_lists | to_json }}

antispam:
secret: {{ matrix_bot_meowlnir_config_antispam_secret | to_json }}
filter_local_invites: {{ matrix_bot_meowlnir_config_antispam_filter_local_invites | to_json }}
auto_reject_invites_token: {{ matrix_bot_meowlnir_config_antispam_auto_reject_invites_token | to_json if matrix_bot_meowlnir_config_antispam_auto_reject_invites_token else 'null' }}
notify_management_room: {{ matrix_bot_meowlnir_config_antispam_notify_management_room | to_json }}
block_invites_to: {{ matrix_bot_meowlnir_config_antispam_block_invites_to | to_json }}

policy_server:
always_redact: {{ matrix_bot_meowlnir_config_policy_server_always_redact | to_json }}
signing_key: {{ matrix_bot_meowlnir_config_policy_server_signing_key | to_json }}

encryption:
enable: {{ matrix_bot_meowlnir_config_encryption_enable | to_json }}
pickle_key: {{ matrix_bot_meowlnir_config_encryption_pickle_key | to_json }}

database:
type: {{ matrix_bot_meowlnir_database_engine | to_json }}
uri: {{ matrix_bot_meowlnir_database_connection_string | to_json }}
max_open_conns: {{ matrix_bot_meowlnir_config_database_max_open_conns | int }}
max_idle_conns: {{ matrix_bot_meowlnir_config_database_max_idle_conns | int }}
max_conn_idle_time: {{ matrix_bot_meowlnir_config_database_max_conn_idle_time | to_json if matrix_bot_meowlnir_config_database_max_conn_idle_time else 'null' }}
max_conn_lifetime: {{ matrix_bot_meowlnir_config_database_max_conn_lifetime | to_json if matrix_bot_meowlnir_config_database_max_conn_lifetime else 'null' }}

synapse_db:
type: postgres
uri: {{ matrix_bot_meowlnir_synapse_database_uri | to_json }}
max_open_conns: {{ matrix_bot_meowlnir_config_synapse_db_max_open_conns | int }}
max_idle_conns: {{ matrix_bot_meowlnir_config_synapse_db_max_idle_conns | int }}
max_conn_idle_time: {{ matrix_bot_meowlnir_config_synapse_db_max_conn_idle_time | to_json if matrix_bot_meowlnir_config_synapse_db_max_conn_idle_time else 'null' }}
max_conn_lifetime: {{ matrix_bot_meowlnir_config_synapse_db_max_conn_lifetime | to_json if matrix_bot_meowlnir_config_synapse_db_max_conn_lifetime else 'null' }}

logging:
min_level: {{ matrix_bot_meowlnir_config_logging_min_level | to_json }}
writers: {{ matrix_bot_meowlnir_config_logging_writers | to_json }}

+ 78
- 0
roles/custom/matrix-bot-meowlnir/templates/labels.j2 Vedi File

@@ -0,0 +1,78 @@
{#
SPDX-FileCopyrightText: 2026 Slavi Pantaleev

SPDX-License-Identifier: AGPL-3.0-or-later
#}

{% if matrix_bot_meowlnir_container_labels_traefik_enabled %}
traefik.enable=true

{% if matrix_bot_meowlnir_container_labels_traefik_docker_network %}
traefik.docker.network={{ matrix_bot_meowlnir_container_labels_traefik_docker_network }}
{% endif %}

traefik.http.services.matrix-bot-meowlnir.loadbalancer.server.port={{ matrix_bot_meowlnir_config_meowlnir_port }}

{% if matrix_bot_meowlnir_config_reporting_enabled %}
############################################################
# #
# Reports (/_matrix/client/../rooms/../report) #
# #
############################################################

{# Meowlnir serves these paths verbatim and applies CORS headers itself, #}
{# so no path-rewriting or CORS middleware is necessary here. #}

traefik.http.routers.matrix-bot-meowlnir-reporting.rule={{ matrix_bot_meowlnir_container_labels_reporting_traefik_rule }}

{% if matrix_bot_meowlnir_container_labels_reporting_traefik_priority | int > 0 %}
traefik.http.routers.matrix-bot-meowlnir-reporting.priority={{ matrix_bot_meowlnir_container_labels_reporting_traefik_priority }}
{% endif %}

traefik.http.routers.matrix-bot-meowlnir-reporting.service=matrix-bot-meowlnir
traefik.http.routers.matrix-bot-meowlnir-reporting.entrypoints={{ matrix_bot_meowlnir_container_labels_reporting_traefik_entrypoints }}
traefik.http.routers.matrix-bot-meowlnir-reporting.tls={{ matrix_bot_meowlnir_container_labels_reporting_traefik_tls | to_json }}

{% if matrix_bot_meowlnir_container_labels_reporting_traefik_tls %}
traefik.http.routers.matrix-bot-meowlnir-reporting.tls.certResolver={{ matrix_bot_meowlnir_container_labels_reporting_traefik_tls_certResolver }}
{% endif %}

############################################################
# #
# /Reports (/_matrix/client/../rooms/../report) #
# #
############################################################
{% endif %}

{% if matrix_bot_meowlnir_policy_server_enabled %}
############################################################
# #
# Policy server (/_matrix/policy) #
# #
############################################################

{# Served on the federation endpoint, so that other servers in a room can reach it. #}

traefik.http.routers.matrix-bot-meowlnir-policy-server.rule={{ matrix_bot_meowlnir_container_labels_policy_server_traefik_rule }}

{% if matrix_bot_meowlnir_container_labels_policy_server_traefik_priority | int > 0 %}
traefik.http.routers.matrix-bot-meowlnir-policy-server.priority={{ matrix_bot_meowlnir_container_labels_policy_server_traefik_priority }}
{% endif %}

traefik.http.routers.matrix-bot-meowlnir-policy-server.service=matrix-bot-meowlnir
traefik.http.routers.matrix-bot-meowlnir-policy-server.entrypoints={{ matrix_bot_meowlnir_container_labels_policy_server_traefik_entrypoints }}
traefik.http.routers.matrix-bot-meowlnir-policy-server.tls={{ matrix_bot_meowlnir_container_labels_policy_server_traefik_tls | to_json }}

{% if matrix_bot_meowlnir_container_labels_policy_server_traefik_tls %}
traefik.http.routers.matrix-bot-meowlnir-policy-server.tls.certResolver={{ matrix_bot_meowlnir_container_labels_policy_server_traefik_tls_certResolver }}
{% endif %}

############################################################
# #
# /Policy server (/_matrix/policy) #
# #
############################################################
{% endif %}
{% endif %}

{{ matrix_bot_meowlnir_container_labels_traefik_labels_additional_labels }}

+ 54
- 0
roles/custom/matrix-bot-meowlnir/templates/systemd/matrix-bot-meowlnir.service.j2 Vedi File

@@ -0,0 +1,54 @@
#jinja2: lstrip_blocks: True
[Unit]
Description=Matrix Meowlnir moderation bot
{% for service in matrix_bot_meowlnir_systemd_required_services_list %}
Requires={{ service }}
After={{ service }}
{% endfor %}
{% for service in matrix_bot_meowlnir_systemd_wanted_services_list %}
Wants={{ service }}
{% endfor %}
DefaultDependencies=no

[Service]
Type=simple
Environment="HOME={{ devture_systemd_docker_base_systemd_unit_home_path }}"
ExecStartPre=-{{ devture_systemd_docker_base_host_command_sh }} -c '{{ devture_systemd_docker_base_host_command_docker }} stop -t {{ devture_systemd_docker_base_container_stop_grace_time_seconds }} matrix-bot-meowlnir 2>/dev/null || true'
ExecStartPre=-{{ devture_systemd_docker_base_host_command_sh }} -c '{{ devture_systemd_docker_base_host_command_docker }} rm matrix-bot-meowlnir 2>/dev/null || true'

ExecStartPre={{ devture_systemd_docker_base_host_command_docker }} create \
--rm \
--name=matrix-bot-meowlnir \
--log-driver=none \
--user={{ matrix_user_uid }}:{{ matrix_user_gid }} \
--cap-drop=ALL \
--read-only \
--network={{ matrix_bot_meowlnir_container_network }} \
{% if matrix_bot_meowlnir_container_http_host_bind_port %}
-p {{ matrix_bot_meowlnir_container_http_host_bind_port }}:{{ matrix_bot_meowlnir_config_meowlnir_port }} \
{% endif %}
--label-file={{ matrix_bot_meowlnir_base_path }}/labels \
--mount type=bind,src={{ matrix_bot_meowlnir_data_path }},dst=/data \
--mount type=bind,src={{ matrix_bot_meowlnir_config_path }},dst=/data/config,ro \
{% for arg in matrix_bot_meowlnir_container_extra_arguments %}
{{ arg }} \
{% endfor %}
{{ matrix_bot_meowlnir_container_image }} \
/usr/bin/meowlnir --config=/data/config/config.yaml --no-update

{% if matrix_bot_meowlnir_container_network != 'host' %}
{% for network in matrix_bot_meowlnir_container_additional_networks %}
ExecStartPre={{ devture_systemd_docker_base_host_command_docker }} network connect {{ network }} matrix-bot-meowlnir
{% endfor %}
{% endif %}

ExecStart={{ devture_systemd_docker_base_host_command_docker }} start --attach matrix-bot-meowlnir

ExecStop=-{{ devture_systemd_docker_base_host_command_sh }} -c '{{ devture_systemd_docker_base_host_command_docker }} stop -t {{ devture_systemd_docker_base_container_stop_grace_time_seconds }} matrix-bot-meowlnir 2>/dev/null || true'
ExecStop=-{{ devture_systemd_docker_base_host_command_sh }} -c '{{ devture_systemd_docker_base_host_command_docker }} rm matrix-bot-meowlnir 2>/dev/null || true'
Restart=always
RestartSec=30
SyslogIdentifier=matrix-bot-meowlnir

[Install]
WantedBy=multi-user.target

+ 3
- 0
roles/custom/matrix-bot-meowlnir/templates/systemd/matrix-bot-meowlnir.service.j2.license Vedi File

@@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2026 Slavi Pantaleev

SPDX-License-Identifier: AGPL-3.0-or-later

+ 16
- 0
roles/custom/matrix-bot-meowlnir/vars/main.yml Vedi File

@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---

# The fields every entry in `matrix_bot_meowlnir_bots` must carry.
#
# Lives here, not in `defaults/main.yml`, because it describes the role's own expectations and is not something to override.
# `initial_managers` is deliberately absent — it is optional and falls back to `matrix_bot_meowlnir_initial_managers`.
matrix_bot_meowlnir_bot_required_fields:
- username
- displayname
- avatar_url
- management_room_auto_create
- management_rooms

+ 1
- 0
setup.yml Vedi File

@@ -89,6 +89,7 @@
- custom/matrix-bot-honoroit
- custom/matrix-bot-mjolnir
- custom/matrix-bot-draupnir
- custom/matrix-bot-meowlnir
- custom/matrix-cactus-comments
- custom/matrix-cactus-comments-client
- custom/matrix-matrixto


Caricamento…
Annulla
Salva