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:
parent
538d7623be
commit
b35373b0ec
4 changed files with 122 additions and 109 deletions
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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" = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue