From b35373b0ecbd21e0621521eb23dde19ed951a5e1 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 17:00:50 +0100 Subject: [PATCH] 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. --- flake.lock | 54 +++--- .../mx/nextcloud-claude-bot/bot.py | 165 ++++++++++-------- .../mx/nextcloud-claude-bot/default.nix | 2 +- .../mx/nextcloud-claude-bot/module.nix | 10 +- 4 files changed, 122 insertions(+), 109 deletions(-) diff --git a/flake.lock b/flake.lock index 644d00b..703e220 100644 --- a/flake.lock +++ b/flake.lock @@ -454,11 +454,11 @@ "homebrew-cask": { "flake": false, "locked": { - "lastModified": 1769770011, - "narHash": "sha256-Z+qyxP9dQVk1xBJKJvrvKg2/8SGnYEUArs5vJuhc4ZE=", + "lastModified": 1770127519, + "narHash": "sha256-wIpVsLhx1gaB2JYfpVipt9ZLAReKFO0kmVIOhieHfqs=", "owner": "homebrew", "repo": "homebrew-cask", - "rev": "4b98892b8c059ebc23e6516c917f6b01741a2969", + "rev": "76e6c1bda247fe48dc30683203cce2b28b5d6eee", "type": "github" }, "original": { @@ -470,11 +470,11 @@ "homebrew-core": { "flake": false, "locked": { - "lastModified": 1769769028, - "narHash": "sha256-9RhJZXZO/PJ7A+917XRROv8xPtzHlPthtAMhunUAfM0=", + "lastModified": 1770130704, + "narHash": "sha256-95Jwssj3WbBwHO4nNB5uVIgIym/fuSDBb5vs6eKdgp0=", "owner": "homebrew", "repo": "homebrew-core", - "rev": "95b2944276a57b176eadc835575c3b591f88999f", + "rev": "5369d45006ea107dead79ef8ef4b29b7c972f276", "type": "github" }, "original": { @@ -515,11 +515,11 @@ }, "mnw": { "locked": { - "lastModified": 1768701608, - "narHash": "sha256-kSvWF3Xt2HW9hmV5V7i8PqeWJIBUKmuKoHhOgj3Znzs=", + "lastModified": 1769981889, + "narHash": "sha256-ndI7AxL/6auelkLHngdUGVImBiHkG8w2N2fOTKZKn4k=", "owner": "Gerg-L", "repo": "mnw", - "rev": "20d63a8a1ae400557c770052a46a9840e768926b", + "rev": "332fed8f43b77149c582f1782683d6aeee1f07cf", "type": "github" }, "original": { @@ -562,11 +562,11 @@ "systems": "systems_2" }, "locked": { - "lastModified": 1769716128, - "narHash": "sha256-CAsiyTNjI0WmtJstw3kGyL7Q1jPCn7AsO6Ms47G+x3w=", + "lastModified": 1770130359, + "narHash": "sha256-IfoT9oaeIE6XjXprMORG2qZFzGGZ0v6wJcOlQRdlpvY=", "owner": "NotAShelf", "repo": "nvf", - "rev": "866b983c4047b87bcdca6ab3673ed7bd602f0251", + "rev": "92854bd0eaaa06914afba345741c372439b8e335", "type": "github" }, "original": { @@ -642,11 +642,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1769598131, - "narHash": "sha256-e7VO/kGLgRMbWtpBqdWl0uFg8Y2XWFMdz0uUJvlML8o=", + "lastModified": 1770056022, + "narHash": "sha256-yvCz+Qmci1bVucXEyac3TdoSPMtjqVJmVy5wro6j/70=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fa83fd837f3098e3e678e6cf017b2b36102c7211", + "rev": "d04d8548aed39902419f14a8537006426dc1e4fa", "type": "github" }, "original": { @@ -748,11 +748,11 @@ ] }, "locked": { - "lastModified": 1769742225, - "narHash": "sha256-roSD/OJ3x9nF+Dxr+/bLClX3U8FP9EkCQIFpzxKjSUM=", + "lastModified": 1770088046, + "narHash": "sha256-4hfYDnUTvL1qSSZEA4CEThxfz+KlwSFQ30Z9jgDguO0=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "bcdd8d37594f0e201639f55889c01c827baf5c75", + "rev": "71f9daa4e05e49c434d08627e755495ae222bc34", "type": "github" }, "original": { @@ -835,11 +835,11 @@ ] }, "locked": { - "lastModified": 1769469829, - "narHash": "sha256-wFcr32ZqspCxk4+FvIxIL0AZktRs6DuF8oOsLt59YBU=", + "lastModified": 1770110318, + "narHash": "sha256-NUVGVtYBTC96WhPh4Y3SVM7vf0o1z5W4uqRBn9v1pfo=", "owner": "Mic92", "repo": "sops-nix", - "rev": "c5eebd4eb2e3372fe12a8d70a248a6ee9dd02eff", + "rev": "f990b0a334e96d3ef9ca09d4bd92778b42fd84f9", "type": "github" }, "original": { @@ -857,11 +857,11 @@ "rust-overlay": "rust-overlay_3" }, "locked": { - "lastModified": 1768997903, - "narHash": "sha256-UpBfh3I4PhykVHqV74rrxufF3X1Z8z8sx/lFgMFfIP8=", + "lastModified": 1769829418, + "narHash": "sha256-ALZKPUa0eHP6HwETAJ9PsAnYQjNLF6eEpo1W2fmYqwA=", "owner": "haraldh", "repo": "ssh-tresor", - "rev": "dd45aed45f8d9b8729b7698ef43e7cc32fab97b6", + "rev": "2e1bfa29bd5ad5a60c3e0effd69851a67d455781", "type": "github" }, "original": { @@ -932,11 +932,11 @@ }, "unstable": { "locked": { - "lastModified": 1769461804, - "narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=", + "lastModified": 1770115704, + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", "owner": "nixos", "repo": "nixpkgs", - "rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d", + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", "type": "github" }, "original": { diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py index da1eb17..dd1049e 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -12,7 +12,7 @@ import json import logging import os import re -import subprocess +import secrets from datetime import datetime from typing import Optional @@ -24,7 +24,6 @@ from fastapi.responses import JSONResponse 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", "") @@ -50,10 +49,54 @@ 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 +# Number of recent messages to fetch for context +CONTEXT_MESSAGES = int(os.environ.get("CONTEXT_MESSAGES", "6")) + + +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: @@ -76,10 +119,6 @@ def verify_signature(body: bytes, signature: str, random: Optional[str] = None) else: 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): return True @@ -89,23 +128,36 @@ def verify_signature(body: bytes, signature: str, random: Optional[str] = None) return False -def build_prompt(user_id: str, message: str) -> str: - """Build prompt with conversation history.""" - history = conversations.get(user_id, []) - +async def build_prompt(conversation_token: str, current_message: str, current_user: str) -> str: + """Build prompt with conversation history from Nextcloud.""" 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}") - + + # Fetch recent history from Nextcloud + history = await fetch_chat_history(conversation_token) + + # 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 - parts.append(f"User: {message}") - + parts.append(f"User ({current_user}): {current_message}") + 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: 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 - + + # Bot authentication - signature is over the message being sent + 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) @@ -196,9 +234,6 @@ async def handle_webhook( """Handle incoming webhook from Nextcloud Talk.""" 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 if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature, x_nextcloud_talk_random): log.warning("Invalid webhook signature") @@ -284,21 +319,6 @@ async def handle_webhook( 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** @@ -309,24 +329,16 @@ Schreib mir einfach eine Nachricht und ich antworte dir. • In Gruppenchats: @Claude gefolgt von deiner Frage **Befehle:** -• `/clear` oder `/reset` – Konversation zurücksetzen • `/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) return JSONResponse({"status": "ok", "action": "help"}) - - # Build prompt and call Claude - prompt = build_prompt(actor_id, message_text) + + # Build prompt with chat history and call Claude + prompt = await build_prompt(conversation_token, message_text, actor_id) 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) @@ -341,6 +353,7 @@ async def health(): "nextcloud_url": NEXTCLOUD_URL, "claude_path": CLAUDE_PATH, "allowed_users": ALLOWED_USERS if ALLOWED_USERS else "all", + "context_messages": CONTEXT_MESSAGES, } diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix index 234efd7..c74f2ed 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix @@ -6,7 +6,7 @@ enable = true; nextcloudUrl = "https://nc.hoyer.xyz"; botSecretFile = config.sops.secrets."nextcloud-claude-bot/secret".path; - allowedUsers = [ "harald" ]; + allowedUsers = []; # Allow all registered users }; sops.secrets."nextcloud-claude-bot/secret" = { diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix index eb1a0a7..6edcd71 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix @@ -56,12 +56,12 @@ in { description = "Nextcloud usernames allowed to talk to the bot (empty = all)"; }; - maxTokens = mkOption { + contextMessages = mkOption { type = types.int; - default = 4096; - description = "Max tokens for Claude response"; + default = 6; + description = "Number of recent messages to fetch from chat for context"; }; - + timeout = mkOption { type = types.int; default = 120; @@ -89,7 +89,7 @@ in { NEXTCLOUD_URL = cfg.nextcloudUrl; CLAUDE_PATH = cfg.claudePath; ALLOWED_USERS = concatStringsSep "," cfg.allowedUsers; - MAX_TOKENS = toString cfg.maxTokens; + CONTEXT_MESSAGES = toString cfg.contextMessages; TIMEOUT = toString cfg.timeout; SYSTEM_PROMPT = cfg.systemPrompt or ""; PYTHONPATH = botModule;