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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
153
cortex/routers/nextcloud_talk.py
Normal file
153
cortex/routers/nextcloud_talk.py
Normal 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)
|
||||||
87
docs/NEXTCLOUD_TALK_BOT.md
Normal file
87
docs/NEXTCLOUD_TALK_BOT.md
Normal 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 |
|
||||||
Reference in New Issue
Block a user