{ config, pkgs, pkgs-unstable, lib, ... }: with lib; let cfg = config.services.matrix-vm.mautrix-whatsapp; yamlFormat = pkgs.formats.yaml { }; registrationDir = builtins.dirOf (toString cfg.appservice.registrationFile); baseConfig = { homeserver = { address = cfg.matrix.homeserverUrl; domain = cfg.matrix.serverName; verify_ssl = cfg.matrix.verifySSL; }; database = { type = cfg.database.type; uri = cfg.database.uri; max_open_conns = cfg.database.maxOpenConnections; max_idle_conns = cfg.database.maxIdleConnections; }; appservice = { address = "http://${cfg.appservice.hostname}:${toString cfg.appservice.port}"; hostname = cfg.appservice.hostname; port = cfg.appservice.port; id = cfg.appservice.id; bot = { username = cfg.appservice.senderLocalpart; displayname = cfg.appservice.botDisplayName; avatar = cfg.appservice.botAvatar; }; public_address = cfg.appservice.publicAddress; username_template = "${cfg.appservice.userPrefix}{{.}}"; protocols = cfg.appservice.protocols; "de.sorunome.msc2409.push_ephemeral" = cfg.appservice.pushEphemeral; }; network = { os_name = cfg.network.osName; browser_name = cfg.network.browserName; direct_media_auto_request = cfg.network.directMediaAutoRequest; initial_auto_reconnect = cfg.network.initialAutoReconnect; history_sync = { max_initial_conversations = cfg.network.historySync.maxInitialConversations; request_full_sync = cfg.network.historySync.requestFullSync; dispatch_wait = cfg.network.historySync.dispatchWait; media_requests = { auto_request_media = cfg.network.historySync.mediaRequests.autoRequest; request_method = cfg.network.historySync.mediaRequests.requestMethod; request_local_time = cfg.network.historySync.mediaRequests.requestLocalTime; max_async_handle = cfg.network.historySync.mediaRequests.maxAsyncHandle; }; }; }; bridge = { command_prefix = cfg.bridge.commandPrefix; personal_filtering_spaces = cfg.bridge.personalFilteringSpaces; bridge_status_notices = cfg.bridge.bridgeStatusNotices; permissions = cfg.bridge.permissions; cleanup_on_logout = { enabled = cfg.bridge.cleanupOnLogout != "nothing"; }; }; double_puppet = { servers = cfg.bridge.additionalDoublePuppetServers; allow_discovery = cfg.bridge.allowDiscovery; secrets = {}; }; encryption = { allow = cfg.encryption.enable; default = cfg.encryption.default; require = cfg.encryption.require; allow_key_sharing = cfg.encryption.allowKeySharing; }; analytics = { token = null; }; provisioning = { shared_secret = if cfg.appservice.provisioningSharedSecretFile != null then "__credential__" else "disable"; }; }; configBaseFile = yamlFormat.generate "mautrix-whatsapp-config-base.yaml" baseConfig; registrationBase = { id = cfg.appservice.id; url = "http://${cfg.appservice.hostname}:${toString cfg.appservice.port}"; sender_localpart = cfg.appservice.senderLocalpart; rate_limited = false; namespaces = { users = [{ regex = "@${cfg.appservice.userPrefix}.+:${cfg.matrix.serverName}"; exclusive = true; }]; aliases = [{ regex = "#${cfg.appservice.aliasPrefix}.*:${cfg.matrix.serverName}"; exclusive = true; }]; rooms = []; }; "de.sorunome.msc2409.push_ephemeral" = cfg.appservice.pushEphemeral; }; registrationBaseFile = yamlFormat.generate "mautrix-whatsapp-registration-base.yaml" registrationBase; extraConfigFile = optionalString (cfg.extraConfig != { }) (toString (yamlFormat.generate "mautrix-whatsapp-extra.yaml" cfg.extraConfig)); generateConfigScript = pkgs.writeShellScript "generate-mautrix-whatsapp-config" '' set -euo pipefail CONFIG_OUT="/run/${cfg.runtimeDirectory}/config.yaml" REG_OUT="${cfg.appservice.registrationFile}" # Runtime directories created by systemd.tmpfiles.d ${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 <<'PY' import os import pathlib import pwd import grp import yaml base_config_path = pathlib.Path("${configBaseFile}") config_output = pathlib.Path("/run/${cfg.runtimeDirectory}/config.yaml") registration_base_path = pathlib.Path("${registrationBaseFile}") registration_output = pathlib.Path("${cfg.appservice.registrationFile}") extra_config_path = "${extraConfigFile}" with base_config_path.open("r", encoding="utf-8") as fh: config = yaml.safe_load(fh) if extra_config_path: with open(extra_config_path, "r", encoding="utf-8") as fh: extra_cfg = yaml.safe_load(fh) or {} def deep_merge(base, extra): for key, value in extra.items(): if isinstance(value, dict) and isinstance(base.get(key), dict): deep_merge(base[key], value) else: base[key] = value return base deep_merge(config, extra_cfg) creds_dir = pathlib.Path(os.environ.get("CREDENTIALS_DIRECTORY", "")) if not creds_dir: raise SystemExit("CREDENTIALS_DIRECTORY not provided") def read_required(name): path = creds_dir / name if not path.exists(): raise SystemExit(f"Missing credential {name}") return path.read_text(encoding="utf-8").strip() def read_optional(name): path = creds_dir / name if path.exists(): return path.read_text(encoding="utf-8").strip() return None as_token = read_required("as_token") hs_token = read_required("hs_token") login_secret = read_optional("login_shared_secret") provisioning_secret = read_optional("provisioning_shared_secret") config.setdefault("double_puppet", {}).setdefault("secrets", {}) if login_secret: config["double_puppet"]["secrets"]["${cfg.matrix.serverName}"] = login_secret if provisioning_secret: config.setdefault("provisioning", {})["shared_secret"] = provisioning_secret else: config.setdefault("provisioning", {})["shared_secret"] = "disable" with registration_base_path.open("r", encoding="utf-8") as fh: registration = yaml.safe_load(fh) registration["as_token"] = as_token registration["hs_token"] = hs_token # Add tokens to config appservice section config.setdefault("appservice", {})["as_token"] = as_token config.setdefault("appservice", {})["hs_token"] = hs_token config_output.parent.mkdir(parents=True, exist_ok=True) with config_output.open("w", encoding="utf-8") as fh: yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=False) registration_output.parent.mkdir(parents=True, exist_ok=True) with registration_output.open("w", encoding="utf-8") as fh: yaml.safe_dump(registration, fh, default_flow_style=False, sort_keys=False) svc_uid = pwd.getpwnam("${cfg.user}").pw_uid svc_gid = grp.getgrnam("${cfg.group}").gr_gid reg_uid = pwd.getpwnam("${cfg.appservice.registrationUser}").pw_uid reg_gid = grp.getgrnam("${cfg.appservice.registrationGroup}").gr_gid os.chown(config_output, svc_uid, svc_gid) os.chmod(config_output, 0o640) os.chown(registration_output, reg_uid, reg_gid) os.chmod(registration_output, 0o640) PY ''; loadCredentials = [ "as_token:${cfg.appservice.asTokenFile}" "hs_token:${cfg.appservice.hsTokenFile}" ] ++ optional (cfg.matrix.loginSharedSecretFile != null) "login_shared_secret:${cfg.matrix.loginSharedSecretFile}" ++ optional (cfg.appservice.provisioningSharedSecretFile != null) "provisioning_shared_secret:${cfg.appservice.provisioningSharedSecretFile}"; in { options.services.matrix-vm.mautrix-whatsapp = { enable = mkEnableOption "mautrix-whatsapp bridge"; package = mkOption { type = types.package; default = pkgs-unstable.mautrix-whatsapp; description = "Package providing the bridge executable."; }; user = mkOption { type = types.str; default = "mautrix_whatsapp"; description = "System user for the bridge."; }; group = mkOption { type = types.str; default = "mautrix_whatsapp"; description = "Primary group for the bridge."; }; dataDir = mkOption { type = types.path; default = "/var/lib/mautrix_whatsapp"; description = "State directory."; }; runtimeDirectory = mkOption { type = types.str; default = "mautrix_whatsapp"; description = "Runtime directory name under /run."; }; extraPackages = mkOption { type = types.listOf types.package; default = []; description = "Additional packages exposed to the service PATH."; }; extraConfig = mkOption { type = yamlFormat.type; default = {}; description = "Optional YAML fragment merged into the config."; }; matrix = { homeserverUrl = mkOption { type = types.str; default = "http://127.0.0.1:6167"; description = "Homeserver URL (bridge -> HS)."; }; serverName = mkOption { type = types.str; default = "example.com"; description = "Matrix server name."; }; verifySSL = mkOption { type = types.bool; default = false; description = "Verify TLS for homeserver requests."; }; loginSharedSecretFile = mkOption { type = types.nullOr types.path; default = null; description = "Shared secret file for automatic double puppeting."; }; }; database = { type = mkOption { type = types.enum [ "postgres" "sqlite3-fk-wal" ]; default = "postgres"; description = "Database backend."; }; uri = mkOption { type = types.str; default = "postgresql:///mautrix_whatsapp?host=/run/postgresql"; description = "Database connection string."; }; maxOpenConnections = mkOption { type = types.ints.positive; default = 10; description = "Maximum open DB connections."; }; maxIdleConnections = mkOption { type = types.ints.unsigned; default = 2; description = "Maximum idle DB connections."; }; }; appservice = { id = mkOption { type = types.str; default = "whatsapp"; description = "Appservice ID."; }; senderLocalpart = mkOption { type = types.str; default = "whatsappbot"; description = "Bot localpart."; }; botDisplayName = mkOption { type = types.nullOr types.str; default = "WhatsApp bridge"; description = "Bot display name."; }; botAvatar = mkOption { type = types.nullOr types.str; default = "mxc://maunium.net/NeXNQarUbrlYBiPCpprYsRqr"; description = "Bot avatar MXC URI."; }; hostname = mkOption { type = types.str; default = "127.0.0.1"; description = "Bind address for bridge HTTP listener."; }; port = mkOption { type = types.port; default = 29318; description = "Port for bridge HTTP listener."; }; publicAddress = mkOption { type = types.nullOr types.str; default = null; description = "Public URL for bridge (optional)."; }; asTokenFile = mkOption { type = types.path; description = "Path containing the appservice as_token."; }; hsTokenFile = mkOption { type = types.path; description = "Path containing the appservice hs_token."; }; provisioningSharedSecretFile = mkOption { type = types.nullOr types.path; default = null; description = "Provisioning API shared secret file (optional)."; }; registrationFile = mkOption { type = types.path; default = "/var/lib/matrix-appservices/mautrix_whatsapp_registration.yaml"; description = "Generated registration file path."; }; registrationUser = mkOption { type = types.str; default = cfg.user; description = "Owner user for the registration file."; }; registrationGroup = mkOption { type = types.str; default = cfg.group; description = "Owner group for the registration file."; }; userPrefix = mkOption { type = types.str; default = "whatsapp_"; description = "MXID prefix for puppets."; }; aliasPrefix = mkOption { type = types.str; default = "whatsapp_"; description = "Alias prefix for portals."; }; protocols = mkOption { type = types.listOf types.str; default = []; description = "Optional MSC3079 protocol identifiers."; }; pushEphemeral = mkOption { type = types.bool; default = false; description = "Enable MSC2409 push ephemeral events."; }; }; network = { osName = mkOption { type = types.str; default = "mautrix-whatsapp"; description = "Device name shown in WhatsApp linked devices."; }; browserName = mkOption { type = types.str; default = "unknown"; description = "Browser indicator shown in WhatsApp linked devices."; }; directMediaAutoRequest = mkOption { type = types.bool; default = true; description = "Automatically request expired media during backfill."; }; initialAutoReconnect = mkOption { type = types.bool; default = true; description = "Retry connection failures automatically on startup."; }; historySync = { maxInitialConversations = mkOption { type = types.int; default = -1; description = "Chats to create on initial history sync (-1 = all)."; }; requestFullSync = mkOption { type = types.bool; default = false; description = "Request a full-year history sync on login."; }; dispatchWait = mkOption { type = types.str; default = "1m"; description = "Wait time for history payloads before backfill."; }; mediaRequests = { autoRequest = mkOption { type = types.bool; default = true; description = "Automatically request expired media."; }; requestMethod = mkOption { type = types.enum [ "immediate" "local_time" ]; default = "immediate"; description = "Scheduling mode for automatic media requests."; }; requestLocalTime = mkOption { type = types.int; default = 120; description = "Minutes after midnight when using local_time scheduling."; }; maxAsyncHandle = mkOption { type = types.ints.positive; default = 2; description = "Maximum concurrent media requests per user."; }; }; }; }; bridge = { commandPrefix = mkOption { type = types.str; default = "!wa"; description = "Command prefix for management commands."; }; personalFilteringSpaces = mkOption { type = types.bool; default = true; description = "Create personal spaces for each login."; }; bridgeStatusNotices = mkOption { type = types.enum [ "none" "errors" "all" ]; default = "errors"; description = "Status notice verbosity."; }; cleanupOnLogout = mkOption { type = types.enum [ "nothing" "kick" "unbridge" "delete" ]; default = "nothing"; description = "Portal cleanup behaviour on logout."; }; permissions = mkOption { type = types.attrsOf (types.enum [ "relay" "commands" "user" "admin" ]); default = { "*" = "relay"; }; description = "Permission mapping for users/domains."; }; allowDiscovery = mkOption { type = types.bool; default = false; description = "Allow double puppeting discovery for remote homeservers."; }; additionalDoublePuppetServers = mkOption { type = types.attrsOf types.str; default = {}; description = "Extra homeservers permitted for double puppeting (domain -> client API URL)."; }; }; encryption = { enable = mkOption { type = types.bool; default = false; description = "Enable end-to-bridge encryption."; }; default = mkOption { type = types.bool; default = false; description = "Enable encryption in newly created portals."; }; require = mkOption { type = types.bool; default = false; description = "Drop plaintext messages in encrypted rooms."; }; allowKeySharing = mkOption { type = types.bool; default = true; description = "Fulfil inbound key requests."; }; }; }; config = mkIf cfg.enable { users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; home = cfg.dataDir; createHome = false; }; users.groups.${cfg.group} = {}; systemd.tmpfiles.rules = [ "d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -" "d ${registrationDir} 2770 root matrix-appservices -" "d /run/${cfg.runtimeDirectory} 0750 ${cfg.user} ${cfg.group} -" ]; systemd.services.mautrix-whatsapp = { description = "mautrix-whatsapp Matrix bridge"; after = [ "network.target" "network-online.target" ] ++ optional (cfg.database.type == "postgres") "postgresql.service"; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; path = [ cfg.package ] ++ cfg.extraPackages; serviceConfig = { Type = "simple"; User = cfg.user; Group = cfg.group; WorkingDirectory = cfg.dataDir; # Runtime directory created by systemd.tmpfiles.d ExecStartPre = generateConfigScript; ExecStart = "${cfg.package}/bin/mautrix-whatsapp -c /run/${cfg.runtimeDirectory}/config.yaml"; Restart = "always"; RestartSec = "10s"; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; PrivateDevices = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectKernelLogs = true; ProtectClock = true; ProtectControlGroups = true; RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; RestrictSUIDSGID = true; RestrictRealtime = true; LockPersonality = true; UMask = "0002"; SystemCallArchitectures = "native"; ReadWritePaths = [ cfg.dataDir cfg.appservice.registrationFile registrationDir ]; LoadCredential = loadCredentials; }; }; }; }