From ddf5dd6338a3b701a2cd6efe0b194bcdbf9eb37b Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Sun, 29 Mar 2026 13:24:36 -0400 Subject: [PATCH] docs: add Google Chat setup guide, update NC Talk for per-user routing - docs/GOOGLE_CHAT_BOT.md: new step-by-step guide covering channels.json, Google Cloud Console config, JWT audience, and troubleshooting - docs/NEXTCLOUD_TALK_BOT.md: updated for per-user endpoints (/webhook/nextcloud/{username}), channels.json config, removed old server-level .env references, updated Multi-User note Co-Authored-By: Claude Sonnet 4.6 --- docs/GOOGLE_CHAT_BOT.md | 100 ++++++++++++++++++++++++++++++++++ docs/NEXTCLOUD_TALK_BOT.md | 108 +++++++++++++++++++------------------ 2 files changed, 157 insertions(+), 51 deletions(-) create mode 100644 docs/GOOGLE_CHAT_BOT.md diff --git a/docs/GOOGLE_CHAT_BOT.md b/docs/GOOGLE_CHAT_BOT.md new file mode 100644 index 0000000..9c1dc1a --- /dev/null +++ b/docs/GOOGLE_CHAT_BOT.md @@ -0,0 +1,100 @@ +# Google Chat Bot Integration + +Cortex connects to Google Chat as a **Workspace Add-on** — each Cortex user gets their own webhook endpoint routed to their chosen persona. + +**Status:** Live and confirmed working (2026-03-27) + +--- + +## Prerequisites + +- A Google Cloud project with **Google Chat API** enabled +- The Cortex server reachable at a public HTTPS URL +- The user pre-registered in Cortex (`manage_passwords.py invite` or `google-add`) + +--- + +## Per-User Setup + +### 1. Create the user's `channels.json` + +Create `home/{username}/channels.json` on the Cortex server: + +```json +{ + "google_chat": { + "persona": "inara", + "audience": "https://cortex.dgrzone.com/channels/google-chat/{username}", + "backend": "claude", + "timeout": 25 + } +} +``` + +- **`persona`** — which persona responds (must exist under `home/{username}/persona/`) +- **`audience`** — must exactly match the HTTP endpoint URL you set in Google Cloud Console (Google uses this as the JWT `aud` claim) +- **`backend`** — `"claude"` recommended; Google Chat requires a response within 30s +- **`timeout`** — keep at 25 (Google's hard limit is 30s; this leaves a 5s buffer) + +### 2. Configure Google Chat API in Google Cloud Console + +1. Go to [console.cloud.google.com](https://console.cloud.google.com) and select the project +2. **APIs & Services → Enabled APIs & services → Google Chat API** +3. Click the **Configuration** tab +4. Fill in **Application info:** + - App name: `Cortex` (or your persona name) + - Avatar URL: optional + - Description: optional +5. Under **Interactive features:** + - Enable **"Join spaces and group conversations"** if you want the bot in group chats, or leave it off for DM-only +6. Under **Connection settings:** + - Select **HTTP endpoint URL** + - Enter: `https://cortex.dgrzone.com/channels/google-chat/{username}` +7. Under **Visibility:** + - Add the specific Google accounts that should be able to use this bot + - For One Sky IT Workspace users: add individuals or the whole domain +8. Click **Save** + +> **Important:** The URL in step 6 must exactly match the `audience` value in `channels.json`. Google includes this URL as the JWT `aud` claim on every request, and Cortex rejects any request where they don't match. + +--- + +## How It Works + +1. User sends a message in Google Chat → Google POSTs a signed JSON payload to `/channels/google-chat/{username}` +2. Cortex reads the user's `channels.json`, verifies the JWT `systemIdToken` from `authorizationEventObject` +3. Sets the persona context, builds the system prompt, calls the LLM +4. Returns the response wrapped in `hostAppDataAction → chatDataAction → createMessageAction` + +The response must be returned synchronously (Google Chat does not support async/background replies like NC Talk does). The 25s timeout is a hard constraint. + +--- + +## JWT Verification + +Google Chat Workspace Add-ons send a `systemIdToken` in the request body at: +`body["authorizationEventObject"]["systemIdToken"]` + +Claims verified by Cortex: +- `iss` = `https://accounts.google.com` +- `aud` = the value of `audience` in `channels.json` + +If `audience` is empty, verification is skipped (useful for local testing, never in production). + +--- + +## Nginx + +The `/channels/` prefix is already public in `auth_middleware.py` — no Nginx changes needed if you're already proxying all traffic to Cortex. Verify the path isn't blocked by basic auth or IP restrictions. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| 404 on the webhook | `channels.json` missing or no `google_chat` key | Create/check `home/{username}/channels.json` | +| 401 Invalid token | `audience` in `channels.json` doesn't match the endpoint URL | Make them identical — copy the URL exactly | +| 401 Missing token | No `systemIdToken` in request | Bot may not be a Workspace Add-on; check connection settings type | +| Timeout / no response | LLM too slow | `backend: "claude"` recommended; reduce context tier if needed | +| Bot not receiving messages | Visibility not configured | Add the user's Google account under Visibility in Cloud Console | diff --git a/docs/NEXTCLOUD_TALK_BOT.md b/docs/NEXTCLOUD_TALK_BOT.md index e26e7dc..8b1140d 100644 --- a/docs/NEXTCLOUD_TALK_BOT.md +++ b/docs/NEXTCLOUD_TALK_BOT.md @@ -1,20 +1,50 @@ # Nextcloud Talk Bot Integration -Inara is registered as a bot in Nextcloud Talk, receiving messages via webhook and replying through the bot API. +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) +**Status:** Live and confirmed working (2026-03-20); per-user routing added 2026-03-27 --- -## Installation +## 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 Run on the Nextcloud server (inside the Docker container): ```bash docker exec -it --user www-data php /var/www/html/occ talk:bot:install \ - "Inara" \ - "" \ - "https://cortex.dgrzone.com/inara-nextcloud-talk-webhook" \ + "{persona_name}" \ + "" \ + "https://cortex.dgrzone.com/webhook/nextcloud/{username}" \ --feature webhook --feature response --feature reaction ``` @@ -26,7 +56,7 @@ To list installed bots and verify registration: docker exec -it --user www-data php /var/www/html/occ talk:bot:list ``` -To uninstall (if re-registering with a new secret): +To remove a bot (e.g. to re-register with a new secret or URL): ```bash docker exec -it --user www-data php /var/www/html/occ talk:bot:remove @@ -34,36 +64,13 @@ docker exec -it --user www-data php /var/www/html/occ talk:bo --- -## Configuration - -**`cortex/.env`:** -``` -NEXTCLOUD_URL=https://cloud.dgrzone.com -NEXTCLOUD_TALK_BOT_SECRET= -``` - -`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: - -```nginx -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) +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 --- @@ -76,7 +83,6 @@ location = /inara-nextcloud-talk-webhook { Nextcloud signs its outgoing webhook with `HMAC-SHA256(secret, random + raw_body)`: ```python -# _verify_signature in nextcloud_talk.py expected = hmac.new( secret.encode(), (random_header + body.decode("utf-8")).encode(), @@ -89,7 +95,6 @@ expected = hmac.new( 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 -# _send_reply in nextcloud_talk.py sig = hmac.new( secret.encode(), (random_str + message).encode("utf-8"), # message text only, NOT json.dumps({"message": ...}) @@ -105,23 +110,23 @@ sig = hmac.new(secret.encode(), (random_str + '{"message": "..."}').encode(), ha --- -## Multi-User Note +## Nginx -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. +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: ---- - -## 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`. +```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 `@inara` (or whatever `AGENT_NAME` is set to); the prefix is stripped before sending to the LLM +- **@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 --- @@ -130,10 +135,11 @@ Also: never set `ANTHROPIC_API_KEY` to an OAuth token value (`sk-ant-oat01-...`) | 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 | Wrong secret in `.env` | Match secret to `occ talk:bot:install` value | +| 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 | `occ security:bruteforce:reset ` | -| 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) | +| 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 |