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>
This commit is contained in:
Slavi Pantaleev
2026-08-13 06:05:30 +03:00
co-authored by Claude Opus 5
parent bed7bddf4a
commit f9222dc70c
36 changed files with 2270 additions and 7 deletions
@@ -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"
@@ -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
@@ -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)
}}
@@ -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
@@ -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
@@ -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."
]
}}
@@ -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'])
@@ -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'"
@@ -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
@@ -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
@@ -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'"
@@ -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) }}"
@@ -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
@@ -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('!')"
@@ -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