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": {
"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": {

View file

@ -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,
}

View file

@ -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" = {

View file

@ -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;