{ config, lib, pkgs, ... }: with lib; let cfg = config.services.nextcloud-opencode-bot; pythonEnv = pkgs.python3.withPackages ( ps: with ps; [ fastapi uvicorn httpx ] ); botModule = pkgs.runCommand "nextcloud-opencode-bot-module" { } '' mkdir -p $out cp ${./bot.py} $out/nextcloud_opencode_bot.py ''; in { options.services.nextcloud-opencode-bot = { enable = mkEnableOption "Nextcloud Talk OpenCode Bot"; port = mkOption { type = types.port; default = 8086; description = "Port for the webhook listener"; }; host = mkOption { type = types.str; default = "127.0.0.1"; description = "Host to bind to"; }; nextcloudUrl = mkOption { type = types.str; example = "https://cloud.example.com"; description = "Base URL of your Nextcloud instance"; }; botSecretFile = mkOption { type = types.path; description = "Path to file containing the bot secret (shared with Nextcloud)"; }; opencodePath = mkOption { type = types.path; default = "${pkgs.opencode}/bin/opencode"; description = "Path to opencode CLI binary"; }; opencodeConfig = mkOption { type = types.path; description = "Path to the opencode config.json file (placed at $HOME/.config/opencode/config.json on service start)"; }; model = mkOption { type = types.str; default = "halo-8000/halo-8000"; description = "Model identifier passed to `opencode run -m`"; }; botName = mkOption { type = types.str; default = "Halo"; description = "Bot display name (used to detect @mentions in group chats)"; }; allowedUsers = mkOption { type = types.listOf types.str; default = [ ]; description = "Nextcloud usernames allowed to talk to the bot (empty = all)"; }; contextMessages = mkOption { type = types.int; default = 6; description = "Number of recent messages to keep as context"; }; timeout = mkOption { type = types.int; default = 120; description = "Timeout in seconds for opencode CLI"; }; systemPrompt = mkOption { type = types.nullOr types.str; default = null; description = "Optional additional system prompt appended to the built-in one"; }; }; config = mkIf cfg.enable { systemd.services.nextcloud-opencode-bot = { description = "Nextcloud Talk OpenCode Bot"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; path = with pkgs; [ bash coreutils git curl jq ]; environment = { HOME = "/var/lib/nextcloud-opencode-bot"; BOT_HOST = cfg.host; BOT_PORT = toString cfg.port; NEXTCLOUD_URL = cfg.nextcloudUrl; OPENCODE_PATH = cfg.opencodePath; OPENCODE_MODEL = cfg.model; BOT_NAME = cfg.botName; ALLOWED_USERS = concatStringsSep "," cfg.allowedUsers; CONTEXT_MESSAGES = toString cfg.contextMessages; TIMEOUT = toString cfg.timeout; SYSTEM_PROMPT = cfg.systemPrompt or ""; PYTHONPATH = botModule; }; serviceConfig = { Type = "simple"; # Materialize the opencode config at the path opencode looks for by # default ($HOME/.config/opencode/config.json). We copy rather than # symlink so opencode's config loader sees a regular file. ExecStartPre = pkgs.writeShellScript "install-opencode-config" '' set -eu install -d -m 0700 "$HOME/.config/opencode" install -m 0600 ${cfg.opencodeConfig} "$HOME/.config/opencode/config.json" ''; ExecStart = "${pythonEnv}/bin/uvicorn nextcloud_opencode_bot:app --host ${cfg.host} --port ${toString cfg.port}"; Restart = "always"; RestartSec = 5; User = "opencode-bot"; Group = "opencode-bot"; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = "read-only"; PrivateTmp = true; PrivateDevices = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; MemoryDenyWriteExecute = false; LockPersonality = true; LoadCredential = "bot-secret:${cfg.botSecretFile}"; StateDirectory = "nextcloud-opencode-bot"; }; }; users.users.opencode-bot = { isSystemUser = true; group = "opencode-bot"; home = "/var/lib/nextcloud-opencode-bot"; }; users.groups.opencode-bot = { }; }; }