Files
Cortex-Inara/cortex/routers/nextcloud_talk.py
Scott Idem 97438f1a0f feat: multi-instance support — agent_name and user_name configurable
All hardcoded "Inara"/"Scott" strings replaced with settings.agent_name
and settings.user_name, read from .env at startup:

- config.py: AGENT_NAME and USER_NAME settings (defaults: Inara / Scott)
- llm_client.py: conversation labels in prompt builder
- session_logger.py: **Name:** labels in session log markdown
- memory_distiller.py: distillation system prompts (mid + long)
- routers/nextcloud_talk.py: @mention prefix strip
- routers/google_chat.py: greeting message

Second instance scaffolding:
- holly/: identity directory with placeholder files (USER_NAME=Holly,
  AGENT_NAME to be chosen by Holly)
- cortex/.env.holly: config for Holly's instance on port 8001
- cortex-holly.service: systemd unit for the second instance

No behavioural change to the Inara/Scott instance — defaults unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:13:11 -04:00

173 lines
6.1 KiB
Python

import asyncio
import hashlib
import hmac
import json
import logging
import secrets
import httpx
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
from config import settings
from context_loader import load_context
from llm_client import complete
from session_logger import log_turn
from session_store import load as load_session, save as save_session
import event_bus
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
if not logger.handlers:
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("%(levelname)s:%(name)s: %(message)s"))
logger.addHandler(_h)
logger.propagate = False
router = APIRouter()
def _verify_signature(body: bytes, random_header: str, sig_header: str) -> bool:
"""Nextcloud signs requests with HMAC-SHA256(key=secret, msg=random+body)."""
expected = hmac.new(
settings.nextcloud_talk_bot_secret.encode(),
(random_header + body.decode("utf-8", errors="replace")).encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig_header.lower())
async def _send_reply(conversation_token: str, message: str) -> None:
"""Post a message to Nextcloud Talk as the bot."""
url = (
f"{settings.nextcloud_url}/ocs/v2.php/apps/spreed/api/v1"
f"/bot/{conversation_token}/message"
)
# NC Talk verifies HMAC over (random + message_text), NOT the raw body.
# See BotController::getBotFromHeaders → checksumVerificationService::validateRequest($random, $sig, $secret, $message)
body_dict = {"message": message}
body_bytes = json.dumps(body_dict, ensure_ascii=False).encode("utf-8")
random_str = secrets.token_hex(32)
sig = hmac.new(
settings.nextcloud_talk_bot_secret.encode(),
(random_str + message).encode("utf-8"),
hashlib.sha256,
).hexdigest()
logger.info("NCT _send_reply → %s (body: %s)", url, body_bytes.decode())
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
url,
content=body_bytes,
headers={
"Content-Type": "application/json",
"OCS-APIRequest": "true",
"X-Nextcloud-Talk-Bot-Random": random_str,
"X-Nextcloud-Talk-Bot-Signature": sig,
},
timeout=15,
)
logger.info("NCT reply: %s%s", resp.status_code, resp.text[:400])
except Exception as e:
logger.error("NCT reply error: %s", e)
async def _process_message(conversation_token: str, user_text: str, actor_name: str) -> None:
logger.info("NCT process: token=%s user=%s text=%r", conversation_token, actor_name, user_text)
session_id = f"nct_{conversation_token}"
system_prompt = load_context(settings.default_tier)
history = load_session(session_id)
history.append({"role": "user", "content": user_text})
await event_bus.publish({
"type": "nct_message",
"session_id": session_id,
"role": "user",
"content": user_text,
"actor": actor_name,
})
try:
response_text, backend = await asyncio.wait_for(
complete(system_prompt=system_prompt, messages=history),
timeout=settings.nextcloud_talk_timeout,
)
except asyncio.TimeoutError:
logger.warning("NCT timeout for %s", conversation_token)
await _send_reply(conversation_token, "⏳ Still thinking — this is taking longer than usual.")
return
except Exception as e:
logger.error("NCT LLM error for %s: %s", conversation_token, e)
await _send_reply(conversation_token, "⚠️ Something went wrong on my end.")
return
logger.info("NCT LLM responded via %s (%d chars)", backend, len(response_text))
history.append({"role": "assistant", "content": response_text})
save_session(session_id, history)
log_turn(session_id, user_text, response_text)
await event_bus.publish({
"type": "nct_response",
"session_id": session_id,
"role": "assistant",
"content": response_text,
"backend": backend,
})
await _send_reply(conversation_token, response_text)
@router.post("/inara-nextcloud-talk-webhook")
async def nextcloud_talk_webhook(request: Request, background_tasks: BackgroundTasks):
body = await request.body()
if not settings.nextcloud_talk_bot_secret:
logger.error("nextcloud_talk_bot_secret not configured")
return Response(status_code=500)
random_header = request.headers.get("X-Nextcloud-Talk-Random", "")
sig_header = request.headers.get("X-Nextcloud-Talk-Signature", "")
if not _verify_signature(body, random_header, sig_header):
logger.warning("NCT webhook: signature mismatch")
raise HTTPException(status_code=401, detail="Invalid signature")
try:
payload = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON")
if payload.get("type") != "Create":
return Response(status_code=200)
obj = payload.get("object", {})
if obj.get("type") != "Note":
return Response(status_code=200)
actor = payload.get("actor", {})
target = payload.get("target", {})
if actor.get("type") == "bots":
return Response(status_code=200)
conversation_token = target.get("id", "")
try:
content = json.loads(obj.get("content", "{}"))
user_text = content.get("message", "").strip()
except (json.JSONDecodeError, AttributeError):
user_text = (obj.get("name") or obj.get("content", "")).strip()
mention_prefix = f"@{settings.agent_name.lower()}"
if user_text.lower().startswith(mention_prefix):
user_text = user_text[len(mention_prefix):].strip()
if not user_text:
return Response(status_code=200)
actor_name = actor.get("name", "User")
logger.info("NCT message from %s in %s: %r", actor_name, conversation_token, user_text[:60])
background_tasks.add_task(_process_message, conversation_token, user_text, actor_name)
return Response(status_code=200)