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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-29 13:24:36 -04:00
parent 93f7f44e51
commit ddf5dd6338
2 changed files with 157 additions and 51 deletions

100
docs/GOOGLE_CHAT_BOT.md Normal file
View File

@@ -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 |

View File

@@ -1,20 +1,50 @@
# Nextcloud Talk Bot Integration # 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": "<a secret you choose — must match the occ install command>",
"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): Run on the Nextcloud server (inside the Docker container):
```bash ```bash
docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bot:install \ docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bot:install \
"Inara" \ "{persona_name}" \
"<secret from cortex .env NEXTCLOUD_TALK_BOT_SECRET>" \ "<bot_secret from channels.json>" \
"https://cortex.dgrzone.com/inara-nextcloud-talk-webhook" \ "https://cortex.dgrzone.com/webhook/nextcloud/{username}" \
--feature webhook --feature response --feature reaction --feature webhook --feature response --feature reaction
``` ```
@@ -26,7 +56,7 @@ To list installed bots and verify registration:
docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bot:list 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): To remove a bot (e.g. to re-register with a new secret or URL):
```bash ```bash
docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bot:remove <bot-id> docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bot:remove <bot-id>
@@ -34,36 +64,13 @@ docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bo
--- ---
## 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:
```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 ## How It Works
1. User sends a message in Talk → Nextcloud POSTs a signed webhook to `/inara-nextcloud-talk-webhook` 1. User sends a message in Talk → Nextcloud POSTs a signed webhook to `/webhook/nextcloud/{username}`
2. Cortex verifies the incoming HMAC signature, extracts the message text, runs it through the LLM 2. Cortex reads the user's `channels.json`, verifies the incoming HMAC signature
3. Cortex POSTs the reply to `/ocs/v2.php/apps/spreed/api/v1/bot/{token}/message` with its own HMAC signature 3. Sets the persona context, builds the system prompt, runs the LLM in a `BackgroundTask`
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) 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)`: Nextcloud signs its outgoing webhook with `HMAC-SHA256(secret, random + raw_body)`:
```python ```python
# _verify_signature in nextcloud_talk.py
expected = hmac.new( expected = hmac.new(
secret.encode(), secret.encode(),
(random_header + body.decode("utf-8")).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()`. 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 ```python
# _send_reply in nextcloud_talk.py
sig = hmac.new( sig = hmac.new(
secret.encode(), secret.encode(),
(random_str + message).encode("utf-8"), # message text only, NOT json.dumps({"message": ...}) (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:
--- ```nginx
location ^~ /webhook/ {
## Claude CLI Auth in systemd proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
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. proxy_set_header X-Real-IP $remote_addr;
}
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 ## 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 - **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 | | 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) | | 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 (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>` | | 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 | | Bot auto-disabled by Nextcloud | Webhook held open too long | Verify `BackgroundTasks` is used — Cortex returns 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 | | 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 with basic auth | Add a `location =` block before the auth block (see Nginx section above) | | No response at all | Nginx blocking the path | Add a `location ^~ /webhook/` block before any auth block |