Files
Cortex-Inara/docs/NEXTCLOUD_TALK_BOT.md
Scott Idem c2825194d4 docs: update project docs, NC Talk guide, Tina persona, and gitignore
- 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>
2026-03-21 00:13:35 -04:00

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

  1. User sends a message in Talk → Nextcloud POSTs a signed webhook to /inara-nextcloud-talk-webhook
  2. Cortex verifies the incoming HMAC signature, extracts the message text, runs it through the LLM
  3. Cortex POSTs the reply to /ocs/v2.php/apps/spreed/api/v1/bot/{token}/message with its own HMAC signature
  4. 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 whatever AGENT_NAME is 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)