- CLAUDE.md: add new auth/onboarding files to directory map, update security section (JWT/bcrypt/invite details), expand recently completed - README.md: fix Web UI auth description, add User Management section - TODO__Agents.md: mark NC Talk docs and auth/onboarding complete, update Holly onboarding plan to reflect single-instance multi-user approach - docs/NEXTCLOUD_TALK_BOT.md: complete guide — occ commands, nginx config, clarify incoming vs outgoing HMAC difference, multi-user note, full troubleshooting table - home/holly/persona/tina/: flesh out all four persona files with real content (DCC name origin, metal music, reading, foster cats, Holly's profile) - .gitignore: exclude home/**/auth.json, invite.json, profile.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5.4 KiB
Nextcloud Talk Bot Integration
Inara is registered as a bot in Nextcloud Talk, receiving messages via webhook and replying through the bot API.
Status: Live and confirmed working (2026-03-20)
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 (three-dot menu → Bots).
To list installed bots and verify registration:
docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bot:list
To uninstall (if re-registering with a new secret):
docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bot:remove <bot-id>
Configuration
cortex/.env:
NEXTCLOUD_URL=https://cloud.dgrzone.com
NEXTCLOUD_TALK_BOT_SECRET=<shared secret — must match occ install command>
NEXTCLOUD_URL defaults to https://cloud.dgrzone.com in config.py.
Nginx: The /inara-nextcloud-talk-webhook endpoint must be reachable by Nextcloud without basic auth. Add a location block before the default auth_basic block:
location = /inara-nextcloud-talk-webhook {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
(The /channels/ prefix is already bypassed for Google Chat — consider moving the webhook path to /channels/nextcloud in a future cleanup to unify the nginx config.)
How It Works
- User sends a message in Talk → Nextcloud POSTs a signed webhook to
/inara-nextcloud-talk-webhook - Cortex verifies the incoming HMAC signature, extracts the message text, runs it through the LLM
- Cortex POSTs the reply to
/ocs/v2.php/apps/spreed/api/v1/bot/{token}/messagewith its own HMAC signature - The webhook handler returns HTTP 200 immediately; the LLM call happens in a
BackgroundTask(prevents Nextcloud from disabling the bot due to slow response)
HMAC Signing — Critical Detail
Incoming and outgoing signatures use different message construction. This is the most common source of 401 errors.
Incoming (Nextcloud → Cortex)
Nextcloud signs its outgoing webhook with HMAC-SHA256(secret, random + raw_body):
# _verify_signature in nextcloud_talk.py
expected = hmac.new(
secret.encode(),
(random_header + body.decode("utf-8")).encode(),
hashlib.sha256,
).hexdigest()
Outgoing (Cortex → Nextcloud)
When Cortex posts a reply, Nextcloud verifies the signature against the parsed message string, not the raw body. This is because BotController::sendMessage passes the parsed $message parameter to checksumVerificationService::validateRequest, not $request->getContent().
# _send_reply in nextcloud_talk.py
sig = hmac.new(
secret.encode(),
(random_str + message).encode("utf-8"), # message text only, NOT json.dumps({"message": ...})
hashlib.sha256,
).hexdigest()
Wrong (causes persistent outgoing 401):
# DON'T sign the full JSON body:
sig = hmac.new(secret.encode(), (random_str + '{"message": "..."}').encode(), hashlib.sha256).hexdigest()
Multi-User Note
NC Talk currently uses the default user and persona (settings.default_tier, load_context()). All Talk conversations go to Inara regardless of who is messaging. Per-conversation persona routing (e.g., Holly gets Tina) is a future enhancement — would require mapping Nextcloud user IDs or conversation tokens to Cortex users.
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.
Triggering the Bot
- @mention — prefix the message with
@inara(or whateverAGENT_NAMEis set to); the prefix is stripped before sending to the LLM - Any message in a conversation where the bot is enabled — all messages are forwarded, not just @mentions
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Webhook not received | Bot not enabled for conversation | Enable in Talk conversation settings (Bots) |
| Incoming 401 | Wrong secret in .env |
Match secret to occ talk:bot:install value |
| Reply POST returns 401 (first try) | HMAC computed over wrong data | Sign random + message_text only (not raw JSON body) |
| Reply POST returns 401 (persistent) | Brute force protection triggered | occ security:bruteforce:reset <cortex-IP> |
| Bot auto-disabled by Nextcloud | Webhook held open too long | Verify BackgroundTasks is used — return 200 immediately |
| Claude falls back to Gemini | Stale/wrong auth token | Token is auto-refreshed from ~/.claude/.credentials.json; run claude auth login if expired |
| No response at all | Nginx blocking the path with basic auth | Add a location = block before the auth block (see Nginx section above) |