{ config, lib, pkgs, ... }: let inherit (lib) mkEnableOption mkIf mkMerge mkOption types; cfg = config.services.bsky-news; mkService = botName: bot: operation: { description = "Bluesky ${botName} ${operation} job"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; environment.BSKY_CREDENTIALS_FILE = "%d/bsky"; serviceConfig = { Type = "oneshot"; ExecStart = lib.escapeShellArgs [ (lib.getExe' cfg.package "bsky-news") botName operation ]; LoadCredential = "bsky:${bot.credentialsFile}"; DynamicUser = true; StateDirectory = "bsky-news-${botName}"; WorkingDirectory = "%S/bsky-news-${botName}"; UMask = "0077"; NoNewPrivileges = true; PrivateTmp = true; ProtectHome = true; ProtectSystem = "strict"; ProtectClock = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; RestrictSUIDSGID = true; }; }; mkTimer = unitName: schedule: randomizedDelaySec: { description = "Run ${unitName} periodically"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = schedule; Persistent = true; RandomizedDelaySec = randomizedDelaySec; Unit = "${unitName}.service"; }; }; servicesForBot = botName: bot: let breakingUnit = "bsky-news-${botName}-breaking"; in { ${breakingUnit} = mkService botName bot "breaking"; }; timersForBot = botName: bot: let breakingUnit = "bsky-news-${botName}-breaking"; in { ${breakingUnit} = mkTimer breakingUnit bot.breakingNews.schedule bot.breakingNews.randomizedDelaySec; }; in { options.services.bsky-news = { enable = mkEnableOption "periodic Bluesky news bots"; package = mkOption { type = types.package; default = pkgs.callPackage ./default.nix { }; defaultText = lib.literalExpression "pkgs.callPackage ./default.nix { }"; description = "The bsky-news package to run."; }; bots = mkOption { default = { }; description = "Bluesky bot accounts and their schedules."; type = types.attrsOf (types.submodule ({ ... }: { options = { credentialsFile = mkOption { type = types.str; description = '' Path to a credentials file, normally an agenix secret. It is exposed to the process through systemd's credential directory; BSKY_CREDENTIALS_FILE contains the resulting path. ''; }; breakingNews = { schedule = mkOption { type = types.str; default = "*:0/10"; description = "systemd OnCalendar expression for breaking news."; }; randomizedDelaySec = mkOption { type = types.str; default = "30s"; description = "Maximum random delay applied to breaking-news runs."; }; }; }; })); }; }; config = mkIf cfg.enable { systemd.services = mkMerge (lib.mapAttrsToList servicesForBot cfg.bots); systemd.timers = mkMerge (lib.mapAttrsToList timersForBot cfg.bots); }; }