nixos/mautrix-discord: init mautrix-discord module

Co-authored-by: William <willbou2@gmail.com>
Co-authored-by: Robin <robin@robin.town>
This commit is contained in:
Mistyttm
2025-06-07 14:14:46 +10:00
co-authored by William Robin
parent 067a39e41a
commit 136d3d5cb1
5 changed files with 710 additions and 0 deletions
@@ -28,6 +28,8 @@
[dwl](https://codeberg.org/dwl/dwl), a compact, hackable compositor for Wayland based on wlroots. Available as [programs.dwl](#opt-programs.dwl.enable).
- [mautrix-discord](https://github.com/mautrix/discord), a Matrix-Discord puppeting/relay bridge. Available as [services.mautrix-discord](#opt-services.mautrix-discord.enable).
## Backward Incompatibilities {#sec-release-25.11-incompatibilities}
<!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->
+1
View File
@@ -764,6 +764,7 @@
./services/matrix/lk-jwt-service.nix
./services/matrix/matrix-alertmanager.nix
./services/matrix/maubot.nix
./services/matrix/mautrix-discord.nix
./services/matrix/mautrix-meta.nix
./services/matrix/mautrix-signal.nix
./services/matrix/mautrix-telegram.nix
@@ -0,0 +1,538 @@
{
lib,
config,
pkgs,
...
}:
let
cfg = config.services.mautrix-discord;
dataDir = cfg.dataDir;
format = pkgs.formats.yaml { };
registrationFile = "${dataDir}/discord-registration.yaml";
settingsFile = "${dataDir}/config.yaml";
settingsFileUnformatted = format.generate "discord-config-unsubstituted.yaml" cfg.settings;
in
{
options = {
services.mautrix-discord = {
enable = lib.mkEnableOption "Mautrix-Discord, a Matrix-Discord puppeting/relay-bot bridge";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.mautrix-discord;
defaultText = lib.literalExpression "pkgs.mautrix-discord";
description = ''
The mautrix-discord package to use.
'';
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType = format.type;
config = {
_module.args = { inherit cfg lib; };
};
options = {
homeserver = lib.mkOption {
type = lib.types.attrs;
default = {
software = "standard";
status_endpoint = null;
message_send_checkpoint_endpoint = null;
async_media = false;
websocket = false;
ping_interval_seconds = 0;
};
description = ''
fullDataDiration.
See [example-config.yaml](https://github.com/mautrix/discord/blob/main/example-config.yaml)
for more information.
'';
};
appservice = lib.mkOption {
type = lib.types.attrs;
default = {
address = "http://localhost:8009";
port = 8009;
id = "discord";
bot = {
username = "discordbot";
displayname = "Discord bridge bot";
avatar = "mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC";
};
as_token = "generate";
hs_token = "generate";
database = {
type = "sqlite3";
uri = "file:/var/lib/mautrix-discord/mautrix-discord.db?_txlock=immediate";
};
};
defaultText = lib.literalExpression ''
{
address = "http://localhost:8009";
port = 8009;
id = "discord";
bot = {
username = "discordbot";
displayname = "Discord bridge bot";
avatar = "mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC";
};
as_token = "generate";
hs_token = "generate";
database = {
type = "sqlite3";
uri = "file:''${config.services.mautrix-discord.dataDir}/mautrix-discord.db?_txlock=immediate";
};
}
'';
description = ''
Appservice configuration.
See [example-config.yaml](https://github.com/mautrix/discord/blob/main/example-config.yaml)
for more information.
'';
};
bridge = lib.mkOption {
type = lib.types.attrs;
default = {
username_template = "discord_{{.}}";
displayname_template = "{{or .GlobalName .Username}}{{if .Bot}} (bot){{end}}";
channel_name_template = "{{if or (eq .Type 3) (eq .Type 4)}}{{.Name}}{{else}}#{{.Name}}{{end}}";
guild_name_template = "{{.Name}}";
private_chat_portal_meta = "default";
public_address = null;
avatar_proxy_key = "generate";
portal_message_buffer = 128;
startup_private_channel_create_limit = 5;
delivery_receipts = false;
message_status_events = false;
message_error_notices = true;
restricted_rooms = true;
autojoin_thread_on_open = true;
embed_fields_as_tables = true;
mute_channels_on_create = false;
sync_direct_chat_list = false;
resend_bridge_info = false;
custom_emoji_reactions = true;
delete_portal_on_channel_delete = false;
delete_guild_on_leave = true;
federate_rooms = true;
prefix_webhook_messages = false;
enable_webhook_avatars = true;
use_discord_cdn_upload = true;
cache_media = "unencrypted";
direct_media = {
enabled = false;
server_name = "discord-media.example.com";
allow_proxy = true;
server_key = "generate";
};
animated_sticker = {
target = "webp";
args = {
width = 320;
height = 320;
fps = 25;
};
};
command_prefix = "!discord";
management_room_text = {
welcome = "Hello, I'm a Discord bridge bot.";
welcome_connected = "Use `help` for help.";
welcome_unconnected = "Use `help` for help or `login` to log in.";
additional_help = "";
};
backfill = {
forward_limits = {
initial = {
dm = 0;
channel = 0;
thread = 0;
};
missed = {
dm = 0;
channel = 0;
thread = 0;
};
max_guild_members = -1;
};
};
encryption = {
allow = false;
default = false;
appservice = false;
msc4190 = false;
require = false;
allow_key_sharing = false;
plaintext_mentions = false;
delete_keys = {
delete_outbound_on_ack = false;
dont_store_outbound = false;
ratchet_on_decrypt = false;
delete_fully_used_on_decrypt = false;
delete_prev_on_new_session = false;
delete_on_device_delete = false;
periodically_delete_expired = false;
delete_outdated_inbound = false;
};
verification_levels = {
receive = "unverified";
send = "unverified";
share = "cross-signed-tofu";
};
rotation = {
enable_custom = false;
milliseconds = 604800000;
messages = 100;
disable_device_change_key_rotation = false;
};
};
provisioning = {
prefix = "/_matrix/provision";
shared_secret = "generate";
debug_endpoints = false;
};
permissions = {
"*" = "relay";
# "example.com" = "user";
# "@admin:example.com": "admin";
};
};
description = ''
Bridge configuration.
See [example-config.yaml](https://github.com/mautrix/discord/blob/main/example-config.yaml)
for more information.
'';
};
};
};
default = { };
example = lib.literalExpression ''
{
homeserver = {
address = "http://localhost:8008";
domain = "public-domain.tld";
};
appservice.public = {
prefix = "/public";
external = "https://public-appservice-address/public";
};
bridge.permissions = {
"example.com" = "full";
"@admin:example.com" = "admin";
};
}
'';
description = ''
{file}`config.yaml` configuration as a Nix attribute set.
Configuration options should match those described in
[example-config.yaml](https://github.com/mautrix/discord/blob/main/example-config.yaml).
'';
};
registerToSynapse = lib.mkOption {
type = lib.types.bool;
default = config.services.matrix-synapse.enable;
defaultText = lib.literalExpression "config.services.matrix-synapse.enable";
description = ''
Whether to add the bridge's app service registration file to
`services.matrix-synapse.settings.app_service_config_files`.
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/mautrix-discord";
defaultText = "/var/lib/mautrix-discord";
description = ''
Directory to store the bridge's configuration and database files.
This directory will be created if it does not exist.
'';
};
# TODO: Get upstream to add an environment File option. Refer to https://github.com/NixOS/nixpkgs/pull/404871#issuecomment-2895663652 and https://github.com/mautrix/discord/issues/187
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
File containing environment variables to substitute when copying the configuration
out of Nix store to the `services.mautrix-discord.dataDir`.
Can be used for storing the secrets without making them available in the Nix store.
For example, you can set `services.mautrix-discord.settings.appservice.as_token = "$MAUTRIX_DISCORD_APPSERVICE_AS_TOKEN"`
and then specify `MAUTRIX_DISCORD_APPSERVICE_AS_TOKEN="{token}"` in the environment file.
This value will get substituted into the configuration file as a token.
'';
};
serviceUnit = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "mautrix-discord.service";
description = ''
The systemd unit (a service or a target) for other services to depend on if they
need to be started after matrix-synapse.
This option is useful as the actual parent unit for all matrix-synapse processes
changes when configuring workers.
'';
};
registrationServiceUnit = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "mautrix-discord-registration.service";
description = ''
The registration service that generates the registration file.
Systemd unit (a service or a target) for other services to depend on if they
need to be started after mautrix-discord registration service.
This option is useful as the actual parent unit for all matrix-synapse processes
changes when configuring workers.
'';
};
serviceDependencies = lib.mkOption {
type = lib.types.listOf lib.types.str;
default =
[ cfg.registrationServiceUnit ]
++ (lib.lists.optional config.services.matrix-synapse.enable config.services.matrix-synapse.serviceUnit)
++ (lib.lists.optional config.services.matrix-conduit.enable "matrix-conduit.service")
++ (lib.lists.optional config.services.dendrite.enable "dendrite.service");
defaultText = ''
[ cfg.registrationServiceUnit ] ++
(lib.lists.optional config.services.matrix-synapse.enable config.services.matrix-synapse.serviceUnit) ++
(lib.lists.optional config.services.matrix-conduit.enable "matrix-conduit.service") ++
(lib.lists.optional config.services.dendrite.enable "dendrite.service");
'';
description = ''
List of Systemd services to require and wait for when starting the application service.
'';
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion =
cfg.settings.homeserver.domain or "" != "" && cfg.settings.homeserver.address or "" != "";
message = ''
The options with information about the homeserver:
`services.mautrix-discord.settings.homeserver.domain` and
`services.mautrix-discord.settings.homeserver.address` have to be set.
'';
}
{
assertion = cfg.settings.bridge.permissions or { } != { };
message = ''
The option `services.mautrix-discord.settings.bridge.permissions` has to be set.
'';
}
{
assertion = cfg.settings.appservice.id != "";
message = ''
The option `services.mautrix-discord.settings.appservice.id` has to be set.
'';
}
{
assertion = cfg.settings.appservice.bot.username != "";
message = ''
The option `services.mautrix-discord.settings.appservice.bot.username` has to be set.
'';
}
];
users.users.mautrix-discord = {
isSystemUser = true;
group = "mautrix-discord";
extraGroups = [ "mautrix-discord-registration" ];
home = dataDir;
description = "Mautrix-Discord bridge user";
};
users.groups.mautrix-discord = { };
users.groups.mautrix-discord-registration = {
members = lib.lists.optional config.services.matrix-synapse.enable "matrix-synapse";
};
services.matrix-synapse = lib.mkIf cfg.registerToSynapse {
settings.app_service_config_files = [ registrationFile ];
};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 770 mautrix-discord mautrix-discord -"
];
systemd.services = {
matrix-synapse = lib.mkIf cfg.registerToSynapse {
serviceConfig.SupplementaryGroups = [ "mautrix-discord-registration" ];
# Make synapse depend on the registration service when auto-registering
wants = [ "mautrix-discord-registration.service" ];
after = [ "mautrix-discord-registration.service" ];
};
mautrix-discord-registration = {
description = "Mautrix-Discord registration generation service";
wantedBy = lib.mkIf cfg.registerToSynapse [ "multi-user.target" ];
before = lib.mkIf cfg.registerToSynapse [ "matrix-synapse.service" ];
path = [
pkgs.yq
pkgs.envsubst
cfg.package
];
script = ''
# substitute the settings file by environment variables
# in this case read from EnvironmentFile
rm -f '${settingsFile}'
old_umask=$(umask)
umask 0177
envsubst \
-o '${settingsFile}' \
-i '${settingsFileUnformatted}'
config_has_tokens=$(yq '.appservice | has("as_token") and has("hs_token")' '${settingsFile}')
registration_already_exists=$([[ -f '${registrationFile}' ]] && echo "true" || echo "false")
echo "There are tokens in the config: $config_has_tokens"
echo "Registration already existed: $registration_already_exists"
# tokens not configured from config/environment file, and registration file
# is already generated, override tokens in config to make sure they are not lost
if [[ $config_has_tokens == "false" && $registration_already_exists == "true" ]]; then
echo "Copying as_token, hs_token from registration into configuration"
yq -sY '.[0].appservice.as_token = .[1].as_token
| .[0].appservice.hs_token = .[1].hs_token
| .[0]' '${settingsFile}' '${registrationFile}' \
> '${settingsFile}.tmp'
mv '${settingsFile}.tmp' '${settingsFile}'
fi
# make sure --generate-registration does not affect config.yaml
cp '${settingsFile}' '${settingsFile}.tmp'
echo "Generating registration file"
mautrix-discord \
--generate-registration \
--config='${settingsFile}.tmp' \
--registration='${registrationFile}'
rm '${settingsFile}.tmp'
# no tokens configured, and new were just generated by generate registration for first time
if [[ $config_has_tokens == "false" && $registration_already_exists == "false" ]]; then
echo "Copying newly generated as_token, hs_token from registration into configuration"
yq -sY '.[0].appservice.as_token = .[1].as_token
| .[0].appservice.hs_token = .[1].hs_token
| .[0]' '${settingsFile}' '${registrationFile}' \
> '${settingsFile}.tmp'
mv '${settingsFile}.tmp' '${settingsFile}'
fi
# make sure --generate-registration does not affect config.yaml
cp '${settingsFile}' '${settingsFile}.tmp'
echo "Generating registration file"
mautrix-discord \
--generate-registration \
--config='${settingsFile}.tmp' \
--registration='${registrationFile}'
rm '${settingsFile}.tmp'
# no tokens configured, and new were just generated by generate registration for first time
if [[ $config_has_tokens == "false" && $registration_already_exists == "false" ]]; then
echo "Copying newly generated as_token, hs_token from registration into configuration"
yq -sY '.[0].appservice.as_token = .[1].as_token
| .[0].appservice.hs_token = .[1].hs_token
| .[0]' '${settingsFile}' '${registrationFile}' \
> '${settingsFile}.tmp'
mv '${settingsFile}.tmp' '${settingsFile}'
fi
# Make sure correct tokens are in the registration file
if [[ $config_has_tokens == "true" || $registration_already_exists == "true" ]]; then
echo "Copying as_token, hs_token from configuration to the registration file"
yq -sY '.[1].as_token = .[0].appservice.as_token
| .[1].hs_token = .[0].appservice.hs_token
| .[1]' '${settingsFile}' '${registrationFile}' \
> '${registrationFile}.tmp'
mv '${registrationFile}.tmp' '${registrationFile}'
fi
umask $old_umask
chown :mautrix-discord-registration '${registrationFile}'
chmod 640 '${registrationFile}'
'';
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
UMask = 27;
User = "mautrix-discord";
Group = "mautrix-discord";
SystemCallFilter = [ "@system-service" ];
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ dataDir ];
StateDirectory = "mautrix-discord";
EnvironmentFile = cfg.environmentFile;
};
restartTriggers = [ settingsFileUnformatted ];
};
mautrix-discord = {
description = "Mautrix-Discord, a Matrix-Discord puppeting/relaybot bridge";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ] ++ cfg.serviceDependencies;
after = [ "network-online.target" ] ++ cfg.serviceDependencies;
path = [
pkgs.lottieconverter
pkgs.ffmpeg-headless
];
serviceConfig = {
Type = "simple";
User = "mautrix-discord";
Group = "mautrix-discord";
PrivateUsers = true;
Restart = "on-failure";
RestartSec = 30;
WorkingDirectory = dataDir;
ExecStart = ''
${lib.getExe cfg.package} \
--config='${settingsFile}'
'';
EnvironmentFile = cfg.environmentFile;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
PrivateDevices = true;
PrivateTmp = true;
RestrictSUIDSGID = true;
RestrictRealtime = true;
LockPersonality = true;
ProtectKernelLogs = true;
ProtectHostname = true;
ProtectClock = true;
SystemCallArchitectures = "native";
SystemCallErrorNumber = "EPERM";
SystemCallFilter = "@system-service";
ReadWritePaths = [ cfg.dataDir ];
};
restartTriggers = [ settingsFileUnformatted ];
};
};
meta = {
maintainers = with lib.maintainers; [
mistyttm
];
};
};
}
+1
View File
@@ -801,6 +801,7 @@ in
matrix-continuwuity = runTest ./matrix/continuwuity.nix;
matrix-synapse = runTest ./matrix/synapse.nix;
matrix-synapse-workers = runTest ./matrix/synapse-workers.nix;
mautrix-discord = runTest ./matrix/mautrix-discord.nix;
mattermost = handleTest ./mattermost { };
mautrix-meta-postgres = runTest ./matrix/mautrix-meta-postgres.nix;
mautrix-meta-sqlite = runTest ./matrix/mautrix-meta-sqlite.nix;
+168
View File
@@ -0,0 +1,168 @@
{ pkgs, ... }:
let
homeserverUrl = "http://homeserver:8008";
in
{
name = "mautrix-discord";
meta.maintainers = pkgs.mautrix-discord.meta.maintainers;
nodes = {
homeserver =
{ pkgs, ... }:
{
services.matrix-synapse = {
enable = true;
settings = {
server_name = "homeserver";
database.name = "sqlite3";
enable_registration = true;
# don't use this in production, always use some form of verification
enable_registration_without_verification = true;
listeners = [
{
bind_addresses = [ "0.0.0.0" ];
port = 8008;
resources = [
{
"compress" = true;
"names" = [ "client" ];
}
{
"compress" = false;
"names" = [ "federation" ];
}
];
tls = false;
type = "http";
}
];
};
};
services.mautrix-discord = {
enable = true;
registerToSynapse = true; # Enable automatic registration
settings = {
homeserver = {
address = homeserverUrl;
domain = "homeserver";
};
appservice = {
address = "http://homeserver:8009";
port = 8009;
id = "discord";
bot = {
username = "discordbot";
displayname = "Discord bridge bot";
avatar = "mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC";
};
# These will be generated automatically
as_token = "generate";
hs_token = "generate";
database = {
type = "sqlite3";
uri = "file:/var/lib/mautrix-discord/mautrix-discord.db?_txlock=immediate";
};
};
bridge = {
permissions = {
"@alice:homeserver" = "user";
"*" = "relay";
};
};
};
};
networking.firewall.allowedTCPPorts = [
8008
8009
];
};
client =
{ pkgs, ... }:
{
environment.systemPackages = [
(pkgs.writers.writePython3Bin "do_test"
{
libraries = [ pkgs.python3Packages.matrix-nio ];
flakeIgnore = [
"F401" # imported but unused
"E302" # expected 2 blank lines
];
}
''
import sys
import asyncio
from nio import AsyncClient, RoomMessageNotice, RoomCreateResponse
async def message_callback(matrix: AsyncClient, msg: str, _r, e):
print(f"Received message: {msg}")
async def run(homeserver: str):
client = AsyncClient(homeserver, "@test:homeserver")
# Register a new user
response = await client.register("test", "password123")
if not response.transport_response.ok:
print(f"Failed to register: {response}")
return False
# Login
response = await client.login("password123")
if not response.transport_response.ok:
print(f"Failed to login: {response}")
return False
print("Successfully logged in and basic functionality works")
await client.close()
return True
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: do_test <homeserver_url>")
sys.exit(1)
homeserver_url = sys.argv[1]
success = asyncio.run(run(homeserver_url))
sys.exit(0 if success else 1)
''
)
];
};
};
testScript = ''
start_all()
with subtest("wait for homeserver and bridge to be ready"):
homeserver.wait_for_unit("matrix-synapse.service")
homeserver.wait_for_open_port(8008)
homeserver.wait_for_unit("mautrix-discord.service")
homeserver.wait_for_open_port(8009)
with subtest("verify registration file was created"):
homeserver.wait_until_succeeds("test -f /var/lib/mautrix-discord/discord-registration.yaml")
homeserver.succeed("ls -la /var/lib/mautrix-discord/")
with subtest("verify bridge connects to homeserver"):
# Give the bridge a moment to connect
homeserver.sleep(5)
# Check that the bridge is running and listening
homeserver.succeed("systemctl is-active mautrix-discord.service")
homeserver.succeed("netstat -tlnp | grep :8009")
with subtest("test basic matrix functionality"):
client.succeed("do_test ${homeserverUrl} >&2")
'';
}