191 lines
9.1 KiB
Markdown
191 lines
9.1 KiB
Markdown
# ai-bot
|
|
|
|
A plaintext Matrix bot user (`@ai:vojo.chat`, display name **Vojo AI**) that
|
|
answers xAI Grok completions in its rooms: `@`-mentions in group rooms and every
|
|
message in a 1:1. It runs as a **Synapse application service** — Synapse pushes
|
|
event transactions to the bot's HTTP endpoint; the bot speaks the Matrix CS-API
|
|
back over plain HTTP (no Olm/Megolm — Vojo rooms are unencrypted by default) and
|
|
calls the xAI OpenAI-compatible Chat Completions API.
|
|
|
|
Authentication is the appservice `as_token`/`hs_token` (from the registration) —
|
|
non-expiring, so there is **no token rotation and no stored password**.
|
|
|
|
It is a **separate server-side service**, deployed next to Synapse. It lives in
|
|
this repo (alongside `apps/widget-*`) but ships nothing to the web client.
|
|
|
|
> Branding: user-facing name is **Vojo AI** with a generic icon. "Grok" appears
|
|
> only as the factual attribution ("powered by Grok, xAI") and as the real model
|
|
> id — never as the product name or logo (xAI Brand Guidelines).
|
|
|
|
Design source of truth: `docs/plans/grok_bot.md`. Privacy/152-ФЗ pre-launch
|
|
gating lives there (§6) and is **not** closed by this code.
|
|
|
|
## Layout
|
|
|
|
```
|
|
apps/ai-bot/
|
|
├── main.go # entrypoint, lifecycle, `check-config` subcommand
|
|
├── config.go # env parsing + validation + redacted summary
|
|
├── bot.go # event handling, classification, limiter wiring
|
|
├── appservice.go # HTTP transaction-push server (hs_token auth, txn idempotency)
|
|
├── matrix.go # CS-API client as the appservice user (as_token + ?user_id=)
|
|
├── registration.go # generate + read registration.yaml (tokens, mautrix idiom)
|
|
├── events.go # Matrix event types + decoders
|
|
├── mentions.go # m.mentions + pill/reply fallbacks (F29/F30)
|
|
├── context.go # xAI message-window assembly (trigger + bot replies)
|
|
├── xai.go # chat/completions client + retry (F6)
|
|
├── store.go # Postgres (vojo_ai): spend ledger, txn/event dedup, encrypted-warned set
|
|
├── messages.go # bot-authored RU notices
|
|
├── markdown.go # markdown → org.matrix.custom.html for the reply's formatted_body
|
|
├── util.go # bounded dedup set
|
|
├── prompts/system_ru.txt
|
|
├── Dockerfile # CGO-free static build → distroless, EXPOSE 8009
|
|
└── .env.example
|
|
```
|
|
|
|
## Configuration
|
|
|
|
All via environment (see `.env.example`). Required: `HOMESERVER_URL`, `BOT_MXID`,
|
|
`AS_TOKEN`, `HS_TOKEN`, `XAI_API_KEY`, `ALLOWED_SERVERS`, `AI_BOT_DATABASE_URL`.
|
|
`AS_ADDR` (default `:8009`) is the transaction-push listen address — it must match
|
|
the `url` port in the registration. The model is env-configurable (`XAI_MODEL`,
|
|
default `grok-4.20-0309-non-reasoning`; `grok-4.3` is an alternative — **re-verify
|
|
the id + price on docs.x.ai before deploy**).
|
|
|
|
### Database
|
|
|
|
The bot keeps its **operational state** — appservice transaction + event dedup, the
|
|
daily spend ledger, and the encrypted-room warned set — in a dedicated Postgres
|
|
database `vojo_ai` on the shared server, mirroring the per-service bridge databases
|
|
(each bridge owns its own role + DB). It stores **no message content**: the room
|
|
timeline is canonical in Synapse, and the bot's xAI context window is the in-memory
|
|
buffer in `bot.go`. The schema is created/migrated on startup (a `schema_version`
|
|
table + idempotent `CREATE TABLE IF NOT EXISTS`), so a fresh `vojo_ai` needs no
|
|
manual DDL — just the role + database:
|
|
|
|
```sql
|
|
-- once, as the Postgres superuser (e.g. `docker exec vojo-postgres-1 psql -U synapse -d postgres`):
|
|
CREATE ROLE vojo_ai LOGIN PASSWORD '<32-char secret>'; -- least privilege; NOT a superuser
|
|
CREATE DATABASE vojo_ai OWNER vojo_ai;
|
|
```
|
|
|
|
Point the bot at it with `AI_BOT_DATABASE_URL` (libpq/pgx DSN). Inside the docker
|
|
network the host is the `postgres` service; `sslmode=disable` matches Synapse and
|
|
the bridges on the internal network:
|
|
|
|
```
|
|
AI_BOT_DATABASE_URL=postgres://vojo_ai:<secret>@postgres:5432/vojo_ai?sslmode=disable
|
|
```
|
|
|
|
The hard USD ceiling is priced from the **API-returned token usage** times the
|
|
configured `XAI_PRICE_*_PER_M` fallbacks, so a price change only needs those
|
|
constants updated — it can't silently blow the cap.
|
|
|
|
## One-time setup (appservice registration)
|
|
|
|
Like the mautrix bridges (e.g. telegram), the bot **generates its own
|
|
registration** (random `as_token`/`hs_token`) and reads its tokens back from that
|
|
same file — the single source of truth shared with Synapse, no hand-copying.
|
|
|
|
1. Generate it (writes `REGISTRATION_PATH`, default `/data/registration.yaml`):
|
|
```bash
|
|
docker compose run --rm ai-bot generate-registration
|
|
```
|
|
2. Bind-mount that same file into the Synapse container (e.g. as
|
|
`/data/ai-registration.yaml`) and add it to `homeserver.yaml`:
|
|
```yaml
|
|
app_service_config_files:
|
|
- /data/ai-registration.yaml
|
|
```
|
|
3. **Restart Synapse** (it caches AS configs at startup). Synapse auto-creates
|
|
`@ai:vojo.chat` from `sender_localpart` — no `register_new_matrix_user`.
|
|
|
|
The bot reads `REGISTRATION_PATH` for its tokens (no env `AS_TOKEN`/`HS_TOKEN`
|
|
needed) and sets its own display name (`BOT_DISPLAY_NAME`, default "Vojo AI") on
|
|
startup. The bot writes/reads `/data`, so that dir must be owned by the image's
|
|
runtime uid (distroless nonroot = **65532**): `sudo chown -R 65532:65532 ~/vojo/ai-bot`.
|
|
|
|
## Run
|
|
|
|
```bash
|
|
go run . check-config # local config smoke test (no homeserver contact)
|
|
go run . # real run (needs env + a reachable homeserver)
|
|
```
|
|
|
|
### Image & secrets model
|
|
|
|
The image is **config-less** (a `.dockerignore` keeps `.env`, `state/` and VCS
|
|
out of the build context; the Dockerfile copies only the binary + `prompts/`).
|
|
Build locally and ship like the mautrix bridges (VS Code task **Deploy AI bot** =
|
|
`docker build -t ai-bot:custom` → `docker save | ssh docker load`), then run on
|
|
the server with config + secrets supplied at runtime.
|
|
|
|
Config and secrets are **separated**: non-secret config in `ai-bot.env`
|
|
(`env_file`); the appservice tokens live in the generated `registration.yaml`
|
|
(read via `REGISTRATION_PATH`); the only remaining standalone secret is the xAI
|
|
key (`XAI_API_KEY_FILE`).
|
|
|
|
Compose stanza (add to `~/vojo/docker-compose.yml`; the **service key `ai-bot`**
|
|
must match the registration `url` host `http://ai-bot:8009`):
|
|
|
|
```yaml
|
|
ai-bot:
|
|
image: ai-bot:custom
|
|
container_name: vojo-ai-bot
|
|
restart: unless-stopped
|
|
depends_on: [synapse, postgres] # needs both up before it starts
|
|
env_file: ./ai-bot/ai-bot.env # config incl. AI_BOT_DATABASE_URL (chmod 600 — embeds the DB password)
|
|
environment:
|
|
REGISTRATION_PATH: /data/registration.yaml # tokens (generated; shared with Synapse)
|
|
STATE_DIR: /data/state # runtime dir (the operational store is now in Postgres)
|
|
XAI_API_KEY_FILE: /data/secrets/xai_api_key # the one standalone secret
|
|
volumes:
|
|
- ./ai-bot:/data # owned by uid 65532 (see setup)
|
|
```
|
|
|
|
Also bind-mount the same registration into Synapse and restart it:
|
|
|
|
```yaml
|
|
synapse:
|
|
volumes:
|
|
- ./ai-bot/registration.yaml:/data/ai-registration.yaml:ro
|
|
```
|
|
|
|
`HOMESERVER_URL` must use the Synapse **service name** (`http://synapse:8008`),
|
|
not `localhost`. Synapse and the bot must share a docker network (same compose
|
|
project does this) so Synapse can push to `http://ai-bot:8009`.
|
|
|
|
## Verification status
|
|
|
|
Compile-level + unit-tested locally:
|
|
|
|
- ✅ `go vet` clean, `gofmt` clean, static CGO-free build.
|
|
- ✅ `go test` — appservice transaction handling (hs_token auth → 403 on bad
|
|
token, txnId idempotency / no re-dispatch, legacy `?access_token=`, user query
|
|
200/404); mention detection (m.mentions, empty-`{}` F29, no-body-fallback F30,
|
|
pill, reply-to-bot); DM classification (invited+joined==2, F3: 2 joined + 1
|
|
invited is **not** a 1:1); group-vs-DM context minimisation (groups never leak
|
|
third-party content); USD pricing; **markdown → HTML rendering** (escaping,
|
|
safe-URL allowlist, false-positive guards, oversize/adversarial fallbacks).
|
|
- ✅ `check-config` reads env + loads the system prompt.
|
|
|
|
The store-backed tests (appservice transaction handling + the dedup/limiter/warned
|
|
store in `store_test.go`, including the concurrent per-user-cap guarantee and
|
|
restart-durability) need a throwaway Postgres via `AI_BOT_TEST_DATABASE_URL`; they
|
|
**skip** when it is unset, so `go test ./...` stays green without one. To run them:
|
|
|
|
```bash
|
|
docker run -d --name pg -e POSTGRES_PASSWORD=p -p 5432:5432 postgres:16
|
|
# … create role+db vojo_ai, then:
|
|
AI_BOT_TEST_DATABASE_URL=postgres://vojo_ai:…@localhost:5432/vojo_ai?sslmode=disable go test ./...
|
|
```
|
|
|
|
Deferred to a live homeserver + xAI key + a loaded registration (runtime ✔):
|
|
|
|
- Synapse pushes transactions → bot replies (`authenticated as @ai:vojo.chat` in logs);
|
|
- invite from `:vojo.chat` → join, foreign-server invite → leave (F11);
|
|
- `@`-mention / 1:1 message → `m.notice` reply with reply (and thread, F27) relation,
|
|
carrying a `formatted_body` (org.matrix.custom.html) when the answer has markdown;
|
|
- encrypted room → exactly one notice, **not** repeated after restart (F5);
|
|
- per-user cap → silent drop; global USD ceiling → one notice/room/day;
|
|
- a retried transaction (lost 200) is processed at most once (txn dedup).
|