From fe854ee534a2a42eb6165ac0561ecb42f26dc274 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Mon, 16 Mar 2026 23:04:26 -0400 Subject: [PATCH] Add Nextcloud Talk bot integration (Inara) - New routers/nextcloud_talk.py: webhook handler verifies incoming HMAC, calls LLM via BackgroundTasks, posts reply with correctly computed signature (random + message_text, not raw body) - llm_client.py: read Claude OAuth token live from ~/.claude/.credentials.json to avoid stale systemd env tokens; strip conflicting ANTHROPIC_API_KEY - config.py: add nextcloud_url, nextcloud_talk_bot_secret, nextcloud_talk_timeout settings - main.py: register nextcloud_talk router, add logging setup - docs/NEXTCLOUD_TALK_BOT.md: installation guide + HMAC signing gotcha Co-Authored-By: Claude Sonnet 4.6 --- cortex/config.py | 6 ++ cortex/llm_client.py | 32 ++++++- cortex/main.py | 6 +- cortex/routers/nextcloud_talk.py | 153 +++++++++++++++++++++++++++++++ docs/NEXTCLOUD_TALK_BOT.md | 87 ++++++++++++++++++ 5 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 cortex/routers/nextcloud_talk.py create mode 100644 docs/NEXTCLOUD_TALK_BOT.md diff --git a/cortex/config.py b/cortex/config.py index 7d0c503..63c0390 100644 --- a/cortex/config.py +++ b/cortex/config.py @@ -20,6 +20,12 @@ class Settings(BaseSettings): google_chat_timeout: int = 25 # Backend forced for Google Chat — Claude is more reliable within the 25s deadline google_chat_backend: str = "claude" + + # Nextcloud Talk bot + nextcloud_url: str = "https://cloud.dgrzone.com" + nextcloud_talk_bot_secret: str = "" # set in .env + nextcloud_talk_timeout: int = 55 + host: str = "0.0.0.0" port: int = 8000 diff --git a/cortex/llm_client.py b/cortex/llm_client.py index 6de506d..f2c8eea 100644 --- a/cortex/llm_client.py +++ b/cortex/llm_client.py @@ -64,6 +64,23 @@ async def _dispatch( return await _claude(system_prompt, messages, model) +def _fresh_claude_token() -> str | None: + """Read the current OAuth access token from the Claude credentials file. + + The token in the systemd .env goes stale (it rotates on each login). + Reading directly from ~/.claude/.credentials.json always gets the latest. + """ + import json as _json + creds_path = os.path.expanduser("~/.claude/.credentials.json") + try: + with open(creds_path) as f: + data = _json.load(f) + return data["claudeAiOauth"]["accessToken"] + except Exception as e: + logger.debug("Could not read Claude credentials file: %s", e) + return None + + async def _claude(system_prompt: str, messages: list[dict], model: str | None) -> str: cmd = [ "claude", "--print", @@ -75,7 +92,16 @@ async def _claude(system_prompt: str, messages: list[dict], model: str | None) - if system_prompt: cmd.extend(["--system-prompt", system_prompt]) cmd.append(_build_conversation(messages)) - return await _run(cmd, timeout=settings.timeout_claude) + + # Always use the freshest token from the credentials file so the systemd + # service doesn't break when the env-var token rotates after a login. + env = os.environ.copy() + token = _fresh_claude_token() + if token: + env["CLAUDE_CODE_OAUTH_TOKEN"] = token + env.pop("ANTHROPIC_API_KEY", None) # never let a stale API key override OAuth + + return await _run(cmd, timeout=settings.timeout_claude, env=env) async def _gemini(system_prompt: str, messages: list[dict]) -> str: @@ -148,11 +174,11 @@ def _clean_gemini_output(text: str) -> str: return "\n".join(lines).strip() -async def _run(cmd: list[str], timeout: int = 60) -> str: +async def _run(cmd: list[str], timeout: int = 60, env: dict | None = None) -> str: loop = asyncio.get_running_loop() result = await loop.run_in_executor( None, - lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout), + lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env), ) if result.returncode != 0: detail = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}" diff --git a/cortex/main.py b/cortex/main.py index 1cecb28..9a19da8 100644 --- a/cortex/main.py +++ b/cortex/main.py @@ -1,11 +1,14 @@ +import logging from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse import uvicorn +logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s") + from config import settings -from routers import chat, google_chat +from routers import chat, google_chat, nextcloud_talk @asynccontextmanager @@ -19,6 +22,7 @@ app = FastAPI(title="Cortex Dispatcher", lifespan=lifespan) app.include_router(chat.router) app.include_router(google_chat.router) +app.include_router(nextcloud_talk.router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/cortex/routers/nextcloud_talk.py b/cortex/routers/nextcloud_talk.py new file mode 100644 index 0000000..0b2b945 --- /dev/null +++ b/cortex/routers/nextcloud_talk.py @@ -0,0 +1,153 @@ +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 + +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}) + + 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 _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() + + if user_text.lower().startswith("@inara"): + user_text = user_text[6:].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) diff --git a/docs/NEXTCLOUD_TALK_BOT.md b/docs/NEXTCLOUD_TALK_BOT.md new file mode 100644 index 0000000..613662f --- /dev/null +++ b/docs/NEXTCLOUD_TALK_BOT.md @@ -0,0 +1,87 @@ +# Nextcloud Talk Bot Integration + +Inara is registered as a bot in Nextcloud Talk, receiving messages via webhook and replying through the bot API. + +--- + +## Installation + +Run on the Nextcloud server (inside the Docker container): + +```bash +docker exec -it --user www-data php /var/www/html/occ talk:bot:install \ + "Inara" \ + "" \ + "https://cortex.dgrzone.com/inara-nextcloud-talk-webhook" \ + --feature webhook --feature response --feature reaction +``` + +After installing, enable the bot in each Talk conversation via the conversation settings UI. + +--- + +## Configuration (cortex/.env) + +``` +NEXTCLOUD_TALK_BOT_SECRET= +``` + +`NEXTCLOUD_URL` defaults to `https://cloud.dgrzone.com` in `config.py`. + +--- + +## How It Works + +1. User sends a message in Talk → Nextcloud POSTs signed webhook to `/inara-nextcloud-talk-webhook` +2. Cortex verifies the incoming HMAC signature, extracts the message, runs it through the LLM +3. Cortex POSTs the reply to `/ocs/v2.php/apps/spreed/api/v1/bot/{token}/message` + +--- + +## HMAC Signing — Critical Detail + +**The signature covers `random + message_text`, NOT `random + raw_body`.** + +This differs from typical webhook protocols. Nextcloud Talk's `BotController::sendMessage` passes the *parsed `$message` parameter* to `ChecksumVerificationService::validateRequest`, not the raw request body. + +Source: `spreed/lib/Controller/BotController.php` → `getBotFromHeaders()`: +```php +$this->checksumVerificationService->validateRequest($random, $checksum, $secret, $message); +// $message is the parsed string, not $request->getContent() +``` + +Correct Python: +```python +sig = hmac.new( + secret.encode(), + (random_str + message_text).encode("utf-8"), # message_text only, not full body + hashlib.sha256, +).hexdigest() +``` + +Wrong (causes persistent 401): +```python +# DON'T sign the full JSON body: +sig = hmac.new(secret.encode(), (random_str + '{"message": "..."}').encode(), hashlib.sha256).hexdigest() +``` + +--- + +## Claude CLI Auth in systemd + +The `CLAUDE_CODE_OAUTH_TOKEN` in `.env` goes stale after each `claude auth login` (tokens rotate). Cortex reads the token live from `~/.claude/.credentials.json` on every Claude call (`llm_client._fresh_claude_token()`), so no manual `.env` update is needed after re-authentication. + +Also: never set `ANTHROPIC_API_KEY` to an OAuth token value (`sk-ant-oat01-...`) — the Claude CLI treats it as a direct API key and fails. Only real API keys (`sk-ant-api03-...`) belong in `ANTHROPIC_API_KEY`. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Webhook not received | Bot not enabled for conversation | Enable in Talk conversation settings | +| Incoming 401 on webhook | Wrong secret in `.env` | Match secret to `occ talk:bot:install` value | +| Reply POST returns 401 | HMAC computed over wrong data | Sign `random + message_text` only | +| Reply POST returns 401 (persistent) | Brute force protection | `occ security:bruteforce:reset ` | +| Claude falls back to Gemini | Stale/wrong auth token | Token is auto-refreshed from `~/.claude/.credentials.json`; run `claude auth login` if expired | +| Bot auto-disabled by Nextcloud | Webhook held open too long during LLM call | Use `BackgroundTasks` — return 200 immediately |