From d8e8293c0ec5113c6c734bfc0abf521b236fcfb0 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Wed, 13 May 2026 15:08:18 +0200 Subject: [PATCH] feat(mx): add Nextcloud Talk opencode bot pointing at halo.hoyer.tail:8000 Mirrors the existing nextcloud-claude-bot setup but invokes `opencode run` against the local `halo-8000` provider/model. The bot listens on 127.0.0.1:8086, is exposed via the `/_opencode-bot/` location on nc.hoyer.xyz, and uses `@Halo` as its mention trigger in group chats. The opencode config (config/opencode/config.json) is installed into the service's $HOME/.config/opencode/ on each start, so the bot picks up the same provider definition the user uses interactively. The model map keys are renamed to `halo-8000` / `halo-8001` so the canonical `provider/model` reference works without an alias indirection. --- .secrets/hetzner/nextcloud-opencode-bot.yaml | 35 ++ config/opencode/config.json | 4 +- systems/x86_64-linux/mx/default.nix | 1 + .../mx/nextcloud-opencode-bot/bot.py | 325 ++++++++++++++++++ .../mx/nextcloud-opencode-bot/default.nix | 35 ++ .../mx/nextcloud-opencode-bot/module.nix | 178 ++++++++++ 6 files changed, 576 insertions(+), 2 deletions(-) create mode 100644 .secrets/hetzner/nextcloud-opencode-bot.yaml create mode 100644 systems/x86_64-linux/mx/nextcloud-opencode-bot/bot.py create mode 100644 systems/x86_64-linux/mx/nextcloud-opencode-bot/default.nix create mode 100644 systems/x86_64-linux/mx/nextcloud-opencode-bot/module.nix diff --git a/.secrets/hetzner/nextcloud-opencode-bot.yaml b/.secrets/hetzner/nextcloud-opencode-bot.yaml new file mode 100644 index 0000000..9d4bd71 --- /dev/null +++ b/.secrets/hetzner/nextcloud-opencode-bot.yaml @@ -0,0 +1,35 @@ +nextcloud-opencode-bot: + secret: ENC[AES256_GCM,data:TYSUYHzZGvCtJ3aCBS4h73LQHToqDcYV8CNdVqF8NmjdUkxO/6RsXUE3lMR7nE1T8YeJA2F9E3ABLJ71O8WSlg==,iv:Ho0g7HGShHOeso0B+ojwxM8A+P0epFvos/u+fJPZ+zY=,tag:Nk0D/Yt2/tPxCIownuEQnw==,type:str] +sops: + age: + - recipient: age1qur4kh3gay9ryk3jh2snvjp6x9eq94zdrmgkrfcv4fzsu7l6lumq4tr3uy + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjZ1FZUFFicjdKOFRGaTNY + V0NDa2ZIeXJ4LzY3UU5pRnZlY3lQdFBRcVRrCjNNRFh2aE9vWktmWlFSTjVSRG42 + eFcrTzh3SXhFTTNBbGZBOXg5UkZsRHMKLS0tIDFER21GVWw0L29obU5OYk1GR05C + Y3I0OFMxb2xPRjQrNVYvcGNNUmE5V28KkqgFKnwU94rFq9hQMKCoY/xG2M7tlhbj + pjtmQzh36oJ7w7ZOFC/6lShLMD2D8yEPDeqA7idHxVZnAYMf+hbi8g== + -----END AGE ENCRYPTED FILE----- + - recipient: age1dwcz3fmp29ju4svy0t0wz4ylhpwlqa8xpw4l7t4gmgqr0ev37qrsfn840l + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmS25iUFVpZTdYcDJRT2NQ + cFVSWFFaS1V4M2I1UVFLTGdjSFBGdStpc0ZVCmlXZ3FtKzMyV1VJQWZFNzhDR291 + dSt5MUJEZ053TXhQUGNSUVFSNllJOUUKLS0tIGF6TDlPSHIrMkxpaW5LQ2ZOcHlj + c3I4aXFWUU05REN0SGRlY0UvejJKQkkKM2kiJ/dhDI658xwDFJFXWFdGeSl8bAgU + MXH8CyZ8f7ZfHQQAzJAtYgXxCLq+CCA/42yiFbFOCpGYJQBeOJgZpQ== + -----END AGE ENCRYPTED FILE----- + - recipient: age1cpm9xhgue7sjvq7zyeeaxwr96c93sfzxxxj76sxsq7s7kgnygvcq5jxren + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxSTJDS2lSUUlTRzJPTWpa + R1IxSGdNZnpqZXpRcE13THdERHN0RUVZclNvCm1WZEx4VFk5dFBhNERINjNZNEds + OVFHS2JXemUvZEJERkg0SGs0elBDZWcKLS0tIGpKRGF2Mnc5U00zT3JZWnhJZjE0 + bFRwajlkaEhFcjJNaENuOFArS2EwVEkKt4t8Zoa20JJC4IHMNsVK7yvst2dJ9dQl + PMJ/ZUgGcE1fkH1FjfQP/e3LBnxovo+ep3NXFnP1zwje0c+tsXMX6g== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-05-13T13:07:22Z" + mac: ENC[AES256_GCM,data:rnhSDTFAUTty4cMPw8hOrElcVm++kazQaeeVkApovwFwYppMlhanEV5kKRvPEqb53boNoP77K/KlhKVtD6gJprAvCSZhQ7N08AvIBrl/3fqOifSXh/iz/I5HEdwxFSRoHirlb1ZWcdvZRv/owOBtfZZ9S+ul5o89Og7g2Cf+hVg=,iv:DvBtkfYAxk4P1HvHSZANFdJTeDp6W22YW35vvJB5a/A=,tag:E7luVwe0uEXSmJl+GWGGmA==,type:str] + unencrypted_suffix: _unencrypted + version: 3.12.1 diff --git a/config/opencode/config.json b/config/opencode/config.json index ca58be7..00df4a4 100644 --- a/config/opencode/config.json +++ b/config/opencode/config.json @@ -9,7 +9,7 @@ "baseURL": "http://halo.hoyer.tail:8000/v1" }, "models": { - "unsloth/Qwen3.6-27B-GGUF:UD-Q8_K_XL": { "name" : "halo8000" } + "halo-8000": { "name" : "halo-8000" } } }, "halo-8001": { @@ -19,7 +19,7 @@ "baseURL": "http://halo.hoyer.tail:8001/v1" }, "models": { - "placeholder": { "name" : "halo8001" } + "halo-8001": { "name" : "halo-8001" } } } } diff --git a/systems/x86_64-linux/mx/default.nix b/systems/x86_64-linux/mx/default.nix index a3d4285..5372615 100644 --- a/systems/x86_64-linux/mx/default.nix +++ b/systems/x86_64-linux/mx/default.nix @@ -18,6 +18,7 @@ ./network.nix ./nextcloud.nix ./nextcloud-claude-bot + ./nextcloud-opencode-bot ./nginx.nix ./ntfy.nix ./postgresql.nix diff --git a/systems/x86_64-linux/mx/nextcloud-opencode-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-opencode-bot/bot.py new file mode 100644 index 0000000..b300a8f --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-opencode-bot/bot.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +""" +Nextcloud Talk OpenCode Bot + +Receives webhooks from Nextcloud Talk and responds using opencode CLI +against a local model exposed via the `halo-8000` provider. +""" + +import asyncio +import hashlib +import hmac +import json +import logging +import os +import re +import secrets +from datetime import datetime +from typing import Optional + +import httpx +from fastapi import FastAPI, Request, HTTPException, Header +from fastapi.responses import JSONResponse + +NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "").rstrip("/") +OPENCODE_PATH = os.environ.get("OPENCODE_PATH", "opencode") +OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "halo-8000/halo-8000") +ALLOWED_USERS = [u.strip() for u in os.environ.get("ALLOWED_USERS", "").split(",") if u.strip()] +TIMEOUT = int(os.environ.get("TIMEOUT", "120")) +SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", "") +BOT_NAME = os.environ.get("BOT_NAME", "Halo") + + +def get_bot_secret() -> str: + cred_path = os.environ.get("CREDENTIALS_DIRECTORY", "") + if cred_path: + secret_file = os.path.join(cred_path, "bot-secret") + if os.path.exists(secret_file): + with open(secret_file) as f: + return f.read().strip() + return os.environ.get("BOT_SECRET", "") + + +BOT_SECRET = get_bot_secret() + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s" +) +log = logging.getLogger(__name__) + +app = FastAPI(title="Nextcloud OpenCode Bot") + +conversations: dict[str, list[tuple[str, str]]] = {} +MAX_HISTORY = int(os.environ.get("CONTEXT_MESSAGES", "6")) + + +def generate_bot_auth_headers(body: str = "") -> dict: + random = secrets.token_hex(32) + digest = hmac.new( + BOT_SECRET.encode(), + (random + body).encode(), + hashlib.sha256 + ).hexdigest() + return { + "X-Nextcloud-Talk-Bot-Random": random, + "X-Nextcloud-Talk-Bot-Signature": digest, + "OCS-APIRequest": "true", + } + + +def verify_signature(body: bytes, signature: str, random: Optional[str] = None) -> bool: + if not BOT_SECRET: + log.warning("No bot secret configured, skipping signature verification") + return True + + if signature.startswith("sha256="): + signature = signature[7:] + + expected1 = hmac.new(BOT_SECRET.encode(), body, hashlib.sha256).hexdigest() + if random: + expected2 = hmac.new(BOT_SECRET.encode(), (random.encode() + body), hashlib.sha256).hexdigest() + else: + expected2 = None + + if hmac.compare_digest(expected1, signature): + return True + if expected2 and hmac.compare_digest(expected2, signature): + return True + return False + + +BOT_SYSTEM_PROMPT = """\ +Du bist ein KI-Assistent im Nextcloud Talk Chat. +Deine Antworten werden direkt in den Chatraum gepostet. +Halte deine Antworten kurz und prägnant, da es ein Chat ist. +Nutze Markdown für Formatierung wenn sinnvoll. + +Du erhältst: +- : Die letzten Nachrichten im Chatraum (User und deine Antworten) +- : Die aktuelle Nachricht, auf die du antworten sollst""" + + +def build_system_prompt() -> str: + if SYSTEM_PROMPT: + return f"{BOT_SYSTEM_PROMPT}\n\n{SYSTEM_PROMPT.strip()}" + return BOT_SYSTEM_PROMPT + + +def build_prompt(conversation_token: str, current_message: str, current_user: str) -> str: + """Build the full prompt. opencode run has no system-prompt flag, so we + inline the system instructions at the top.""" + parts = [ + "", + build_system_prompt(), + "", + "", + ] + + history = conversations.get(conversation_token, []) + if history: + parts.append("") + for role, msg in history[-MAX_HISTORY:]: + parts.append(f"{role}: {msg}") + parts.append("") + parts.append("") + + parts.append(f"") + parts.append(current_message) + parts.append("") + + return "\n".join(parts) + + +async def call_opencode(prompt: str) -> str: + """Call opencode CLI and return response.""" + cmd = [OPENCODE_PATH, "run", "-m", OPENCODE_MODEL, prompt] + + log.info(f"Calling opencode: {OPENCODE_PATH} run -m {OPENCODE_MODEL} ...") + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=TIMEOUT + ) + + if proc.returncode != 0: + log.error(f"opencode CLI error: {stderr.decode()}") + return f"❌ Fehler beim Aufruf von opencode: {stderr.decode()[:200]}" + + return stdout.decode().strip() + + except asyncio.TimeoutError: + log.error(f"opencode CLI timeout after {TIMEOUT}s") + return f"⏱️ Timeout: opencode hat nicht innerhalb von {TIMEOUT}s geantwortet." + except Exception as e: + log.exception("Error calling opencode") + return f"❌ Fehler: {str(e)}" + + +async def send_reply(conversation_token: str, message: str, reply_to: int = None): + if not NEXTCLOUD_URL: + log.error("NEXTCLOUD_URL not configured") + return + + url = f"{NEXTCLOUD_URL}/ocs/v2.php/apps/spreed/api/v1/bot/{conversation_token}/message" + + headers = generate_bot_auth_headers(message) + headers["Content-Type"] = "application/json" + + payload = { + "message": message, + "referenceId": hashlib.sha256( + f"{conversation_token}-{datetime.now().isoformat()}".encode() + ).hexdigest()[:32], + } + + if reply_to: + payload["replyTo"] = reply_to + + async with httpx.AsyncClient() as client: + try: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code not in (200, 201): + log.error(f"Failed to send reply: {resp.status_code} {resp.text}") + else: + log.info(f"Reply sent to conversation {conversation_token}") + except Exception: + log.exception("Error sending reply to Nextcloud") + + +@app.post("/webhook") +async def handle_webhook( + request: Request, + x_nextcloud_talk_signature: Optional[str] = Header(None, alias="X-Nextcloud-Talk-Signature"), + x_nextcloud_talk_random: Optional[str] = Header(None, alias="X-Nextcloud-Talk-Random"), +): + body = await request.body() + + if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature, x_nextcloud_talk_random): + log.warning("Invalid webhook signature") + raise HTTPException(status_code=401, detail="Invalid signature") + + try: + data = json.loads(body) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON") + + log.info(f"Received webhook: {json.dumps(data, indent=2)[:500]}") + + actor = data.get("actor", {}) + actor_type = actor.get("type", "") + actor_id_full = actor.get("id", "") + + if "/" in actor_id_full: + actor_id = actor_id_full.split("/", 1)[1] + else: + actor_id = actor_id_full + + obj = data.get("object", {}) + message_id = obj.get("id") + content_str = obj.get("content", "{}") + try: + content = json.loads(content_str) + message_text = content.get("message", "") + except json.JSONDecodeError: + message_text = content_str + + target = data.get("target", {}) + conversation_token = target.get("id", "") + + if actor_type not in ("users", "Person"): + log.info(f"Ignoring non-user actor: {actor_type}") + return JSONResponse({"status": "ignored", "reason": "not a user message"}) + + is_direct_message = False + + bot_mentioned = False + clean_message = message_text + + escaped = re.escape(BOT_NAME) + mention_patterns = [ + rf'@"?{escaped}"?\s*', + ] + + for pattern in mention_patterns: + if re.search(pattern, message_text, re.IGNORECASE): + bot_mentioned = True + clean_message = re.sub(pattern, '', message_text, flags=re.IGNORECASE).strip() + break + + if not is_direct_message and not bot_mentioned: + log.info("Ignoring message in group chat without mention") + return JSONResponse({"status": "ignored", "reason": "not mentioned in group chat"}) + + if bot_mentioned: + message_text = clean_message + + if ALLOWED_USERS and actor_id not in ALLOWED_USERS: + log.warning(f"User {actor_id} not in allowed list") + await send_reply( + conversation_token, + "🚫 Du bist nicht berechtigt, diesen Bot zu nutzen.", + reply_to=message_id + ) + return JSONResponse({"status": "rejected", "reason": "user not allowed"}) + + if not message_text.strip(): + return JSONResponse({"status": "ignored", "reason": "empty message"}) + + log.info(f"Processing message from {actor_id}: {message_text[:100]}") + + if message_text.strip().lower() in ("hilfe", "help", "?"): + help_text = f"""🤖 **{BOT_NAME} Bot Hilfe** + +Schreib mir einfach eine Nachricht und ich antworte dir. + +**Nutzung:** +• In Gruppenchats: @{BOT_NAME} gefolgt von deiner Frage + +**Befehle:** +• `hilfe` oder `?` – Diese Hilfe anzeigen + +Modell: `{OPENCODE_MODEL}` +Der Bot merkt sich die letzten Nachrichten pro Raum (bis zum Neustart).""" + await send_reply(conversation_token, help_text, reply_to=message_id) + return JSONResponse({"status": "ok", "action": "help"}) + + prompt = build_prompt(conversation_token, message_text, actor_id) + response = await call_opencode(prompt) + + if conversation_token not in conversations: + conversations[conversation_token] = [] + conversations[conversation_token].append((f"User ({actor_id})", message_text)) + conversations[conversation_token].append(("Assistant", response)) + + if len(conversations[conversation_token]) > MAX_HISTORY * 2: + conversations[conversation_token] = conversations[conversation_token][-MAX_HISTORY * 2:] + + await send_reply(conversation_token, response, reply_to=message_id) + + return JSONResponse({"status": "ok"}) + + +@app.get("/health") +async def health(): + return { + "status": "ok", + "nextcloud_url": NEXTCLOUD_URL, + "opencode_path": OPENCODE_PATH, + "opencode_model": OPENCODE_MODEL, + "bot_name": BOT_NAME, + "allowed_users": ALLOWED_USERS if ALLOWED_USERS else "all", + "max_history": MAX_HISTORY, + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=8086) diff --git a/systems/x86_64-linux/mx/nextcloud-opencode-bot/default.nix b/systems/x86_64-linux/mx/nextcloud-opencode-bot/default.nix new file mode 100644 index 0000000..fba9606 --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-opencode-bot/default.nix @@ -0,0 +1,35 @@ +{ config, ... }: +{ + imports = [ ./module.nix ]; + + services.nextcloud-opencode-bot = { + enable = true; + nextcloudUrl = "https://nc.hoyer.xyz"; + botSecretFile = config.sops.secrets."nextcloud-opencode-bot/secret".path; + opencodeConfig = ../../../../config/opencode/config.json; + model = "halo-8000/halo-8000"; + botName = "Halo"; + allowedUsers = [ ]; + }; + + sops.secrets."nextcloud-opencode-bot/secret" = { + sopsFile = ../../../../.secrets/hetzner/nextcloud-opencode-bot.yaml; + restartUnits = [ "nextcloud-opencode-bot.service" ]; + owner = "opencode-bot"; + }; + + # Nginx location for Nextcloud to send webhooks to the bot + services.nginx.virtualHosts."nc.hoyer.xyz".locations."/_opencode-bot/" = { + proxyPass = "http://127.0.0.1:8086/"; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Only allow from localhost (Nextcloud on same server) + allow 127.0.0.1; + deny all; + ''; + }; +} diff --git a/systems/x86_64-linux/mx/nextcloud-opencode-bot/module.nix b/systems/x86_64-linux/mx/nextcloud-opencode-bot/module.nix new file mode 100644 index 0000000..39daecb --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-opencode-bot/module.nix @@ -0,0 +1,178 @@ +{ + 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 = { }; + }; +}