# Nextcloud Talk Bot Integration Cortex connects to Nextcloud Talk as a bot — each Cortex user gets their own webhook endpoint routed to their chosen persona. **Status:** Live and confirmed working (2026-03-20); per-user routing added 2026-03-27 --- ## Prerequisites - Access to the Nextcloud server (Docker exec or SSH) - The Cortex server reachable at a public HTTPS URL - The user pre-registered in Cortex (`manage_passwords.py invite`) --- ## Per-User Setup ### 1. Create the user's `channels.json` Create `home/{username}/channels.json` on the Cortex server: ```json { "nextcloud": { "persona": "inara", "url": "https://cloud.dgrzone.com", "bot_secret": "", "timeout": 55 } } ``` - **`persona`** — which persona responds (must exist under `home/{username}/persona/`) - **`url`** — base URL of the Nextcloud instance - **`bot_secret`** — a shared HMAC secret; you choose this value and use it in both `channels.json` and the `occ` install command - **`timeout`** — seconds to wait for the LLM before sending a timeout message (NC Talk is async, so 55s is safe) ### 2. Register the bot in Nextcloud The Nextcloud container for DgrZone is `dgr_zone_nextcloud-app-1`. Substitute your own container name if different. First, list existing bots to check if one is already registered (note the bot ID): ```bash docker exec -it --user www-data dgr_zone_nextcloud-app-1 php /var/www/html/occ talk:bot:list ``` If re-registering (new URL or new secret), uninstall the old bot first: ```bash docker exec -it --user www-data dgr_zone_nextcloud-app-1 php /var/www/html/occ talk:bot:uninstall ``` Install the bot: ```bash docker exec -it --user www-data dgr_zone_nextcloud-app-1 php /var/www/html/occ talk:bot:install \ "Inara" \ "" \ "https://cortex.dgrzone.com/webhook/nextcloud/{username}" \ --feature webhook --feature response --feature reaction ``` After installing, enable the bot in each Talk conversation: open the conversation → three-dot menu → **Bots** → enable the bot by name. --- ## How It Works 1. User sends a message in Talk → Nextcloud POSTs a signed webhook to `/webhook/nextcloud/{username}` 2. Cortex reads the user's `channels.json`, verifies the incoming HMAC signature 3. Sets the persona context, builds the system prompt, runs the LLM in a `BackgroundTask` 4. Returns HTTP 200 immediately (prevents Nextcloud from disabling the bot due to slow response) 5. Cortex POSTs the reply to `/ocs/v2.php/apps/spreed/api/v1/bot/{token}/message` with its own HMAC signature --- ## 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)`: ```python 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()`. ```python 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): ```python # DON'T sign the full JSON body: sig = hmac.new(secret.encode(), (random_str + '{"message": "..."}').encode(), hashlib.sha256).hexdigest() ``` --- ## Nginx The `/webhook/` prefix is already public in `auth_middleware.py`. If Nginx applies basic auth or IP restrictions, add a `location` block before the default auth block: ```nginx location ^~ /webhook/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } ``` --- ## Triggering the Bot - **@mention** — prefix the message with `@{persona_name}`; 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 --- ## Logs Two log streams are useful when debugging: ```bash # Nextcloud server logs (bot registration errors, webhook rejections) docker exec -it --user www-data dgr_zone_nextcloud-app-1 php /var/www/html/occ log:tail # Cortex service logs (LLM errors, signature failures, timeouts) journalctl --user -u cortex -f ``` --- ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | 404 on the webhook | `channels.json` missing or no `nextcloud` key | Create/check `home/{username}/channels.json` | | Webhook not received | Bot not enabled for conversation | Enable in Talk conversation settings (Bots) | | Incoming 401 | `bot_secret` in `channels.json` doesn't match `occ install` secret | Re-register with matching secret | | 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 | `docker exec -it --user www-data dgr_zone_nextcloud-app-1 php /var/www/html/occ security:bruteforce:reset ` | | Bot auto-disabled by Nextcloud | Webhook held open too long | Verify `BackgroundTasks` is used — Cortex returns 200 immediately | | Claude falls back to Gemini | Stale/expired auth token | Run `claude auth login`; token is auto-refreshed from `~/.claude/.credentials.json` | | No response at all | Nginx blocking the path | Add a `location ^~ /webhook/` block before any auth block |