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.
This commit is contained in:
parent
eb10ad018f
commit
bc6091f63f
7 changed files with 728 additions and 0 deletions
35
.secrets/hetzner/nextcloud-claude-bot.yaml
Normal file
35
.secrets/hetzner/nextcloud-claude-bot.yaml
Normal file
|
|
@ -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
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
./mailserver.nix
|
./mailserver.nix
|
||||||
./network.nix
|
./network.nix
|
||||||
./nextcloud.nix
|
./nextcloud.nix
|
||||||
|
./nextcloud-claude-bot
|
||||||
./nginx.nix
|
./nginx.nix
|
||||||
./postgresql.nix
|
./postgresql.nix
|
||||||
./rspamd.nix
|
./rspamd.nix
|
||||||
|
|
|
||||||
146
systems/x86_64-linux/mx/nextcloud-claude-bot/README.md
Normal file
146
systems/x86_64-linux/mx/nextcloud-claude-bot/README.md
Normal file
|
|
@ -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 <bot-id> 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
|
||||||
294
systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py
Normal file
294
systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py
Normal file
|
|
@ -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=<hex>
|
||||||
|
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)
|
||||||
31
systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix
Normal file
31
systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix
Normal file
|
|
@ -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;
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
141
systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix
Normal file
141
systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix
Normal file
|
|
@ -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}/";
|
||||||
|
# };
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue