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

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