- 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>
3.3 KiB
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):
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
- User sends a message in Talk → Nextcloud POSTs signed webhook to
/inara-nextcloud-talk-webhook - Cortex verifies the incoming HMAC signature, extracts the message, runs it through the LLM
- 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():
$this->checksumVerificationService->validateRequest($random, $checksum, $secret, $message);
// $message is the parsed string, not $request->getContent()
Correct 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):
# 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 |