Something went wrong. Try again.
A streaming bot for Streamplace bot.timtinkers.online
bot streamplace atproto
Something went wrong. Try again.
streamplace-bot module.nix
8.8 kB · 286 lines
Nix
at frontend
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287{ lib, pkgs, config, ...}:let cfg = config.services.streamplace-bot;
inherit (lib) types mkOption mkEnableOption;
toEnvString = v: if builtins.isBool v then (if v then "true" else "false") else toString v;
# Everything that isn't a secret goes here and gets baked into a plain # EnvironmentFile. Secrets (BOT_PASSWORD) stay out of the Nix store via # cfg.environmentFiles instead. generatedSettings = cfg.extraEnvironment // { PUBLIC_BOT_IDENTIFIER = cfg.botIdentifier; PUBLIC_JETSTREAM_URL = cfg.jetstreamUrl; PUBLIC_CDN_URL = cfg.cdnUrl; PUBLIC_FOLLOWER_MODE = cfg.followerMode; PUBLIC_BACKEND_PORT = cfg.port; };
generatedEnvFile = pkgs.writeText "streamplace-bot.env" ( lib.concatStrings ( lib.mapAttrsToList (name: value: "${name}=${toEnvString value}\n") generatedSettings ) );in{ _class = "nixos";
options.services.streamplace-bot = { enable = mkEnableOption "streamplace-bot: a streaming bot for Streamplace";
package = mkOption { type = types.package; default = pkgs.callPackage ./default.nix { }; defaultText = lib.literalExpression "pkgs.callPackage ./default.nix { }"; description = "The streamplace-bot backend package to run."; };
user = mkOption { type = types.str; default = "streamplace-bot"; description = "User under which streamplace-bot runs."; };
group = mkOption { type = types.str; default = "streamplace-bot"; description = "Group under which streamplace-bot runs."; };
dataDir = mkOption { type = types.str; default = "/var/lib/streamplace-bot"; description = "Working/state directory for streamplace-bot."; };
environmentFiles = mkOption { type = types.listOf types.path; default = [ ]; description = '' Files to load secret environment variables from (outside the Nix store), layered on top of the generated non-secret settings below.
Use this for at least `BOT_PASSWORD`. ''; };
botIdentifier = mkOption { type = types.str; example = "bot.timtinkers.online"; description = "Handle or DID of the bot account (PUBLIC_BOT_IDENTIFIER)."; };
jetstreamUrl = mkOption { type = types.str; default = "wss://jetstream1.us-east.bsky.network/subscribe"; description = "Jetstream instance URL (PUBLIC_JETSTREAM_URL)."; };
cdnUrl = mkOption { type = types.str; default = "https://cdn.bsky.app/img"; description = "CDN URL used to display images (PUBLIC_CDN_URL)."; };
followerMode = mkOption { type = types.bool; default = false; description = '' Whether the bot should also operate in the Streamplace chats of the bot account's followers (FOLLOWER_MODE). ''; };
port = mkOption { type = types.port; default = 8765; description = "Port the websocket/XRPC backend listens on (PUBLIC_BACKEND_PORT)."; };
extraEnvironment = mkOption { type = types.attrsOf ( types.oneOf [ types.str types.int types.bool ] ); default = { }; description = "Additional non-secret environment variables, merged in as-is."; };
frontend = { enable = mkEnableOption "building/serving the Svelte frontend" // { default = true; };
backendUrl = mkOption { type = types.str; default = ""; example = "https://bot.example.com"; description = '' Backend URL baked into the frontend build. Leave empty to assume same-origin (i.e. you're using `nginx.enable` or your own reverse proxy in front of both). Only takes effect when `package` is left at its default - if you supply your own package this is ignored, since the value has to be baked in at build time. ''; };
package = mkOption { type = types.nullOr types.package; default = pkgs.callPackage ./frontend.nix { inherit (cfg.frontend) backendUrl; backendPort = cfg.port; inherit (cfg) botIdentifier cdnUrl; }; defaultText = lib.literalExpression "pkgs.callPackage ./frontend.nix { }"; description = "Static frontend package to serve. Set to null for a backend-only deployment."; }; };
nginx = { enable = mkEnableOption '' an nginx virtual host that serves the frontend and reverse-proxies the backend, mirroring what vite.config.ts's dev-server proxy does for /api/websocket. Leave disabled if you're fronting this with your own nginx/Caddy/whatever config instead '';
hostName = mkOption { type = types.str; example = "bot.example.com"; description = "Virtual host name to serve the frontend and proxy on."; };
proxyPaths = mkOption { type = types.listOf types.str; default = [ "/api" "/xrpc" ]; description = "URL path prefixes reverse-proxied to the backend."; };
enableACME = mkOption { type = types.bool; default = true; description = '' Request an ACME certificate and force HTTPS for this vhost. Turn off if you're fronting `hostName` with your own certs/proxy, or testing locally without a resolvable domain. ''; }; }; };
config = lib.mkIf cfg.enable ( lib.mkMerge [ { users.users.${cfg.user} = { isSystemUser = true; inherit (cfg) group; home = cfg.dataDir; };
users.groups.${cfg.group} = { };
systemd.tmpfiles.settings."streamplace-bot" = { ${cfg.dataDir}.d = { mode = "0750"; inherit (cfg) user group; }; };
systemd.services.streamplace-bot = { description = "streamplace-bot: a streaming bot for Streamplace"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { User = cfg.user; Group = cfg.group; ExecStart = lib.getExe cfg.package; Restart = "on-failure"; RestartSec = 5;
WorkingDirectory = cfg.dataDir; StateDirectory = "streamplace-bot";
EnvironmentFile = [ generatedEnvFile ] ++ cfg.environmentFiles;
NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; PrivateDevices = true; PrivateUsers = true; ProtectHostname = true; ProtectClock = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectKernelLogs = true; ProtectControlGroups = true; RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; RestrictNamespaces = true; LockPersonality = true; # Must stay false: V8's JIT needs W^X page transitions to # generate machine code at isolate startup, and this seccomp # filter blocks that outright, crashing node before it even # gets to user code (V8_Fatal in OS::SetPermissions). MemoryDenyWriteExecute = false; RestrictRealtime = true; RestrictSUIDSGID = true; RemoveIPC = true; PrivateMounts = true; SystemCallFilter = [ "@system-service" "~@privileged @resources" ]; SystemCallArchitectures = "native"; }; }; }
(lib.mkIf cfg.nginx.enable { services.nginx = { enable = true; recommendedProxySettings = true;
virtualHosts.${cfg.nginx.hostName} = { inherit (cfg.nginx) enableACME; forceSSL = cfg.nginx.enableACME;
locations = (lib.listToAttrs ( map (path: { name = path; value = { proxyPass = "http://127.0.0.1:${toString cfg.port}"; proxyWebsockets = true; }; }) cfg.nginx.proxyPaths )) // (lib.optionalAttrs (cfg.frontend.enable && cfg.frontend.package != null) { "/" = { root = cfg.frontend.package; tryFiles = "$uri $uri/ /index.html"; }; }); }; }; }) ] );}