From bc6091f63f805b2f91d3cab32c157a4c823acf79 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 15:40:57 +0100 Subject: [PATCH] feat(nix): add Nextcloud Claude Bot integration - Added configuration for Nextcloud Claude Bot, including NixOS module, secrets management, and example setup files. - Introduced a Python-based HTTP server for handling webhook events and interacting with Nextcloud Talk. - Integrated necessary dependencies and systemd service for seamless operation. --- .secrets/hetzner/nextcloud-claude-bot.yaml | 35 +++ systems/x86_64-linux/mx/default.nix | 1 + .../mx/nextcloud-claude-bot/README.md | 146 +++++++++ .../mx/nextcloud-claude-bot/bot.py | 294 ++++++++++++++++++ .../mx/nextcloud-claude-bot/default.nix | 31 ++ .../nextcloud-claude-bot/example-config.nix | 80 +++++ .../mx/nextcloud-claude-bot/module.nix | 141 +++++++++ 7 files changed, 728 insertions(+) create mode 100644 .secrets/hetzner/nextcloud-claude-bot.yaml create mode 100644 systems/x86_64-linux/mx/nextcloud-claude-bot/README.md create mode 100644 systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py create mode 100644 systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix create mode 100644 systems/x86_64-linux/mx/nextcloud-claude-bot/example-config.nix create mode 100644 systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix diff --git a/.secrets/hetzner/nextcloud-claude-bot.yaml b/.secrets/hetzner/nextcloud-claude-bot.yaml new file mode 100644 index 0000000..4d638f0 --- /dev/null +++ b/.secrets/hetzner/nextcloud-claude-bot.yaml @@ -0,0 +1,35 @@ +nextcloud-claude-bot: + secret: ENC[AES256_GCM,data:I0YxTjU89dDFnpF/TwZYBliLDyre0kNZbWvJD5Jdleihe1LGEptcLuTN0lkO9I8z9U7GDGxoAprb8W+5d2MQrA==,iv:m/q82cfbFID0aW3KfXCZSIa7FhtGx/3TCxv5x8GXVk0=,tag:+IuHUKVqdGrU0RS18NUlPg==,type:str] +sops: + age: + - recipient: age1qur4kh3gay9ryk3jh2snvjp6x9eq94zdrmgkrfcv4fzsu7l6lumq4tr3uy + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwUkZhbzhZbHZQRDJvZW1q + QjlIS2IxL1NaSnZ6L3JDU3hENEh4SFBBLzN3CnRvZkFmOUIzWUgybGdPblp4UmRH + U1JmUCt5WkNUc09EdktUdFBHY0lKUFkKLS0tIDZHRXRtZTBROGJJcFhMVDM4ZDJt + OGZOUElSNGJmaEtPalQ5MXBxQUFaRFkKu2EIbPsNMkejgc2rVC/nL5G2Hfp1IkiA + 3CV36NHFXKRlo8Fxj+hl1Fi063TRlNW0TK5fc15u4En7tdMnCdfJ+A== + -----END AGE ENCRYPTED FILE----- + - recipient: age1dwcz3fmp29ju4svy0t0wz4ylhpwlqa8xpw4l7t4gmgqr0ev37qrsfn840l + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPQTd4U0ZVOHJKMUgyLzF5 + RkpiVmMzYTRZS21ZUnNERTB3K2pDSXpFMlVjClkxNDl6WlcyN0xBT3MzYWVOWnNL + UldRZER4YVFuSHZ0S3BMSVZLQm5pRWcKLS0tIEpZVlA2RFZGbElUQWVWb3c5OSt3 + WlpSVGx4OEJGYU52L2xkdmNteWdGUE0KS0Xa9GmwTiAURgC72OhNLHW1/XgHyHFZ + 4yQ2qri2m14E5oheB8ELzMMY9K/yQUs90UqdZIS8UoSeaG4GqjEuQA== + -----END AGE ENCRYPTED FILE----- + - recipient: age1cpm9xhgue7sjvq7zyeeaxwr96c93sfzxxxj76sxsq7s7kgnygvcq5jxren + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4YjNVSE9mdjhKY2hWVGJ3 + d3VnVDBJODRyMkRyMUJUREwvT0ZUUkRtVUU0CjFOWCtFK05saHNTWGRoazQ2aVgw + bnlPMUNmdVVSUEFoVEtkaXcwVklETm8KLS0tIFBWMERoR0ZiMDJ1bW5May9RSWlv + VktQbU9STjNRVTh6TndIRVBLdFVFUVkKz0dBpDQ9+/Pp3FKsBpcmzuEROsZ65jkw + 9LRQTMGF6kSrbLjRkBs21t5t2kunKgCriAmd8Nv+S/sG/NKqpQMJ6A== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-02-03T14:36:03Z" + mac: ENC[AES256_GCM,data:y0gOLMHjzv2ER+Bvo0glkY2EC28K2uWtPPhv0EDzb4PczGDNgQWGHhdyFsN07+JJIf2LMpKV1u7BMp4e/dF1wDgZsR6wErZLxuLrXfZ6B7mTDOPGUR1rGo5PhbNIO90LL5uQ/aRLl38efqxgU8fHCkuXJkUtM38UQ9+7JN4PVic=,iv:2AKgYujqxeGiiVMhqC8FGFiYbTcogxZx/uUgh+8XowQ=,tag:3RwH2AboBU9T25fWjecsMQ==,type:str] + unencrypted_suffix: _unencrypted + version: 3.11.0 diff --git a/systems/x86_64-linux/mx/default.nix b/systems/x86_64-linux/mx/default.nix index d2130c7..3efb22c 100644 --- a/systems/x86_64-linux/mx/default.nix +++ b/systems/x86_64-linux/mx/default.nix @@ -12,6 +12,7 @@ ./mailserver.nix ./network.nix ./nextcloud.nix + ./nextcloud-claude-bot ./nginx.nix ./postgresql.nix ./rspamd.nix diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/README.md b/systems/x86_64-linux/mx/nextcloud-claude-bot/README.md new file mode 100644 index 0000000..a16b3f8 --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/README.md @@ -0,0 +1,146 @@ +# Nextcloud Claude Bot Setup + +## Voraussetzungen + +- NixOS Server mit Nextcloud (Talk App aktiviert) +- Claude Code CLI installiert und authentifiziert +- Nextcloud Talk Version 17+ (Nextcloud 26+) + +## 1. Bot Secret generieren + +```bash +openssl rand -hex 32 > /var/secrets/nextcloud-claude-bot +chmod 600 /var/secrets/nextcloud-claude-bot +``` + +## 2. NixOS Konfiguration + +Kopiere die Dateien nach `/etc/nixos/nextcloud-claude-bot/` oder in dein Flake: + +``` +/etc/nixos/ +├── configuration.nix +└── nextcloud-claude-bot/ + ├── module.nix + └── bot.py +``` + +Füge das Modul zu deiner `configuration.nix` hinzu (siehe `example-config.nix`). + +## 3. System rebuilden + +```bash +nixos-rebuild switch +``` + +## 4. Bot bei Nextcloud registrieren + +```bash +# Als root oder mit sudo +cd /var/www/nextcloud # oder wo dein Nextcloud liegt + +# Bot secret auslesen +BOT_SECRET=$(cat /var/secrets/nextcloud-claude-bot) + +# Bot installieren +sudo -u nextcloud php occ talk:bot:install \ + "Claude" \ + "Claude AI Assistant" \ + "http://127.0.0.1:8085/webhook" \ + "$BOT_SECRET" +``` + +Falls der Bot extern erreichbar sein muss: +```bash +sudo -u nextcloud php occ talk:bot:install \ + "Claude" \ + "Claude AI Assistant" \ + "https://cloud.example.com/_claude-bot/webhook" \ + "$BOT_SECRET" +``` + +## 5. Bot aktivieren + +Nach der Installation musst du den Bot für Konversationen aktivieren: + +```bash +# Liste alle Bots +sudo -u nextcloud php occ talk:bot:list + +# Bot für alle User verfügbar machen (optional) +sudo -u nextcloud php occ talk:bot:state 1 +``` + +## 6. Testen + +1. Öffne Nextcloud Talk +2. Starte einen neuen Chat mit dem Bot (suche nach "Claude") +3. Schreibe eine Nachricht + +### Health Check + +```bash +curl http://127.0.0.1:8085/health +``` + +### Logs prüfen + +```bash +journalctl -u nextcloud-claude-bot -f +``` + +## Troubleshooting + +### Bot antwortet nicht + +1. Prüfe ob der Service läuft: + ```bash + systemctl status nextcloud-claude-bot + ``` + +2. Prüfe die Logs: + ```bash + journalctl -u nextcloud-claude-bot -n 50 + ``` + +3. Teste den Webhook manuell: + ```bash + curl -X POST http://127.0.0.1:8085/webhook \ + -H "Content-Type: application/json" \ + -d '{"actor":{"type":"users","id":"harald"},"message":{"message":"test","id":1},"conversation":{"token":"abc123","type":1}}' + ``` + +### Claude CLI Fehler + +Stelle sicher, dass Claude CLI als der Service-User funktioniert: + +```bash +# Teste als der User +sudo -u nextcloud-claude-bot claude --print "Hello" +``` + +Die Claude CLI Config liegt in `/var/lib/nextcloud-claude-bot/.config/claude/`. + +### Signature Fehler + +Prüfe ob das Bot Secret in Nextcloud und im Service übereinstimmt: + +```bash +# Secret im Service +cat /var/secrets/nextcloud-claude-bot + +# Secret in Nextcloud (verschlüsselt gespeichert) +sudo -u nextcloud php occ talk:bot:list +``` + +## Befehle im Chat + +- `/help` oder `/hilfe` – Hilfe anzeigen +- `/clear` oder `/reset` – Konversation zurücksetzen + +## Sicherheitshinweise + +- Der Bot läuft nur auf localhost und ist nicht direkt erreichbar +- Nur in `allowedUsers` gelistete Nutzer können den Bot verwenden +- Webhook-Signaturen werden verifiziert +- DynamicUser isoliert den Service diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py new file mode 100644 index 0000000..d1eef3e --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Nextcloud Talk Claude Bot + +Receives webhooks from Nextcloud Talk and responds using Claude CLI. +""" + +import asyncio +import hashlib +import hmac +import json +import logging +import os +import subprocess +from datetime import datetime +from typing import Optional + +import httpx +from fastapi import FastAPI, Request, HTTPException, Header +from fastapi.responses import JSONResponse + +# Configuration from environment +NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "").rstrip("/") +CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "claude") +ALLOWED_USERS = [u.strip() for u in os.environ.get("ALLOWED_USERS", "").split(",") if u.strip()] +MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "4096")) +TIMEOUT = int(os.environ.get("TIMEOUT", "120")) +SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", "") + +# Bot secret from systemd credential +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() + # Fallback for development + return os.environ.get("BOT_SECRET", "") + +BOT_SECRET = get_bot_secret() + +# Logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s" +) +log = logging.getLogger(__name__) + +app = FastAPI(title="Nextcloud Claude Bot") + +# Simple in-memory conversation history (per user) +# Format: {user_id: [(timestamp, role, message), ...]} +conversations: dict[str, list[tuple[datetime, str, str]]] = {} +MAX_HISTORY = 10 # Keep last N exchanges per user + + +def verify_signature(body: bytes, signature: str) -> bool: + """Verify Nextcloud webhook signature.""" + if not BOT_SECRET: + log.warning("No bot secret configured, skipping signature verification") + return True + + expected = hmac.new( + BOT_SECRET.encode(), + body, + hashlib.sha256 + ).hexdigest() + + # Nextcloud sends: sha256= + if signature.startswith("sha256="): + signature = signature[7:] + + return hmac.compare_digest(expected, signature) + + +def build_prompt(user_id: str, message: str) -> str: + """Build prompt with conversation history.""" + history = conversations.get(user_id, []) + + parts = [] + + if SYSTEM_PROMPT: + parts.append(f"System: {SYSTEM_PROMPT}\n") + + # Add recent history + for ts, role, msg in history[-MAX_HISTORY:]: + prefix = "User" if role == "user" else "Assistant" + parts.append(f"{prefix}: {msg}") + + # Add current message + parts.append(f"User: {message}") + + return "\n\n".join(parts) + + +async def call_claude(prompt: str) -> str: + """Call Claude CLI and return response.""" + cmd = [CLAUDE_PATH, "--print"] + + if MAX_TOKENS: + cmd.extend(["--max-tokens", str(MAX_TOKENS)]) + + log.info(f"Calling Claude: {' '.join(cmd)}") + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + stdout, stderr = await asyncio.wait_for( + proc.communicate(prompt.encode()), + timeout=TIMEOUT + ) + + if proc.returncode != 0: + log.error(f"Claude CLI error: {stderr.decode()}") + return f"❌ Fehler beim Aufruf von Claude: {stderr.decode()[:200]}" + + return stdout.decode().strip() + + except asyncio.TimeoutError: + log.error(f"Claude CLI timeout after {TIMEOUT}s") + return f"⏱️ Timeout: Claude hat nicht innerhalb von {TIMEOUT}s geantwortet." + except Exception as e: + log.exception("Error calling Claude") + return f"❌ Fehler: {str(e)}" + + +async def send_reply(conversation_token: str, message: str, reply_to: int = None): + """Send reply back to Nextcloud Talk.""" + 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 = { + "OCS-APIRequest": "true", + "Content-Type": "application/json", + } + + # Bot authentication + if BOT_SECRET: + # Generate random string for request + import secrets + random = secrets.token_hex(32) + digest = hmac.new( + BOT_SECRET.encode(), + (random + message).encode(), + hashlib.sha256 + ).hexdigest() + headers["X-Nextcloud-Talk-Bot-Random"] = random + headers["X-Nextcloud-Talk-Bot-Signature"] = digest + + 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 as e: + 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"), +): + """Handle incoming webhook from Nextcloud Talk.""" + body = await request.body() + + # Verify signature + if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature): + 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]}") + + # Extract message info + # Nextcloud Talk Bot API structure + actor = data.get("actor", {}) + actor_id = actor.get("id", "") + actor_type = actor.get("type", "") + + message_data = data.get("message", {}) + message_text = message_data.get("message", "") + message_id = message_data.get("id") + + conversation = data.get("conversation", {}) + conversation_token = conversation.get("token", "") + conversation_type = conversation.get("type", 0) + + # Only respond to user messages in one-on-one chats (type 1) + if actor_type != "users": + log.info(f"Ignoring non-user actor: {actor_type}") + return JSONResponse({"status": "ignored", "reason": "not a user message"}) + + if conversation_type != 1: # 1 = one-to-one + log.info(f"Ignoring non-DM conversation type: {conversation_type}") + return JSONResponse({"status": "ignored", "reason": "not a direct message"}) + + # Check allowed users + 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]}") + + # Store user message in history + if actor_id not in conversations: + conversations[actor_id] = [] + conversations[actor_id].append((datetime.now(), "user", message_text)) + + # Handle special commands + if message_text.strip().lower() in ("/clear", "/reset", "/neu"): + conversations[actor_id] = [] + await send_reply( + conversation_token, + "🧹 Konversation zurückgesetzt.", + reply_to=message_id + ) + return JSONResponse({"status": "ok", "action": "cleared"}) + + if message_text.strip().lower() in ("/help", "/hilfe"): + help_text = """🤖 **Claude Bot Hilfe** + +Schreib mir einfach eine Nachricht und ich antworte dir. + +**Befehle:** +• `/clear` oder `/reset` – Konversation zurücksetzen +• `/help` oder `/hilfe` – Diese Hilfe anzeigen + +Der Bot merkt sich die letzten Nachrichten für Kontext.""" + await send_reply(conversation_token, help_text, reply_to=message_id) + return JSONResponse({"status": "ok", "action": "help"}) + + # Build prompt and call Claude + prompt = build_prompt(actor_id, message_text) + response = await call_claude(prompt) + + # Store assistant response in history + conversations[actor_id].append((datetime.now(), "assistant", response)) + + # Trim history + if len(conversations[actor_id]) > MAX_HISTORY * 2: + conversations[actor_id] = conversations[actor_id][-MAX_HISTORY * 2:] + + # Send response + await send_reply(conversation_token, response, reply_to=message_id) + + return JSONResponse({"status": "ok"}) + + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return { + "status": "ok", + "nextcloud_url": NEXTCLOUD_URL, + "claude_path": CLAUDE_PATH, + "allowed_users": ALLOWED_USERS if ALLOWED_USERS else "all", + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=8085) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix new file mode 100644 index 0000000..7d61fe7 --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix @@ -0,0 +1,31 @@ +{ config, ... }: +{ + imports = [ ./module.nix ]; + + services.nextcloud-claude-bot = { + enable = true; + nextcloudUrl = "https://nc.hoyer.xyz"; + botSecretFile = config.sops.secrets."nextcloud-claude-bot/secret".path; + allowedUsers = [ "harald" ]; + }; + + sops.secrets."nextcloud-claude-bot/secret" = { + sopsFile = ../../../../.secrets/hetzner/nextcloud-claude-bot.yaml; + restartUnits = [ "nextcloud-claude-bot.service" ]; + }; + + # Nginx location for Nextcloud to send webhooks to the bot + services.nginx.virtualHosts."nc.hoyer.xyz".locations."/_claude-bot/" = { + proxyPass = "http://127.0.0.1:8085/"; + 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-claude-bot/example-config.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/example-config.nix new file mode 100644 index 0000000..9560ff3 --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/example-config.nix @@ -0,0 +1,80 @@ +# Example NixOS configuration for the Nextcloud Claude Bot +# Add this to your configuration.nix or a separate module + +{ config, pkgs, ... }: + +{ + imports = [ + ./nextcloud-claude-bot/module.nix + ]; + + # Install Claude Code CLI + # Note: You'll need to either: + # 1. Use the official package if available in nixpkgs + # 2. Package it yourself + # 3. Use a binary wrapper + + # Option 1: If claude-code is in nixpkgs (check latest state) + # environment.systemPackages = [ pkgs.claude-code ]; + + # Option 2: Manual binary installation wrapper + nixpkgs.overlays = [ + (final: prev: { + claude-code = final.writeShellScriptBin "claude" '' + # Assumes claude is installed via npm globally or similar + exec ${final.nodejs}/bin/node /opt/claude-code/cli.js "$@" + ''; + }) + ]; + + # Create bot secret + # Generate with: openssl rand -hex 32 + # Store in a file, e.g., /var/secrets/nextcloud-claude-bot + + services.nextcloud-claude-bot = { + enable = true; + port = 8085; + host = "127.0.0.1"; + + nextcloudUrl = "https://cloud.example.com"; + botSecretFile = "/var/secrets/nextcloud-claude-bot"; + + # Only allow specific users + allowedUsers = [ "harald" ]; + + # Claude settings + maxTokens = 4096; + timeout = 120; + + # Optional system prompt + systemPrompt = '' + Du bist ein hilfreicher Assistent. Antworte auf Deutsch, + es sei denn der Nutzer schreibt auf Englisch. + ''; + }; + + # Ensure secrets directory exists with proper permissions + systemd.tmpfiles.rules = [ + "d /var/secrets 0750 root root -" + ]; + + # If Nextcloud runs locally, bot can stay on localhost. + # If you need external access (e.g., Nextcloud on different server): + services.nginx.virtualHosts."cloud.example.com" = { + # ... your existing Nextcloud config ... + + locations."/_claude-bot/" = { + proxyPass = "http://127.0.0.1:8085/"; + 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 Nextcloud itself + allow 127.0.0.1; + deny all; + ''; + }; + }; +} diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix new file mode 100644 index 0000000..2cf2495 --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix @@ -0,0 +1,141 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.nextcloud-claude-bot; + + botScript = pkgs.python3Packages.buildPythonApplication { + pname = "nextcloud-claude-bot"; + version = "0.1.0"; + format = "other"; + + propagatedBuildInputs = with pkgs.python3Packages; [ + fastapi + uvicorn + httpx + ]; + + dontUnpack = true; + + installPhase = '' + mkdir -p $out/bin + cp ${./bot.py} $out/bin/nextcloud-claude-bot + chmod +x $out/bin/nextcloud-claude-bot + ''; + }; + +in { + options.services.nextcloud-claude-bot = { + enable = mkEnableOption "Nextcloud Talk Claude Bot"; + + port = mkOption { + type = types.port; + default = 8085; + 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)"; + }; + + claudePath = mkOption { + type = types.path; + default = "${pkgs.claude-code}/bin/claude"; + description = "Path to claude CLI binary"; + }; + + allowedUsers = mkOption { + type = types.listOf types.str; + default = []; + example = [ "harald" "admin" ]; + description = "Nextcloud usernames allowed to talk to the bot (empty = all)"; + }; + + maxTokens = mkOption { + type = types.int; + default = 4096; + description = "Max tokens for Claude response"; + }; + + timeout = mkOption { + type = types.int; + default = 120; + description = "Timeout in seconds for Claude CLI"; + }; + + systemPrompt = mkOption { + type = types.nullOr types.str; + default = null; + example = "Du bist ein hilfreicher Assistent."; + description = "Optional system prompt for Claude"; + }; + }; + + config = mkIf cfg.enable { + systemd.services.nextcloud-claude-bot = { + description = "Nextcloud Talk Claude Bot"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + environment = { + BOT_HOST = cfg.host; + BOT_PORT = toString cfg.port; + NEXTCLOUD_URL = cfg.nextcloudUrl; + CLAUDE_PATH = cfg.claudePath; + ALLOWED_USERS = concatStringsSep "," cfg.allowedUsers; + MAX_TOKENS = toString cfg.maxTokens; + TIMEOUT = toString cfg.timeout; + SYSTEM_PROMPT = cfg.systemPrompt or ""; + }; + + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.python3Packages.uvicorn}/bin/uvicorn nextcloud_claude_bot:app --host ${cfg.host} --port ${toString cfg.port}"; + Restart = "always"; + RestartSec = 5; + + # Security hardening + DynamicUser = true; + 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; # Python needs this + LockPersonality = true; + + # Bot secret + LoadCredential = "bot-secret:${cfg.botSecretFile}"; + + # Claude CLI needs home for config + StateDirectory = "nextcloud-claude-bot"; + Environment = "HOME=/var/lib/nextcloud-claude-bot"; + }; + }; + + # Nginx reverse proxy config (optional, if you want external access) + # services.nginx.virtualHosts."cloud.example.com".locations."/claude-bot/" = { + # proxyPass = "http://${cfg.host}:${toString cfg.port}/"; + # }; + }; +}