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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-16 23:04:26 -04:00
parent 8add4ffd02
commit fe854ee534
5 changed files with 280 additions and 4 deletions

View File

@@ -20,6 +20,12 @@ class Settings(BaseSettings):
google_chat_timeout: int = 25 google_chat_timeout: int = 25
# Backend forced for Google Chat — Claude is more reliable within the 25s deadline # Backend forced for Google Chat — Claude is more reliable within the 25s deadline
google_chat_backend: str = "claude" 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" host: str = "0.0.0.0"
port: int = 8000 port: int = 8000

View File

@@ -64,6 +64,23 @@ async def _dispatch(
return await _claude(system_prompt, messages, model) 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: async def _claude(system_prompt: str, messages: list[dict], model: str | None) -> str:
cmd = [ cmd = [
"claude", "--print", "claude", "--print",
@@ -75,7 +92,16 @@ async def _claude(system_prompt: str, messages: list[dict], model: str | None) -
if system_prompt: if system_prompt:
cmd.extend(["--system-prompt", system_prompt]) cmd.extend(["--system-prompt", system_prompt])
cmd.append(_build_conversation(messages)) 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: 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() 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() loop = asyncio.get_running_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, 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: if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}" detail = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"

View File

@@ -1,11 +1,14 @@
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
import uvicorn import uvicorn
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
from config import settings from config import settings
from routers import chat, google_chat from routers import chat, google_chat, nextcloud_talk
@asynccontextmanager @asynccontextmanager
@@ -19,6 +22,7 @@ app = FastAPI(title="Cortex Dispatcher", lifespan=lifespan)
app.include_router(chat.router) app.include_router(chat.router)
app.include_router(google_chat.router) app.include_router(google_chat.router)
app.include_router(nextcloud_talk.router)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")

View File

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

View File

@@ -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 <nc-app-container> php /var/www/html/occ talk:bot:install \
"Inara" \
"<secret from cortex .env NEXTCLOUD_TALK_BOT_SECRET>" \
"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=<shared secret — must match occ install command>
```
`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 <cortex-IP>` |
| 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 |