feat(bot): replace maxTokens with contextMessages option

- Switched `maxTokens` to `contextMessages` to set chat history length instead of token limit.
- Updated environment variables, NixOS module, and prompt building logic for consistency.
- Removed in-memory conversation history, now fetching from Nextcloud for better scalability.
This commit is contained in:
Harald Hoyer 2026-02-03 17:00:50 +01:00
parent 538d7623be
commit b35373b0ec
4 changed files with 122 additions and 109 deletions

54
flake.lock generated
View file

@ -454,11 +454,11 @@
"homebrew-cask": { "homebrew-cask": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1769770011, "lastModified": 1770127519,
"narHash": "sha256-Z+qyxP9dQVk1xBJKJvrvKg2/8SGnYEUArs5vJuhc4ZE=", "narHash": "sha256-wIpVsLhx1gaB2JYfpVipt9ZLAReKFO0kmVIOhieHfqs=",
"owner": "homebrew", "owner": "homebrew",
"repo": "homebrew-cask", "repo": "homebrew-cask",
"rev": "4b98892b8c059ebc23e6516c917f6b01741a2969", "rev": "76e6c1bda247fe48dc30683203cce2b28b5d6eee",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -470,11 +470,11 @@
"homebrew-core": { "homebrew-core": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1769769028, "lastModified": 1770130704,
"narHash": "sha256-9RhJZXZO/PJ7A+917XRROv8xPtzHlPthtAMhunUAfM0=", "narHash": "sha256-95Jwssj3WbBwHO4nNB5uVIgIym/fuSDBb5vs6eKdgp0=",
"owner": "homebrew", "owner": "homebrew",
"repo": "homebrew-core", "repo": "homebrew-core",
"rev": "95b2944276a57b176eadc835575c3b591f88999f", "rev": "5369d45006ea107dead79ef8ef4b29b7c972f276",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -515,11 +515,11 @@
}, },
"mnw": { "mnw": {
"locked": { "locked": {
"lastModified": 1768701608, "lastModified": 1769981889,
"narHash": "sha256-kSvWF3Xt2HW9hmV5V7i8PqeWJIBUKmuKoHhOgj3Znzs=", "narHash": "sha256-ndI7AxL/6auelkLHngdUGVImBiHkG8w2N2fOTKZKn4k=",
"owner": "Gerg-L", "owner": "Gerg-L",
"repo": "mnw", "repo": "mnw",
"rev": "20d63a8a1ae400557c770052a46a9840e768926b", "rev": "332fed8f43b77149c582f1782683d6aeee1f07cf",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -562,11 +562,11 @@
"systems": "systems_2" "systems": "systems_2"
}, },
"locked": { "locked": {
"lastModified": 1769716128, "lastModified": 1770130359,
"narHash": "sha256-CAsiyTNjI0WmtJstw3kGyL7Q1jPCn7AsO6Ms47G+x3w=", "narHash": "sha256-IfoT9oaeIE6XjXprMORG2qZFzGGZ0v6wJcOlQRdlpvY=",
"owner": "NotAShelf", "owner": "NotAShelf",
"repo": "nvf", "repo": "nvf",
"rev": "866b983c4047b87bcdca6ab3673ed7bd602f0251", "rev": "92854bd0eaaa06914afba345741c372439b8e335",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -642,11 +642,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1769598131, "lastModified": 1770056022,
"narHash": "sha256-e7VO/kGLgRMbWtpBqdWl0uFg8Y2XWFMdz0uUJvlML8o=", "narHash": "sha256-yvCz+Qmci1bVucXEyac3TdoSPMtjqVJmVy5wro6j/70=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "fa83fd837f3098e3e678e6cf017b2b36102c7211", "rev": "d04d8548aed39902419f14a8537006426dc1e4fa",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -748,11 +748,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1769742225, "lastModified": 1770088046,
"narHash": "sha256-roSD/OJ3x9nF+Dxr+/bLClX3U8FP9EkCQIFpzxKjSUM=", "narHash": "sha256-4hfYDnUTvL1qSSZEA4CEThxfz+KlwSFQ30Z9jgDguO0=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "bcdd8d37594f0e201639f55889c01c827baf5c75", "rev": "71f9daa4e05e49c434d08627e755495ae222bc34",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -835,11 +835,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1769469829, "lastModified": 1770110318,
"narHash": "sha256-wFcr32ZqspCxk4+FvIxIL0AZktRs6DuF8oOsLt59YBU=", "narHash": "sha256-NUVGVtYBTC96WhPh4Y3SVM7vf0o1z5W4uqRBn9v1pfo=",
"owner": "Mic92", "owner": "Mic92",
"repo": "sops-nix", "repo": "sops-nix",
"rev": "c5eebd4eb2e3372fe12a8d70a248a6ee9dd02eff", "rev": "f990b0a334e96d3ef9ca09d4bd92778b42fd84f9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -857,11 +857,11 @@
"rust-overlay": "rust-overlay_3" "rust-overlay": "rust-overlay_3"
}, },
"locked": { "locked": {
"lastModified": 1768997903, "lastModified": 1769829418,
"narHash": "sha256-UpBfh3I4PhykVHqV74rrxufF3X1Z8z8sx/lFgMFfIP8=", "narHash": "sha256-ALZKPUa0eHP6HwETAJ9PsAnYQjNLF6eEpo1W2fmYqwA=",
"owner": "haraldh", "owner": "haraldh",
"repo": "ssh-tresor", "repo": "ssh-tresor",
"rev": "dd45aed45f8d9b8729b7698ef43e7cc32fab97b6", "rev": "2e1bfa29bd5ad5a60c3e0effd69851a67d455781",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -932,11 +932,11 @@
}, },
"unstable": { "unstable": {
"locked": { "locked": {
"lastModified": 1769461804, "lastModified": 1770115704,
"narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=", "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d", "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -12,7 +12,7 @@ import json
import logging import logging
import os import os
import re import re
import subprocess import secrets
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@ -24,7 +24,6 @@ from fastapi.responses import JSONResponse
NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "").rstrip("/") NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "").rstrip("/")
CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "claude") CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "claude")
ALLOWED_USERS = [u.strip() for u in os.environ.get("ALLOWED_USERS", "").split(",") if u.strip()] 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")) TIMEOUT = int(os.environ.get("TIMEOUT", "120"))
SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", "") SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", "")
@ -50,10 +49,54 @@ log = logging.getLogger(__name__)
app = FastAPI(title="Nextcloud Claude Bot") app = FastAPI(title="Nextcloud Claude Bot")
# Simple in-memory conversation history (per user) # Number of recent messages to fetch for context
# Format: {user_id: [(timestamp, role, message), ...]} CONTEXT_MESSAGES = int(os.environ.get("CONTEXT_MESSAGES", "6"))
conversations: dict[str, list[tuple[datetime, str, str]]] = {}
MAX_HISTORY = 10 # Keep last N exchanges per user
def generate_bot_auth_headers(body: str = "") -> dict:
"""Generate authentication headers for bot requests to Nextcloud."""
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",
}
async def fetch_chat_history(conversation_token: str, limit: int = CONTEXT_MESSAGES) -> list[dict]:
"""Fetch recent messages from Nextcloud Talk conversation."""
if not NEXTCLOUD_URL:
log.warning("NEXTCLOUD_URL not configured, cannot fetch history")
return []
url = f"{NEXTCLOUD_URL}/ocs/v2.php/apps/spreed/api/v1/chat/{conversation_token}"
params = {
"limit": limit,
"lookIntoFuture": 0,
}
headers = generate_bot_auth_headers()
headers["Accept"] = "application/json"
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, params=params, headers=headers)
if resp.status_code == 200:
data = resp.json()
messages = data.get("ocs", {}).get("data", [])
# Messages come newest first, reverse for chronological order
return list(reversed(messages))
else:
log.warning(f"Failed to fetch chat history: {resp.status_code} {resp.text[:200]}")
return []
except Exception as e:
log.exception("Error fetching chat history")
return []
def verify_signature(body: bytes, signature: str, random: Optional[str] = None) -> bool: def verify_signature(body: bytes, signature: str, random: Optional[str] = None) -> bool:
@ -76,10 +119,6 @@ def verify_signature(body: bytes, signature: str, random: Optional[str] = None)
else: else:
expected2 = None expected2 = None
log.info(f"Signature verification: received={signature[:16]}...")
log.info(f" Method 1 (body only): {expected1[:16]}...")
if expected2:
log.info(f" Method 2 (random+body): {expected2[:16]}...")
if hmac.compare_digest(expected1, signature): if hmac.compare_digest(expected1, signature):
return True return True
@ -89,23 +128,36 @@ def verify_signature(body: bytes, signature: str, random: Optional[str] = None)
return False return False
def build_prompt(user_id: str, message: str) -> str: async def build_prompt(conversation_token: str, current_message: str, current_user: str) -> str:
"""Build prompt with conversation history.""" """Build prompt with conversation history from Nextcloud."""
history = conversations.get(user_id, [])
parts = [] parts = []
if SYSTEM_PROMPT: if SYSTEM_PROMPT:
parts.append(f"System: {SYSTEM_PROMPT}\n") parts.append(f"System: {SYSTEM_PROMPT}\n")
# Add recent history # Fetch recent history from Nextcloud
for ts, role, msg in history[-MAX_HISTORY:]: history = await fetch_chat_history(conversation_token)
prefix = "User" if role == "user" else "Assistant"
parts.append(f"{prefix}: {msg}") # Add recent history (excluding the current message which triggered this)
for msg in history:
actor_type = msg.get("actorType", "")
actor_id = msg.get("actorId", "")
message_text = msg.get("message", "")
msg_type = msg.get("messageType", "")
# Skip system messages
if msg_type == "system":
continue
# Determine if this is a user or the bot
if actor_type == "bots":
parts.append(f"Assistant: {message_text}")
elif actor_type == "users":
parts.append(f"User ({actor_id}): {message_text}")
# Add current message # Add current message
parts.append(f"User: {message}") parts.append(f"User ({current_user}): {current_message}")
return "\n\n".join(parts) return "\n\n".join(parts)
@ -147,35 +199,21 @@ async def send_reply(conversation_token: str, message: str, reply_to: int = None
if not NEXTCLOUD_URL: if not NEXTCLOUD_URL:
log.error("NEXTCLOUD_URL not configured") log.error("NEXTCLOUD_URL not configured")
return return
url = f"{NEXTCLOUD_URL}/ocs/v2.php/apps/spreed/api/v1/bot/{conversation_token}/message" url = f"{NEXTCLOUD_URL}/ocs/v2.php/apps/spreed/api/v1/bot/{conversation_token}/message"
headers = { # Bot authentication - signature is over the message being sent
"OCS-APIRequest": "true", headers = generate_bot_auth_headers(message)
"Content-Type": "application/json", headers["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 = { payload = {
"message": message, "message": message,
"referenceId": hashlib.sha256(f"{conversation_token}-{datetime.now().isoformat()}".encode()).hexdigest()[:32], "referenceId": hashlib.sha256(f"{conversation_token}-{datetime.now().isoformat()}".encode()).hexdigest()[:32],
} }
if reply_to: if reply_to:
payload["replyTo"] = reply_to payload["replyTo"] = reply_to
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
resp = await client.post(url, json=payload, headers=headers) resp = await client.post(url, json=payload, headers=headers)
@ -196,9 +234,6 @@ async def handle_webhook(
"""Handle incoming webhook from Nextcloud Talk.""" """Handle incoming webhook from Nextcloud Talk."""
body = await request.body() body = await request.body()
log.info(f"Headers: signature={x_nextcloud_talk_signature}, random={x_nextcloud_talk_random}")
log.info(f"Body (first 200): {body[:200]}")
# Verify signature # Verify signature
if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature, x_nextcloud_talk_random): if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature, x_nextcloud_talk_random):
log.warning("Invalid webhook signature") log.warning("Invalid webhook signature")
@ -284,21 +319,6 @@ async def handle_webhook(
log.info(f"Processing message from {actor_id}: {message_text[:100]}") 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"): if message_text.strip().lower() in ("/help", "/hilfe"):
help_text = """🤖 **Claude Bot Hilfe** help_text = """🤖 **Claude Bot Hilfe**
@ -309,24 +329,16 @@ Schreib mir einfach eine Nachricht und ich antworte dir.
In Gruppenchats: @Claude gefolgt von deiner Frage In Gruppenchats: @Claude gefolgt von deiner Frage
**Befehle:** **Befehle:**
`/clear` oder `/reset` Konversation zurücksetzen
`/help` oder `/hilfe` Diese Hilfe anzeigen `/help` oder `/hilfe` Diese Hilfe anzeigen
Der Bot merkt sich die letzten Nachrichten für Kontext.""" Der Bot nutzt die letzten Nachrichten aus dem Chat als Kontext."""
await send_reply(conversation_token, help_text, reply_to=message_id) await send_reply(conversation_token, help_text, reply_to=message_id)
return JSONResponse({"status": "ok", "action": "help"}) return JSONResponse({"status": "ok", "action": "help"})
# Build prompt and call Claude # Build prompt with chat history and call Claude
prompt = build_prompt(actor_id, message_text) prompt = await build_prompt(conversation_token, message_text, actor_id)
response = await call_claude(prompt) 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 # Send response
await send_reply(conversation_token, response, reply_to=message_id) await send_reply(conversation_token, response, reply_to=message_id)
@ -341,6 +353,7 @@ async def health():
"nextcloud_url": NEXTCLOUD_URL, "nextcloud_url": NEXTCLOUD_URL,
"claude_path": CLAUDE_PATH, "claude_path": CLAUDE_PATH,
"allowed_users": ALLOWED_USERS if ALLOWED_USERS else "all", "allowed_users": ALLOWED_USERS if ALLOWED_USERS else "all",
"context_messages": CONTEXT_MESSAGES,
} }

View file

@ -6,7 +6,7 @@
enable = true; enable = true;
nextcloudUrl = "https://nc.hoyer.xyz"; nextcloudUrl = "https://nc.hoyer.xyz";
botSecretFile = config.sops.secrets."nextcloud-claude-bot/secret".path; botSecretFile = config.sops.secrets."nextcloud-claude-bot/secret".path;
allowedUsers = [ "harald" ]; allowedUsers = []; # Allow all registered users
}; };
sops.secrets."nextcloud-claude-bot/secret" = { sops.secrets."nextcloud-claude-bot/secret" = {

View file

@ -56,12 +56,12 @@ in {
description = "Nextcloud usernames allowed to talk to the bot (empty = all)"; description = "Nextcloud usernames allowed to talk to the bot (empty = all)";
}; };
maxTokens = mkOption { contextMessages = mkOption {
type = types.int; type = types.int;
default = 4096; default = 6;
description = "Max tokens for Claude response"; description = "Number of recent messages to fetch from chat for context";
}; };
timeout = mkOption { timeout = mkOption {
type = types.int; type = types.int;
default = 120; default = 120;
@ -89,7 +89,7 @@ in {
NEXTCLOUD_URL = cfg.nextcloudUrl; NEXTCLOUD_URL = cfg.nextcloudUrl;
CLAUDE_PATH = cfg.claudePath; CLAUDE_PATH = cfg.claudePath;
ALLOWED_USERS = concatStringsSep "," cfg.allowedUsers; ALLOWED_USERS = concatStringsSep "," cfg.allowedUsers;
MAX_TOKENS = toString cfg.maxTokens; CONTEXT_MESSAGES = toString cfg.contextMessages;
TIMEOUT = toString cfg.timeout; TIMEOUT = toString cfg.timeout;
SYSTEM_PROMPT = cfg.systemPrompt or ""; SYSTEM_PROMPT = cfg.systemPrompt or "";
PYTHONPATH = botModule; PYTHONPATH = botModule;