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:
100
docs/GOOGLE_CHAT_BOT.md
Normal file
100
docs/GOOGLE_CHAT_BOT.md
Normal 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 |
|
||||
@@ -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": "<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):
|
||||
|
||||
```bash
|
||||
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" \
|
||||
"{persona_name}" \
|
||||
"<bot_secret from channels.json>" \
|
||||
"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 <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
|
||||
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
|
||||
|
||||
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 <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) |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user