commit 98a3614d58b68d5b274858f497a4a27ac7d3daac Author: corrado.mulas Date: Sun Oct 4 03:38:08 2026 +0200 latexgrambot: inline LaTeX bot for Telegram in Go Renders LaTeX expressions into PNG pictures, PDF documents and native Telegram rich text messages, entirely locally with TeX Live and poppler. - Chat mode with /image, /pdf, /rich plus per-user preamble and DPI - Inline mode delivering picture, PDF and rich text results - Broad default package set preloaded into a TeX format at image build - MongoDB persistence: settings, users, groups, render statistics - Private S3-compatible bucket with short-lived presigned URLs - OpenTelemetry traces over OTLP - Kubernetes manifests and Argo CD application, Docker Compose, GitHub Actions CI, BSD 3-Clause license diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c5332d8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.cache +README.md +k8s +argocd-application.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8da3abf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + cache: true + - name: gofmt + run: test -z "$(gofmt -l $(git ls-files '*.go'))" + - run: go vet ./... + - run: go test ./... + + image: + if: github.event_name == 'push' + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=sha,format=short + type=ref,event=branch + type=semver,pattern={{version}} + type=raw,value=latest,enable={{is_default_branch}} + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89b38ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*/secret.yaml +.cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1fdd411 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM golang:1.23-alpine AS build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ARG TARGETOS +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w" -o /latexgrambot ./cmd/latexgrambot + +# Runtime: TeX Live (pdflatex) and poppler (pdftoppm). The latex-extra, +# pictures, science and plain-generic collections cover the broad default +# preamble (tikz, pgfplots, siunitx, mhchem, chemfig, ...). +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates tzdata \ + texlive-latex-base texlive-latex-recommended texlive-fonts-recommended \ + texlive-latex-extra texlive-pictures texlive-science texlive-plain-generic \ + lmodern poppler-utils \ + && rm -rf /var/lib/apt/lists/* \ + && adduser --disabled-password --gecos "" --uid 1000 appuser +COPY --from=build /latexgrambot /usr/local/bin/latexgrambot + +# Preload the default document class and package set into a TeX format, so +# every render skips loading ~50 packages (about a second per compile). +RUN mkdir -p /tmp/format \ + && /usr/local/bin/latexgrambot -print-preamble > /tmp/format/preamble.tex \ + && cd /tmp/format \ + && pdflatex -ini -jobname=latexgrambot "&pdflatex" mylatexformat.ltx preamble.tex >/dev/null \ + && install -d /usr/local/share/texmf/web2c \ + && install -m 0644 /tmp/format/latexgrambot.fmt /usr/local/share/texmf/web2c/latexgrambot.fmt \ + && mktexlsr \ + && rm -rf /tmp/format + +USER appuser +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/latexgrambot"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c918ba2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2026, latexgrambot contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4530f89 --- /dev/null +++ b/README.md @@ -0,0 +1,305 @@ +# latexgrambot + +Inline LaTeX bot for Telegram, written in Go. + +> [!**IMPORTANT NOTICE:**] This work has been made with an extra-supervised use of AI agents to perform the grunt work, with architectural choices strictly human-imposed. Has been successfully tested on a busy production environment, but in no case there is any guarantee nor I'm to be held liable of anything if you re-use this work. + + +## Features + +- **Chat mode**: send a message containing LaTeX and the bot replies with a + rich text message (the default). `/image` and `/pdf` return the PNG + picture or the PDF document instead. +- **Inline mode**: in any chat, type `@yourbot ` and pick one of + the results — picture, PDF or rich text. Picking one sends the output into + the chat immediately. +- **Rich text rendering**: replies are Telegram rich messages (Bot API 10.x + `sendRichMessage`) whose `mathematical_expression` block is rendered + natively by the client. The raw source is never echoed. +- **Full LaTeX**: a broad default package set (amsmath, mathtools, tikz, + pgfplots, siunitx, mhchem, chemfig, braket, hyperref, ...) is preloaded + into a TeX format at image build time, so any of it is available without + a custom preamble and without paying the package-load cost per render. +- **Per-user preamble and resolution**: `/setpreamble`, `/resetpreamble`, + `/setdpi`. +- **Persistence and statistics**: per-chat settings, users, group + memberships, render statistics and the inline file-id cache live in + MongoDB; the public `/stats` command reports aggregates. +- **Observability**: OpenTelemetry traces over OTLP gRPC for the HTTP + server, the Telegram API client, the MongoDB driver and every render. + +## Architecture + +``` + Telegram + │ long polling (getUpdates) + ▼ + ┌───────────────────────┐ + │ latexgrambot │ + │ ┌─────────────────┐ │ + commands │ │ bot package │ │ + messages ─┼─▶│ routing, inline │ │ + inline │ │ settings, cache │ │ + queries │ └────────┬────────┘ │ + │ │ │ + │ ┌────────▼────────┐ │ ┌──────────────┐ + │ │ render package │──┼─────▶│ TeX Live │ + │ │ pdflatex+poppler│ │ │ (in image) │ + │ └────────┬────────┘ │ └──────────────┘ + │ │ │ + │ ┌────────▼────────┐ │ ┌──────────────┐ + │ │ store / files │──┼─────▶│ MongoDB │ + │ │ settings, stats │ │ └──────────────┘ + │ │ presigned URLs │──┼─────▶┌──────────────┐ + │ └─────────────────┘ │ │ S3-compatible│ + └───────────┬───────────┘ │ bucket │ + │ └──────────────┘ + │ presigned GET URL fetched by Telegram + ▼ + selected result +``` + +The bot is a single static Go binary plus the TeX Live/poppler runtime. It +only makes outbound connections: Telegram (long polling), MongoDB and the +S3-compatible bucket. No inbound traffic is required except when Telegram +fetches a presigned asset URL. + +## How rendering works + +1. The expression is validated (size caps, a small blocklist of file-reading + TeX primitives) and, when it is entirely math without delimiters, wrapped + in math mode up front. +2. `pdflatex` compiles the document with + `-interaction=nonstopmode -halt-on-error -no-shell-escape` — shell escape + is explicitly disabled for user-supplied code — under a hard timeout. + When a compile fails only because math delimiters were missing, one retry + wraps the whole expression in math mode; if that fails too, the original + error is reported. +3. `pdftoppm` converts the first PDF page to a PNG or JPEG at the configured + DPI. Small formulas rasterize to only a few hundred pixels, so the raster + is re-rendered upscaled (smallest side 2560 px, capped at 4096): Telegram + would otherwise upscale and JPEG-recompress the small picture, blurring + the glyphs. +4. Only the formats a caller needs are produced: chat `/image` renders a + PNG, chat `/pdf` skips rasterization entirely, inline answers render a + JPEG plus the PDF. + +The default preamble is preloaded into a TeX format (`LATEX_FORMAT`) at +image build time: a render compiles only the document body and loads no +packages. + +## Inline delivery + +Inline answers never send a temporary message to the querying user's chat. +If a normal `/image` or `/pdf` reply already produced a correctly typed +Telegram file ID for the same content hash, inline mode reuses it directly. +Otherwise the rendered JPEG/PDF is uploaded to the private bucket under a +fresh UUIDv4 object name and Telegram receives a short-lived presigned GET +URL, which it fetches when the result is picked. A background sweeper +deletes objects once their signed links have certainly expired (twice the +presign TTL, checked every 15 minutes). The native rich-text result needs +neither rendering nor storage. + +Presigned URLs must be reachable from the public internet, so +`S3_PUBLIC_ENDPOINT_URL` has to be the public endpoint of your bucket. If +no S3 settings are provided, inline mode only offers the rich text result. + +## Data model + +MongoDB database (default `latexgram`): + +| Collection | Contents | +|---|---| +| `settings` | per-chat preamble and DPI overrides | +| `users` | every interacting user, first/last seen | +| `groups` | chats the bot was added to or removed from | +| `stats` | one document per render: source, format, DPI, outcome, duration | +| `files` | Telegram file IDs captured from chat replies, per content hash | + +S3 bucket layout: `/.jpg` and `/.pdf`. + +## Requirements + +- Go 1.23+ to build from source. +- `pdflatex` (TeX Live) and `pdftoppm` (poppler) at runtime; the provided + Dockerfile installs everything, including the TeX collections backing the + default preamble. +- MongoDB (only hard dependency). +- Optional: an S3-compatible bucket for inline picture/PDF results. +- A Telegram bot token. For rich text replies the bot must talk to a Bot + API 10.x server (the default `api.telegram.org` qualifies). + +## Quick start + +### Docker + +```sh +docker build -t latexgrambot . +docker run --rm -p 8080:8080 \ + -e TELEGRAM_BOT_TOKEN=123:abc \ + -e MONGODB_URI='mongodb://mongo:27017' \ + latexgrambot +``` + +### Docker Compose + +```sh +TELEGRAM_BOT_TOKEN=123:abc docker compose up --build +``` + +This starts MongoDB and the bot. Add the S3 variables from the compose file +to enable inline picture/PDF results. + +### From source + +```sh +go build ./cmd/latexgrambot +TELEGRAM_BOT_TOKEN=123:abc MONGODB_URI='mongodb://localhost:27017' ./latexgrambot +``` + +`pdflatex` and `pdftoppm` must be on `PATH`. The preloaded format is only +present in the container image; local runs simply load the packages per +render. + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `TELEGRAM_BOT_TOKEN` | required | Bot token (secret) | +| `MONGODB_URI` | required | MongoDB connection string | +| `MONGODB_DATABASE` | `latexgram` | Database with the collections above | +| `S3_ENDPOINT_URL` | empty | S3-compatible endpoint used by the bot | +| `S3_REGION` | `us-east-1` | S3 region used for signing | +| `S3_BUCKET` | empty | Bucket for inline assets | +| `S3_PREFIX` | `content` | Key prefix inside the bucket | +| `S3_PUBLIC_ENDPOINT_URL` | empty | Public endpoint used when signing GET URLs (must be reachable by Telegram) | +| `S3_PRESIGN_TTL` | `1h` | Lifetime of presigned download URLs | +| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | empty | S3 credentials (secret) | +| `LATEX_FORMAT` | empty | Preloaded TeX format name (the image ships `latexgrambot`); empty disables it | +| `RENDER_DPI` | `600` | Default picture resolution | +| `RENDER_TIMEOUT` | `30s` | Per-render `pdflatex` timeout | +| `RENDER_MAX_CONCURRENCY` | `2` | Simultaneous `pdflatex` runs | +| `MAX_EXPRESSION_LEN` | `4000` | Max expression length in characters | +| `MAX_PREAMBLE_LEN` | `4000` | Max preamble length in characters | +| `RICH_TEXT_ENABLED` | `true` | Rich text replies (needs a Bot API 10.x server) | +| `RICH_DEFAULT_MATH` | `true` | A bare source without delimiters/text commands renders as math; `false` parses it as text, so formulas need `$...$` | +| `HEALTH_ADDR` | `:8080` | Health endpoint address | +| `PDFLATEX_BIN` | `pdflatex` | pdflatex binary | +| `PDFTOPPM_BIN` | `pdftoppm` | pdftoppm binary | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | empty | OTLP gRPC collector; empty disables tracing | +| `OTEL_EXPORTER_OTLP_INSECURE` | `true` | Plaintext gRPC to the collector | +| `OTEL_SERVICE_NAME` | `latexgrambot` | Service name in traces | + +## Deployment + +### Kubernetes + +The manifests in `k8s/` are a complete deployment for a single replica: + +```sh +kubectl apply -f k8s/configmap.yaml +kubectl apply -f k8s/deployment.yaml +kubectl apply -f k8s/service.yaml +``` + +Edit `k8s/configmap.yaml` first (MongoDB URI, S3 settings) and create the +secrets from `k8s/secret.example.yaml`. The deployment runs as a non-root +user with a read-only capability set, drops all capabilities and ships +readiness/liveness probes against `/readyz` and `/healthz`. + +Exactly one replica must run: Telegram delivers updates to a single +long-polling consumer per bot token, and the deployment uses the `Recreate` +strategy so an upgrade never runs two consumers at once. + +### Argo CD + +`argocd-application.yaml` is an optional Application manifest. Point +`repoURL` at your fork and adjust the project/namespace. Secrets are +deliberately not managed by Argo CD; apply them manually. + +### Networking + +No ingress is required for the bot itself. The only requirement is that +`S3_PUBLIC_ENDPOINT_URL` (when set) is reachable from the public internet, +because Telegram downloads the presigned asset URLs directly. + +## Operations + +### Health endpoints + +- `GET /healthz` — process liveness, always `200` while the process runs. +- `GET /readyz` — `200` only when MongoDB answers and the Telegram polling + loop has succeeded recently (a 409 conflict from a second consumer shows + up here). + +### Statistics + +`/stats` (public) reports total renders split chat/inline, failed renders, +renders in the last 24 hours, users and active groups, aggregated from the +`stats`, `users` and `groups` collections. + +### Observability + +Set `OTEL_EXPORTER_OTLP_ENDPOINT` to export traces for the HTTP server, the +Telegram API client, the MongoDB driver and every render. Logs go to stdout +with `log.Printf`; failures of uploads, sweeps and Telegram calls are logged +with enough context to diagnose. + +### Asset lifecycle + +Inline assets are immutable, uniquely named and garbage collected by the +in-process sweeper: objects older than twice `S3_PRESIGN_TTL` (at least one +hour) are deleted every 15 minutes. Because names are unique, a later pick +of the same formula simply uploads a new object. + +### Scaling and resource sizing + +`pdflatex` is CPU-bound; each render also briefly uses a few hundred MB. +Tune `RENDER_MAX_CONCURRENCY` to the CPU available and keep the memory limit +comfortably above `concurrency × ~300MB`. The bot itself is lightweight; +MongoDB latency dominates the non-render part of a request, which is why +settings and file IDs are cached in memory. + +### Backups + +The only durable state is MongoDB (settings, users, groups, stats, file +IDs). Back up the `latexgram` database; the S3 bucket contents are +disposable. + +### Upgrades + +Deploy a new image and let the `Recreate` rollout finish. Renders in flight +are interrupted by design; the file-id cache is rebuilt automatically. + +## Security + +- Shell escape is disabled (`-no-shell-escape`) and file-reading primitives + (`\input`, `\write`, `\read`, `\catcode`, ...) are rejected, so + user-supplied LaTeX cannot read files or execute commands. +- Every compile runs with a hard timeout and a bounded number of concurrent + runs; expression and preamble lengths are capped. +- The container runs as a non-root user with all capabilities dropped. +- Secrets (Telegram token, S3 credentials) are read from the environment + and never logged. + +## Development + +```sh +go test ./... +go run ./cmd/latexgrambot +``` + +Unit tests run everywhere. Integration tests skip themselves unless the +environment is available: + +- rendering tests need `pdflatex` and `pdftoppm` on `PATH`; +- MongoDB tests need `MONGODB_URI`; +- the S3 test needs `LATEXGRAMBOT_S3_TEST_ENDPOINT`, + `LATEXGRAMBOT_S3_TEST_ACCESS_KEY` and `LATEXGRAMBOT_S3_TEST_SECRET_KEY`. + +The GitHub Actions workflow (`.github/workflows/ci.yml`) runs the checks and +builds the multiarch image. + +## License + +BSD 3-Clause — see [LICENSE](LICENSE). diff --git a/argocd-application.yaml b/argocd-application.yaml new file mode 100644 index 0000000..edeb076 --- /dev/null +++ b/argocd-application.yaml @@ -0,0 +1,41 @@ +# Optional: GitOps deployment with Argo CD. Point repoURL at your fork, +# adjust the project and the destination namespace. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: latexgrambot + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + + source: + repoURL: https://github.com/your-org/latexgrambot.git + targetRevision: main + path: k8s + directory: + recurse: false + # secret.example.yaml is intentionally excluded: the Telegram token + # and the S3 credentials are applied manually and must never reach + # the repository. + include: '{configmap.yaml,deployment.yaml,service.yaml}' + + destination: + server: https://kubernetes.default.svc + namespace: latexgrambot + + syncPolicy: + automated: + prune: true + selfHeal: true + allowEmpty: false + syncOptions: + - CreateNamespace=true + - ServerSideApply=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m diff --git a/cmd/latexgrambot/main.go b/cmd/latexgrambot/main.go new file mode 100644 index 0000000..0707488 --- /dev/null +++ b/cmd/latexgrambot/main.go @@ -0,0 +1,177 @@ +// latexgrambot renders LaTeX expressions into PNG, PDF and Telegram rich +// messages, entirely locally (TeX Live + poppler, no external services). +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "latexgrambot/internal/bot" + "latexgrambot/internal/config" + "latexgrambot/internal/files" + "latexgrambot/internal/render" + "latexgrambot/internal/store" + "latexgrambot/internal/telemetry" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +func main() { + // Used at image build time to generate the preamble that preloads the + // default packages into the "latexgrambot" TeX format. + if len(os.Args) > 1 && os.Args[1] == "-print-preamble" { + fmt.Print(render.DefaultPreamble()) + return + } + + cfg, err := config.Load() + if err != nil { + log.Fatalf("configuration error: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + shutdown, err := telemetry.Setup(ctx) + if err != nil { + log.Fatalf("telemetry: %v", err) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := shutdown(shutdownCtx); err != nil { + log.Printf("telemetry shutdown: %v", err) + } + }() + + mongoClient, err := mongo.Connect(ctx, + options.Client().ApplyURI(cfg.MongoURI).SetMonitor(otelmongo.NewMonitor()), + ) + if err != nil { + log.Fatalf("mongo connect: %v", err) + } + defer mongoClient.Disconnect(context.Background()) + storage := store.New(mongoClient, cfg.MongoDatabase) + pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second) + err = storage.Ping(pingCtx) + pingCancel() + if err != nil { + log.Fatalf("MongoDB not reachable: %v", err) + } + if err := storage.EnsureIndexes(ctx); err != nil { + log.Fatalf("ensure indexes: %v", err) + } + + renderer := render.New(cfg.PdflatexBin, cfg.PdftoppmBin, cfg.RenderTimeout, cfg.MaxConcurrency, "") + renderer.Format = cfg.LatexFormat + + var fileStore *files.Store + if cfg.S3Enabled() { + s3Store, storeErr := files.New(ctx, files.Config{ + Endpoint: cfg.S3Endpoint, + Region: cfg.S3Region, + Bucket: cfg.S3Bucket, + AccessKeyID: cfg.S3AccessKeyID, + SecretAccessKey: cfg.S3SecretAccessKey, + Prefix: cfg.S3Prefix, + PublicEndpoint: cfg.S3PublicEndpoint, + PresignTTL: cfg.S3PresignTTL, + }) + if storeErr != nil { + log.Fatalf("files store: %v", storeErr) + } + fileStore = s3Store + log.Printf("inline files use private bucket %s with %s presigned URLs", cfg.S3Bucket, cfg.S3PresignTTL) + + // Garbage collect inline assets once their presigned links have + // certainly expired: Telegram downloads them at pick time, and the + // signed URLs are dead after the presign TTL anyway. + assetAge := 2 * cfg.S3PresignTTL + if assetAge < time.Hour { + assetAge = time.Hour + } + go func() { + ticker := time.NewTicker(15 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sweepCtx, cancel := context.WithTimeout(ctx, time.Minute) + removed, sweepErr := fileStore.DeleteExpired(sweepCtx, assetAge) + cancel() + if sweepErr != nil { + log.Printf("asset sweep: %v", sweepErr) + } else if removed > 0 { + log.Printf("asset sweep: removed %d expired objects", removed) + } + } + } + }() + } else { + log.Println("S3 not configured: inline PNG/PDF results disabled") + } + + b, err := bot.New(cfg, renderer, storage, storage, storage, fileStore) + if err != nil { + log.Fatal(err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + mux.HandleFunc("/readyz", func(w http.ResponseWriter, request *http.Request) { + checkCtx, cancel := context.WithTimeout(request.Context(), 3*time.Second) + defer cancel() + if err := storage.Ping(checkCtx); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "degraded", "dependency": "mongodb"}) + return + } + if err := b.Healthy(); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "degraded", "error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + srv := &http.Server{ + Addr: cfg.HealthAddr, + Handler: otelhttp.NewHandler(mux, "latexgrambot-http"), + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("http server: %v", err) + } + }() + + log.Printf("latexgrambot started: dpi=%d timeout=%s concurrency=%d mongo=%s", + cfg.RenderDPI, cfg.RenderTimeout, cfg.MaxConcurrency, cfg.MongoDatabase) + runErr := b.Run(ctx) + + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelShutdown() + _ = srv.Shutdown(shutdownCtx) + if runErr != nil { + log.Fatal(runErr) + } + log.Println("latexgrambot stopped") +} + +func writeJSON(w http.ResponseWriter, status int, value interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8081447 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +# Minimal self-hosted stack: MongoDB plus the bot. +# +# TELEGRAM_BOT_TOKEN=123:abc docker compose up --build +# +# Inline picture/PDF results need an S3-compatible bucket; without one the +# bot still serves rich text and the chat-mode PNG/PDF commands. +services: + mongo: + image: mongo:7 + restart: unless-stopped + volumes: + - mongo-data:/data/db + + latexgrambot: + build: . + restart: unless-stopped + depends_on: + - mongo + ports: + - "8080:8080" + environment: + TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}" + MONGODB_URI: "mongodb://mongo:27017" + MONGODB_DATABASE: "latexgram" + # Optional inline assets (S3-compatible, public endpoint reachable by + # Telegram): + # S3_ENDPOINT_URL: "http://minio:9000" + # S3_REGION: "us-east-1" + # S3_BUCKET: "latexgrambot" + # S3_PREFIX: "content" + # S3_PUBLIC_ENDPOINT_URL: "https://s3.example.com" + # S3_PRESIGN_TTL: "1h" + # AWS_ACCESS_KEY_ID: "" + # AWS_SECRET_ACCESS_KEY: "" + +volumes: + mongo-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ca6a749 --- /dev/null +++ b/go.mod @@ -0,0 +1,62 @@ +module latexgrambot + +go 1.23.0 + +toolchain go1.23.4 + +require ( + github.com/aws/aws-sdk-go-v2 v1.30.3 + github.com/aws/aws-sdk-go-v2/config v1.27.27 + github.com/aws/aws-sdk-go-v2/credentials v1.17.27 + github.com/aws/aws-sdk-go-v2/service/s3 v1.58.3 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/google/uuid v1.6.0 + go.mongodb.org/mongo-driver v1.16.0 + go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo v0.53.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 + go.opentelemetry.io/otel v1.28.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 + go.opentelemetry.io/otel/sdk v1.28.0 + go.opentelemetry.io/otel/trace v1.28.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect + github.com/aws/smithy-go v1.20.3 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect + google.golang.org/grpc v1.64.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a656bcd --- /dev/null +++ b/go.sum @@ -0,0 +1,139 @@ +github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= +github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= +github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90= +github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI= +github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 h1:Z5r7SycxmSllHYmaAZPpmN8GviDrSGhMS6bldqtXZPw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15/go.mod h1:CetW7bDE00QoGEmPUoZuRog07SGVAUVW6LFpNP0YfIg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 h1:YPYe6ZmvUfDDDELqEKtAd6bo8zxhkm+XEFEzQisqUIE= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17/go.mod h1:oBtcnYua/CgzCWYN7NZ5j7PotFDaFSUjCYVTtfyn7vw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 h1:246A4lSTXWJw/rmlQI+TT2OcqeDMKBdyjEQrafMaQdA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15/go.mod h1:haVfg3761/WF7YPuJOER2MP0k4UAXyHaLclKXB6usDg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.58.3 h1:hT8ZAZRIfqBqHbzKTII+CIiY8G2oC9OpLedkZ51DWl8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.58.3/go.mod h1:Lcxzg5rojyVPU/0eFwLtcyTaek/6Mtic5B1gJo7e/zE= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= +github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= +github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.16.0 h1:tpRsfBJMROVHKpdGyc1BBEzzjDUWjItxbVSZ8Ls4BQ4= +go.mongodb.org/mongo-driver v1.16.0/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= +go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo v0.53.0 h1:/g+er1+hOsTE7iGcq5dnjfbYEiIbbRABm1rTvp5EsE0= +go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo v0.53.0/go.mod h1:RHcOHuTeWbvM5a/FElwi/kavuik1RFoSRKcSnIybFlE= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= +go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= +go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 h1:0+ozOGcrp+Y8Aq8TLNN2Aliibms5LEzsq99ZZmAGYm0= +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094/go.mod h1:fJ/e3If/Q67Mj99hin0hMhiNyCRmt6BQ2aWIJshUSJw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..29a3695 --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,882 @@ +// Package bot implements the Telegram-facing behavior: messages render LaTeX +// into a PNG picture, a PDF document or a Telegram rich message, inline +// queries offer the same three formats, and commands manage per-user +// settings. +package bot + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "latexgrambot/internal/config" + "latexgrambot/internal/render" + "latexgrambot/internal/rich" + "latexgrambot/internal/store" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +const ( + pollTimeout = 30 // seconds, Telegram long-poll + pollStale = 3 * time.Minute + minDPI = 72 + maxDPI = 1200 + displayLimit = 3800 // long texts are truncated before being echoed back + + // Included in content hashes so renderer/template changes never reuse + // stale Telegram file_ids or S3 objects from an older deployment. + renderCacheVersion = "presigned-v1" +) + +// outputFormat is one of the three generation formats. +type outputFormat int + +const ( + formatImage outputFormat = iota + formatPDF + formatRich +) + +// chatAction returns the Telegram chat action shown while the format is +// being produced. +func (f outputFormat) chatAction() string { + switch f { + case formatPDF: + return tgbotapi.ChatUploadDocument + case formatRich: + return tgbotapi.ChatTyping + default: + return tgbotapi.ChatUploadPhoto + } +} + +// renderer renders LaTeX into PNG and PDF bytes. +type renderer interface { + RenderFormats(ctx context.Context, preamble, expression string, dpi int, formats render.Formats) (*render.Result, error) +} + +// settingsProvider holds per-chat settings. +type settingsProvider interface { + Get(chatID int64) store.Settings + Set(chatID int64, settings store.Settings) error +} + +// recorder persists users, group memberships and render statistics, and +// reports the aggregates behind /stats. +type recorder interface { + RecordUser(user store.User) + RecordGroup(chat store.Chat, member bool) + RecordRender(stat store.RenderStat) + Stats() store.Stats +} + +// fileCache stores correctly typed Telegram file_ids captured from ordinary +// chat sends. Inline answers can reuse those IDs without rendering or +// uploading anything. +type fileCache interface { + FileRefs(hash string) store.FileRefs + SavePhotoID(hash, fileID string) + SaveDocID(hash, fileID string) +} + +// FileStore stores rendered outputs and returns presigned URLs that Telegram +// can fetch without the bot sending a temporary message to any chat. +type FileStore interface { + Put(ctx context.Context, ext, contentType string, data []byte) (string, error) +} + +// Bot bridges Telegram and the local LaTeX renderer. +type Bot struct { + api *tgbotapi.BotAPI + cfg *config.Config + ren renderer + user settingsProvider + rec recorder + cache fileCache + files FileStore + tracer trace.Tracer + + lastPoll atomic.Int64 // unix time of the last successful getUpdates + + settingsMu sync.Mutex + settingsCache map[int64]store.Settings + refsMu sync.Mutex + refsCache map[string]refsEntry + urlsMu sync.Mutex + urlsCache map[string]urlEntry +} + +const refsCacheTTL = 15 * time.Minute + +type refsEntry struct { + refs store.FileRefs + at time.Time +} + +// urlEntry caches the presigned URLs of a recent render so repeated +// identical inline queries skip rendering and uploading again. +type urlEntry struct { + photoURL string + pdfURL string + expires time.Time +} + +// New authenticates the bot and instruments its HTTP client. +func New(cfg *config.Config, renderer renderer, users settingsProvider, rec recorder, cache fileCache, files FileStore) (*Bot, error) { + api, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken) + if err != nil { + return nil, fmt.Errorf("telegram bot init: %w", err) + } + api.Client = &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} + log.Printf("Authorised as @%s", api.Self.UserName) + return &Bot{ + api: api, + cfg: cfg, + ren: renderer, + user: users, + rec: rec, + cache: cache, + files: files, + tracer: otel.Tracer("latexgrambot/bot"), + settingsCache: map[int64]store.Settings{}, + refsCache: map[string]refsEntry{}, + urlsCache: map[string]urlEntry{}, + }, nil +} + +// Healthy reports whether Telegram polling has been failing for a while +// (e.g. a 409 conflict because another process polls the same token). +func (b *Bot) Healthy() error { + last := b.lastPoll.Load() + if last == 0 { + return nil // not started yet + } + if age := time.Since(time.Unix(last, 0)); age > pollStale { + return fmt.Errorf("no successful Telegram poll for %s", age.Round(time.Second)) + } + return nil +} + +// Run polls Telegram until ctx is cancelled. Updates are processed by a small +// worker pool so slow chat renders do not stall inline queries. +func (b *Bot) Run(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + workers := b.cfg.MaxConcurrency + 2 + updatesCh := make(chan tgbotapi.Update, workers*2) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for upd := range updatesCh { + b.handleUpdate(upd) + } + }() + } + + u := tgbotapi.NewUpdate(0) + u.Timeout = pollTimeout + u.AllowedUpdates = []string{"message", "inline_query", "my_chat_member"} + b.lastPoll.Store(time.Now().Unix()) + log.Println("Listening for Telegram updates") + for ctx.Err() == nil { + updates, err := b.api.GetUpdates(u) + if err != nil { + if ctx.Err() != nil { + break + } + log.Printf("telegram getUpdates failed: %v", err) + select { + case <-ctx.Done(): + case <-time.After(time.Second): + } + continue + } + // A successful response (even an empty one) proves the polling + // loop is healthy; long-poll timeouts with no updates are normal. + b.lastPoll.Store(time.Now().Unix()) + if len(updates) > 0 { + u.Offset = updates[len(updates)-1].UpdateID + 1 + for _, upd := range updates { + updatesCh <- upd + } + } + } + close(updatesCh) + wg.Wait() + return nil +} + +func (b *Bot) handleUpdate(upd tgbotapi.Update) { + switch { + case upd.Message != nil: + b.handleMessage(upd.Message) + case upd.InlineQuery != nil: + b.handleInlineQuery(upd.InlineQuery) + case upd.MyChatMember != nil: + b.handleMyChatMember(upd.MyChatMember) + } +} + +// handleMyChatMember records groups the bot is added to or removed from. +func (b *Bot) handleMyChatMember(update *tgbotapi.ChatMemberUpdated) { + status := update.NewChatMember.Status + member := status == "member" || status == "administrator" || status == "creator" || status == "restricted" + go b.rec.RecordGroup(store.Chat{ + ID: update.Chat.ID, + Type: update.Chat.Type, + Title: update.Chat.Title, + Username: update.Chat.UserName, + }, member) +} + +func (b *Bot) handleMessage(msg *tgbotapi.Message) { + ctx, span := b.tracer.Start(context.Background(), "handle_message", + trace.WithAttributes(attribute.Int64("chat.id", msg.Chat.ID))) + defer span.End() + + if msg.From != nil { + go b.rec.RecordUser(store.User{ + ID: msg.From.ID, + Username: msg.From.UserName, + FirstName: msg.From.FirstName, + LastName: msg.From.LastName, + }) + } + var userID int64 + if msg.From != nil { + userID = msg.From.ID + } + if msg.IsCommand() { + b.handleCommand(msg, userID) + return + } + if strings.TrimSpace(msg.Text) == "" { + return + } + b.renderExpression(ctx, msg.Chat.ID, userID, msg.Text, formatRich) +} + +// handleCommand routes the known commands. Unknown commands (e.g. \frac) are +// rendered as ordinary LaTeX and answered with the default rich text format. +func (b *Bot) handleCommand(msg *tgbotapi.Message, userID int64) { + switch msg.Command() { + case "start": + b.sendText(msg.Chat.ID, welcomeText(b.api.Self.UserName)) + case "help": + b.sendText(msg.Chat.ID, helpText(b.api.Self.UserName)) + case "preamble": + b.showPreamble(msg) + case "setpreamble": + b.setPreamble(msg) + case "resetpreamble": + b.resetPreamble(msg) + case "setdpi": + b.setDPI(msg) + case "settings": + b.showSettings(msg) + case "stats": + b.sendText(msg.Chat.ID, statsText(b.rec.Stats())) + case "pdf", "image", "rich": + b.renderCommand(msg, userID) + default: + b.renderExpression(context.Background(), msg.Chat.ID, userID, msg.Text, formatRich) + } +} + +// renderCommand renders the arguments of a /pdf, /image or /rich command and +// replies with exactly that format. +func (b *Bot) renderCommand(msg *tgbotapi.Message, userID int64) { + format := formatImage + switch msg.Command() { + case "pdf": + format = formatPDF + case "rich": + format = formatRich + } + expression := strings.TrimSpace(msg.CommandArguments()) + if expression == "" { + b.sendText(msg.Chat.ID, fmt.Sprintf("Usage: /%s ", msg.Command())) + return + } + b.renderExpression(context.Background(), msg.Chat.ID, userID, expression, format) +} + +// renderExpression renders the expression and sends only the requested +// format back to the chat, recording the outcome in the statistics. +func (b *Bot) renderExpression(ctx context.Context, chatID, userID int64, expression string, format outputFormat) { + started := time.Now() + if len(expression) > b.cfg.MaxExpressionLen { + b.sendText(chatID, fmt.Sprintf("Expression is too long (max %d characters).", b.cfg.MaxExpressionLen)) + return + } + if format == formatRich { + b.sendRichText(chatID, expression) + go b.rec.RecordRender(store.RenderStat{ + Source: "chat", Format: "rich", ChatID: chatID, UserID: userID, + ExpressionLen: len(expression), OK: true, DurationMs: time.Since(started).Milliseconds(), + }) + return + } + if _, err := b.api.Send(tgbotapi.NewChatAction(chatID, format.chatAction())); err != nil { + log.Printf("send chat action to %d: %v", chatID, err) + } + + settings := b.getSettings(chatID) + dpi := b.dpiFor(settings) + hash := contentHash(settings.Preamble, expression, dpi) + formats := render.FormatPNG + if format == formatPDF { + formats = render.FormatPDF + } + renderCtx, cancel := context.WithTimeout(ctx, b.cfg.RenderTimeout) + defer cancel() + result, err := b.ren.RenderFormats(renderCtx, settings.Preamble, expression, dpi, formats) + + formatName := "png" + if format == formatPDF { + formatName = "pdf" + } + stat := store.RenderStat{ + Source: "chat", Format: formatName, ChatID: chatID, UserID: userID, + DPI: dpi, ExpressionLen: len(expression), + OK: err == nil, DurationMs: time.Since(started).Milliseconds(), + } + if err != nil { + stat.Error = err.Error() + b.sendRenderError(chatID, err) + go b.rec.RecordRender(stat) + return + } + go b.rec.RecordRender(stat) + + switch format { + case formatPDF: + if fileID := b.sendDocument(chatID, result.PDF); fileID != "" && b.cache != nil { + b.saveDocID(hash, fileID) + } + default: + if fileID := b.sendPhoto(chatID, result.PNG); fileID != "" && b.cache != nil { + b.savePhotoID(hash, fileID) + } + } +} + +// sendRichText replies with the rich-message rendering of the expression: a +// single native math block, no source echo. Failures are non-fatal. The +// pinned library predates the rich message API, so the request is built as +// raw params. +func (b *Bot) sendRichText(chatID int64, expression string) { + if !b.cfg.RichTextEnabled { + return + } + if _, err := b.api.Send(tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping)); err != nil { + log.Printf("send chat action to %d: %v", chatID, err) + } + params := tgbotapi.Params{} + params.AddNonZero64("chat_id", chatID) + if err := params.AddInterface("rich_message", rich.Document(expression, b.cfg.RichDefaultMath)); err != nil { + log.Printf("encode rich message: %v", err) + return + } + if _, err := b.api.MakeRequest("sendRichMessage", params); err != nil { + log.Printf("sendRichMessage to %d: %v", chatID, err) + } +} + +func (b *Bot) handleInlineQuery(query *tgbotapi.InlineQuery) { + ctx, span := b.tracer.Start(context.Background(), "answer_inline_query", + trace.WithAttributes(attribute.String("chat.type", query.ChatType))) + defer span.End() + + go b.rec.RecordUser(store.User{ + ID: query.From.ID, + Username: query.From.UserName, + FirstName: query.From.FirstName, + LastName: query.From.LastName, + }) + + answer := tgbotapi.InlineConfig{ + InlineQueryID: query.ID, + Results: b.inlineResults(ctx, query), + IsPersonal: true, + } + if _, err := b.api.Request(answer); err != nil { + log.Printf("answer inline query %s: %v", query.ID, err) + } +} + +// getSettings serves settings from the write-through cache; every write +// goes through saveSettings, so the cache stays consistent. +func (b *Bot) getSettings(chatID int64) store.Settings { + b.settingsMu.Lock() + settings, ok := b.settingsCache[chatID] + b.settingsMu.Unlock() + if ok { + return settings + } + settings = b.user.Get(chatID) + b.settingsMu.Lock() + if b.settingsCache == nil { + b.settingsCache = map[int64]store.Settings{} + } + b.settingsCache[chatID] = settings + b.settingsMu.Unlock() + return settings +} + +func (b *Bot) saveSettings(chatID int64, settings store.Settings) error { + if err := b.user.Set(chatID, settings); err != nil { + return err + } + b.settingsMu.Lock() + if b.settingsCache == nil { + b.settingsCache = map[int64]store.Settings{} + } + b.settingsCache[chatID] = settings + b.settingsMu.Unlock() + return nil +} + +// fileRefs serves Telegram file_ids from a short-lived cache to keep the +// inline hot path free of database round-trips. +func (b *Bot) fileRefs(hash string) store.FileRefs { + b.refsMu.Lock() + entry, ok := b.refsCache[hash] + b.refsMu.Unlock() + if ok && time.Since(entry.at) < refsCacheTTL { + return entry.refs + } + refs := store.FileRefs{} + if b.cache != nil { + refs = b.cache.FileRefs(hash) + } + b.refsMu.Lock() + if b.refsCache == nil { + b.refsCache = map[string]refsEntry{} + } + b.refsCache[hash] = refsEntry{refs: refs, at: time.Now()} + b.refsMu.Unlock() + return refs +} + +func (b *Bot) savePhotoID(hash, fileID string) { + if b.cache == nil { + return + } + b.savePhotoID(hash, fileID) + b.refsMu.Lock() + entry := b.refsCache[hash] + entry.refs.PhotoID = fileID + entry.at = time.Now() + b.refsCache[hash] = entry + b.refsMu.Unlock() +} + +func (b *Bot) saveDocID(hash, fileID string) { + if b.cache == nil { + return + } + b.saveDocID(hash, fileID) + b.refsMu.Lock() + entry := b.refsCache[hash] + entry.refs.DocID = fileID + entry.at = time.Now() + b.refsCache[hash] = entry + b.refsMu.Unlock() +} + +// cachedURLs returns the presigned URLs of a recent render of the same +// content, so repeated identical queries skip render and upload entirely. +func (b *Bot) cachedURLs(hash string) (photoURL, pdfURL string, ok bool) { + b.urlsMu.Lock() + defer b.urlsMu.Unlock() + entry, found := b.urlsCache[hash] + if !found || time.Now().After(entry.expires) { + return "", "", false + } + return entry.photoURL, entry.pdfURL, true +} + +func (b *Bot) cacheURLs(hash, photoURL, pdfURL string) { + ttl := b.cfg.S3PresignTTL - 10*time.Minute + if ttl < 5*time.Minute { + ttl = 5 * time.Minute + } + b.urlsMu.Lock() + if b.urlsCache == nil { + b.urlsCache = map[string]urlEntry{} + } + b.urlsCache[hash] = urlEntry{photoURL: photoURL, pdfURL: pdfURL, expires: time.Now().Add(ttl)} + b.urlsMu.Unlock() +} + +// inlineResults stores rendered files in private object storage and returns +// URL-based results with short-lived signatures. Telegram fetches the selected +// file directly; generating an inline answer never sends or deletes a message +// in the user's chat. +func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) []interface{} { + raw := strings.TrimSpace(query.Query) + if raw == "" { + const example = `\int_0^\infty e^{-x^2}\,dx` + return []interface{}{b.article( + "empty", "Type a LaTeX expression", example, + "e.g. "+example, + )} + } + if len(raw) > b.cfg.MaxExpressionLen { + return []interface{}{b.article( + "long", "Expression too long", + fmt.Sprintf("The expression is limited to %d characters.", b.cfg.MaxExpressionLen), + fmt.Sprintf("The expression is limited to %d characters.", b.cfg.MaxExpressionLen), + )} + } + expr := raw + + settings := b.getSettings(query.From.ID) + dpi := b.dpiFor(settings) + hash := contentHash(settings.Preamble, expr, dpi) + id := hash[:40] + + refs := b.fileRefs(hash) + needPhoto := refs.PhotoID == "" + needDoc := refs.DocID == "" + var photoURL, pdfURL string + if needPhoto || needDoc { + if cachedPhoto, cachedPDF, ok := b.cachedURLs(hash); ok { + photoURL, pdfURL = cachedPhoto, cachedPDF + needPhoto, needDoc = false, false + } + } + if b.files != nil && (needPhoto || needDoc) { + renderCtx, cancel := context.WithTimeout(ctx, b.cfg.RenderTimeout) + defer cancel() + started := time.Now() + result, err := b.ren.RenderFormats(renderCtx, settings.Preamble, expr, dpi, render.FormatJPEG|render.FormatPDF) + stat := store.RenderStat{ + Source: "inline", Format: "inline", UserID: query.From.ID, + DPI: dpi, ExpressionLen: len(expr), OK: err == nil, + DurationMs: time.Since(started).Milliseconds(), + } + if err != nil { + stat.Error = err.Error() + go b.rec.RecordRender(stat) + return []interface{}{b.errorArticle(err)} + } + go b.rec.RecordRender(stat) + + // Upload both formats concurrently: each Put is an object store + // round-trip. + var wg sync.WaitGroup + var photoPutURL, pdfPutURL string + if needPhoto { + wg.Add(1) + go func() { + defer wg.Done() + storedURL, putErr := b.files.Put(ctx, "jpg", "image/jpeg", result.JPEG) + if putErr != nil { + log.Printf("store inline picture: %v", putErr) + return + } + photoPutURL = storedURL + }() + } + if needDoc { + wg.Add(1) + go func() { + defer wg.Done() + storedURL, putErr := b.files.Put(ctx, "pdf", "application/pdf", result.PDF) + if putErr != nil { + log.Printf("store inline document: %v", putErr) + return + } + pdfPutURL = storedURL + }() + } + wg.Wait() + if photoPutURL != "" { + photoURL = photoPutURL + } + if pdfPutURL != "" { + pdfURL = pdfPutURL + } + if photoPutURL != "" || pdfPutURL != "" { + b.cacheURLs(hash, photoURL, pdfURL) + } + } + + results := []interface{}{} + if refs.PhotoID != "" { + photo := tgbotapi.NewInlineQueryResultCachedPhoto("photo-"+id, refs.PhotoID) + photo.Title = "PNG" + results = append(results, photo) + } else if photoURL != "" { + photo := tgbotapi.NewInlineQueryResultPhoto("photo-"+id, photoURL) + photo.ThumbURL = photoURL + photo.Title = "PNG" + results = append(results, photo) + } + if refs.DocID != "" { + document := tgbotapi.NewInlineQueryResultCachedDocument("doc-"+id, refs.DocID, "PDF") + document.Title = "PDF" + results = append(results, document) + } else if pdfURL != "" { + document := tgbotapi.NewInlineQueryResultDocument("doc-"+id, pdfURL, "formula.pdf", "application/pdf") + document.Title = "PDF" + results = append(results, document) + } + if b.cfg.RichTextEnabled { + results = append(results, b.richTextResult("formula-rich-"+id, raw)) + } + if len(results) == 0 { + return []interface{}{b.article( + "nofiles", "Rendering unavailable", + "No output format is available for inline answers.", + "No output format is available for inline answers.", + )} + } + return results +} + +// The formula renders inline with a one-space gap from the bot mention. +func (b *Bot) richTextResult(id, expression string) interface{} { + result := tgbotapi.NewInlineQueryResultArticle(id, "Rich text", "") + result.Description = truncate(expression, 64) + result.InputMessageContent = inputRichMessageContent{RichMessage: rich.InlineDocument(expression, b.cfg.RichDefaultMath)} + return result +} + +// inputRichMessageContent implements the Bot API 10.x InputRichMessageContent +// used as inline input_message_content. +type inputRichMessageContent struct { + RichMessage *rich.Message `json:"rich_message"` +} + +// sendPhoto sends a rendered picture to the chat and returns its reusable +// Telegram file_id. +func (b *Bot) sendPhoto(chatID int64, png []byte) string { + photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "formula.png", Bytes: png}) + msg, err := b.api.Send(photo) + if err != nil { + log.Printf("send png to %d: %v", chatID, err) + return "" + } + var best tgbotapi.PhotoSize + for _, size := range msg.Photo { + if size.FileSize > best.FileSize { + best = size + } + } + return best.FileID +} + +// sendDocument sends the rendered PDF to the chat and returns its reusable +// Telegram file_id. +func (b *Bot) sendDocument(chatID int64, pdf []byte) string { + document := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{Name: "formula.pdf", Bytes: pdf}) + msg, err := b.api.Send(document) + if err != nil { + log.Printf("send pdf to %d: %v", chatID, err) + return "" + } + if msg.Document == nil { + return "" + } + return msg.Document.FileID +} + +// article builds a plain article result; tapping it sends text back to the +// chat. description, when non-empty, is shown under the title in the query +// menu. +func (b *Bot) article(id, title, text, description string) interface{} { + result := tgbotapi.NewInlineQueryResultArticle(id, title, text) + if description != "" { + result.Description = truncate(description, 200) + } + return result +} + +func (b *Bot) errorArticle(err error) interface{} { + var latexErr *render.LatexError + switch { + case errors.As(err, &latexErr): + return b.article("latex-error", "LaTeX error", latexErr.Excerpt, latexErr.Excerpt) + case errors.Is(err, context.DeadlineExceeded): + return b.article("timeout", "Rendering timed out", + "Rendering took too long. Simplify the expression or check the preamble.", + "Rendering took too long. Simplify the expression or check the preamble.") + default: + return b.article("failed", "Rendering failed", "Something went wrong while rendering.", + "Something went wrong while rendering.") + } +} + +func (b *Bot) sendRenderError(chatID int64, err error) { + var latexErr *render.LatexError + switch { + case errors.As(err, &latexErr): + b.sendText(chatID, "LaTeX error:\n\n"+latexErr.Excerpt) + case errors.Is(err, context.DeadlineExceeded): + b.sendText(chatID, "Rendering timed out. Simplify the expression or check the preamble.") + default: + b.sendText(chatID, "Rendering failed: "+err.Error()) + } +} + +func (b *Bot) sendText(chatID int64, text string) { + msg := tgbotapi.NewMessage(chatID, text) + if _, err := b.api.Send(msg); err != nil { + log.Printf("reply to %d: %v", chatID, err) + } +} + +func (b *Bot) showPreamble(msg *tgbotapi.Message) { + settings := b.getSettings(msg.Chat.ID) + if settings.Preamble == "" { + b.sendText(msg.Chat.ID, "You are using the default preamble. Set your own with /setpreamble.") + return + } + b.sendText(msg.Chat.ID, "Your preamble additions:\n\n"+truncate(settings.Preamble, displayLimit)) +} + +func (b *Bot) setPreamble(msg *tgbotapi.Message) { + code := strings.TrimSpace(msg.CommandArguments()) + if code == "" { + b.sendText(msg.Chat.ID, "Usage: /setpreamble \n"+ + "Your additions are loaded after the default package set.") + return + } + if len(code) > b.cfg.MaxPreambleLen { + b.sendText(msg.Chat.ID, fmt.Sprintf("Preamble is too long (max %d characters).", b.cfg.MaxPreambleLen)) + return + } + settings := b.getSettings(msg.Chat.ID) + settings.Preamble = code + if err := b.saveSettings(msg.Chat.ID, settings); err != nil { + log.Printf("store preamble for %d: %v", msg.Chat.ID, err) + b.sendText(msg.Chat.ID, "Could not save the preamble, sorry.") + return + } + b.sendText(msg.Chat.ID, "Preamble additions updated. Use /resetpreamble to remove them.") +} + +func (b *Bot) resetPreamble(msg *tgbotapi.Message) { + settings := b.getSettings(msg.Chat.ID) + settings.Preamble = "" + if err := b.saveSettings(msg.Chat.ID, settings); err != nil { + log.Printf("reset preamble for %d: %v", msg.Chat.ID, err) + b.sendText(msg.Chat.ID, "Could not reset the preamble, sorry.") + return + } + b.sendText(msg.Chat.ID, "Preamble reset to the default.") +} + +func (b *Bot) setDPI(msg *tgbotapi.Message) { + value, err := strconv.Atoi(strings.TrimSpace(msg.CommandArguments())) + if err != nil || value < minDPI || value > maxDPI { + b.sendText(msg.Chat.ID, fmt.Sprintf("Usage: /setdpi <%d-%d> (default %d).", minDPI, maxDPI, b.cfg.RenderDPI)) + return + } + settings := b.getSettings(msg.Chat.ID) + settings.DPI = value + if err := b.saveSettings(msg.Chat.ID, settings); err != nil { + log.Printf("store dpi for %d: %v", msg.Chat.ID, err) + b.sendText(msg.Chat.ID, "Could not save the resolution, sorry.") + return + } + b.sendText(msg.Chat.ID, fmt.Sprintf("Resolution set to %d DPI. Use /setdpi %d to restore the default.", value, b.cfg.RenderDPI)) +} + +func (b *Bot) showSettings(msg *tgbotapi.Message) { + settings := b.getSettings(msg.Chat.ID) + dpi := b.dpiFor(settings) + var text strings.Builder + fmt.Fprintf(&text, "Resolution: %d DPI\n", dpi) + if settings.Preamble == "" { + text.WriteString("Preamble: default") + } else { + text.WriteString("Preamble additions:\n\n") + text.WriteString(truncate(settings.Preamble, displayLimit)) + } + b.sendText(msg.Chat.ID, text.String()) +} + +func (b *Bot) dpiFor(settings store.Settings) int { + if settings.DPI > 0 { + return settings.DPI + } + return b.cfg.RenderDPI +} + +func truncate(text string, limit int) string { + if len(text) <= limit { + return text + } + return text[:limit] + "..." +} + +// contentHash identifies the rendered output of an expression with a given +// preamble and DPI. It also names the stored files. +func contentHash(preamble, expression string, dpi int) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%d", renderCacheVersion, preamble, expression, dpi))) + return hex.EncodeToString(sum[:]) +} + +// statsText renders the public /stats summary. +func statsText(stats store.Stats) string { + return fmt.Sprintf( + "latexgrambot stats\n\n"+ + "Renders: %d (chat %d, inline %d)\n"+ + "Failed renders: %d\n"+ + "Renders in the last 24h: %d\n"+ + "Users: %d\n"+ + "Groups: %d", + stats.Renders, stats.ChatRenders, stats.InlineRenders, + stats.FailedRenders, stats.Renders24h, stats.Users, stats.Groups) +} + +func welcomeText(username string) string { + return fmt.Sprintf( + "Hi! I render LaTeX into rich text messages.\n\n"+ + "Send me any LaTeX expression in this chat and I will reply with a rich text message. "+ + "Use /image or /pdf to get files instead. "+ + "You can also use me inline: in any chat, type @%s followed by an expression.\n\n"+ + "See /help for the available commands.", username) +} + +func helpText(username string) string { + return fmt.Sprintf( + "Usage\n"+ + "- Chat mode: send a message containing LaTeX code (e.g. \\int_0^\\infty e^{-x^2}\\,dx) "+ + "and I reply with a rich text message.\n"+ + "- /rich — reply with the rich text message (default)\n"+ + "- /image — reply with the PNG picture\n"+ + "- /pdf — reply with the PDF document\n"+ + "- Inline mode: in any chat, type @%s and pick the rich text, picture or PDF result.\n"+ + "- The document class is fixed to the standalone preview layout; "+ + "your code goes between \\begin{document} and \\end{document}.\n\n"+ + "Commands\n"+ + "/preamble — show your current preamble\n"+ + "/setpreamble — replace the packages block with your own preamble\n"+ + "/resetpreamble — restore the default preamble\n"+ + "/setdpi <72-1200> — set the picture resolution (default 600)\n"+ + "/settings — show your current settings\n"+ + "/stats — show bot usage statistics\n"+ + "/help — this message", username) +} diff --git a/internal/bot/bot_test.go b/internal/bot/bot_test.go new file mode 100644 index 0000000..15792a2 --- /dev/null +++ b/internal/bot/bot_test.go @@ -0,0 +1,269 @@ +package bot + +import ( + "context" + "strings" + "sync" + "testing" + + "latexgrambot/internal/config" + "latexgrambot/internal/render" + "latexgrambot/internal/rich" + "latexgrambot/internal/store" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/google/uuid" + "go.opentelemetry.io/otel" +) + +type stubRenderer struct { + results *render.Result + err error +} + +func (s *stubRenderer) RenderFormats(_ context.Context, _, _ string, _ int, _ render.Formats) (*render.Result, error) { + return s.results, s.err +} + +type fakeSettings struct { + settings store.Settings +} + +func (f *fakeSettings) Get(int64) store.Settings { return f.settings } +func (f *fakeSettings) Set(int64, store.Settings) error { + return nil +} + +type fakeRecorder struct{} + +func (f *fakeRecorder) RecordUser(store.User) {} +func (f *fakeRecorder) RecordGroup(store.Chat, bool) {} +func (f *fakeRecorder) RecordRender(store.RenderStat) {} + +func (f *fakeRecorder) Stats() store.Stats { + return store.Stats{ + Renders: 42, ChatRenders: 30, InlineRenders: 12, + FailedRenders: 3, Renders24h: 7, Users: 9, Groups: 2, + } +} + +type fakeCache struct { + refs store.FileRefs +} + +func (f *fakeCache) FileRefs(string) store.FileRefs { return f.refs } +func (f *fakeCache) SavePhotoID(_, fileID string) { f.refs.PhotoID = fileID } +func (f *fakeCache) SaveDocID(_, fileID string) { f.refs.DocID = fileID } + +type fakeFiles struct { + mu sync.Mutex + puts map[string][]byte + putCalls int +} + +func (f *fakeFiles) Put(_ context.Context, ext, _ string, data []byte) (string, error) { + f.mu.Lock() + if f.puts == nil { + f.puts = map[string][]byte{} + } + f.puts[ext] = data + f.putCalls++ + f.mu.Unlock() + return "https://s3.example/latexgrambot/content/" + uuid.NewString() + "." + ext + "?X-Amz-Signature=test", nil +} + +func testBot() *Bot { + return &Bot{ + api: &tgbotapi.BotAPI{Self: tgbotapi.User{UserName: "latexgrambot"}}, + cfg: &config.Config{MaxExpressionLen: 4000, RichTextEnabled: true, RichDefaultMath: true, RenderDPI: 600}, + ren: &stubRenderer{results: &render.Result{PNG: []byte("png"), JPEG: []byte("jpg"), PDF: []byte("pdf")}}, + user: &fakeSettings{}, + rec: &fakeRecorder{}, + files: &fakeFiles{}, + tracer: otel.GetTracerProvider().Tracer("test"), + settingsCache: map[int64]store.Settings{}, + refsCache: map[string]refsEntry{}, + urlsCache: map[string]urlEntry{}, + } +} + +func article(t *testing.T, result interface{}) tgbotapi.InlineQueryResultArticle { + t.Helper() + a, ok := result.(tgbotapi.InlineQueryResultArticle) + if !ok { + t.Fatalf("result is %T, want InlineQueryResultArticle", result) + } + return a +} + +func TestInlineResultsUsesPresignedFiles(t *testing.T) { + b := testBot() + results := b.inlineResults(context.Background(), &tgbotapi.InlineQuery{Query: "$F=ma$", ChatType: "supergroup", From: &tgbotapi.User{ID: 1}}) + if len(results) != 3 { + t.Fatalf("got %d results, want 3", len(results)) + } + + photo, ok := results[0].(tgbotapi.InlineQueryResultPhoto) + if !ok { + t.Fatalf("result 0 is %T, want InlineQueryResultPhoto", results[0]) + } + if !strings.Contains(photo.URL, ".jpg?") || photo.ThumbURL != photo.URL { + t.Errorf("photo URL/thumb = %q/%q", photo.URL, photo.ThumbURL) + } + if photo.Title != "PNG" { + t.Errorf("photo title = %q", photo.Title) + } + + document, ok := results[1].(tgbotapi.InlineQueryResultDocument) + if !ok { + t.Fatalf("result 1 is %T, want InlineQueryResultDocument", results[1]) + } + if !strings.Contains(document.URL, ".pdf?") || document.MimeType != "application/pdf" { + t.Errorf("document URL/type = %q/%q", document.URL, document.MimeType) + } + if document.Title != "PDF" { + t.Errorf("document title = %q", document.Title) + } + + richResult := article(t, results[2]) + if richResult.Title != "Rich text" { + t.Errorf("rich title = %q", richResult.Title) + } + if richResult.ReplyMarkup != nil { + t.Error("rich result must not carry a button") + } + content, ok := richResult.InputMessageContent.(inputRichMessageContent) + if !ok || content.RichMessage == nil || len(content.RichMessage.Blocks) != 1 { + t.Fatalf("unexpected rich content: %+v", richResult.InputMessageContent) + } + parts, ok := content.RichMessage.Blocks[0].Text.([]any) + if !ok || len(parts) != 2 { + t.Fatalf("unexpected paragraph content: %+v", content.RichMessage.Blocks[0].Text) + } + math, ok := parts[1].(rich.Inline) + if !ok || math.Type != "mathematical_expression" || math.Expression != "F=ma" { + t.Fatalf("unexpected inline math node: %+v", parts[1]) + } + files := b.files.(*fakeFiles) + if string(files.puts["jpg"]) != "jpg" || string(files.puts["pdf"]) != "pdf" { + t.Fatalf("stored files = %#v", files.puts) + } +} + +func TestInlineResultsCachesPresignedURLs(t *testing.T) { + b := testBot() + query := &tgbotapi.InlineQuery{Query: "$F=ma$", ChatType: "private", From: &tgbotapi.User{ID: 1}} + first := b.inlineResults(context.Background(), query) + second := b.inlineResults(context.Background(), query) + files := b.files.(*fakeFiles) + if files.putCalls != 2 { + t.Fatalf("put calls = %d, want 2 (one per format, cached afterwards)", files.putCalls) + } + firstPhoto := first[0].(tgbotapi.InlineQueryResultPhoto) + secondPhoto := second[0].(tgbotapi.InlineQueryResultPhoto) + if firstPhoto.URL != secondPhoto.URL { + t.Fatalf("second query re-rendered: %q vs %q", firstPhoto.URL, secondPhoto.URL) + } +} + +func TestInlineResultsReusesTelegramFileIDs(t *testing.T) { + b := testBot() + b.cache = &fakeCache{refs: store.FileRefs{PhotoID: "photo-id", DocID: "doc-id"}} + b.ren = &stubRenderer{err: &render.LatexError{Excerpt: "must not render"}} + results := b.inlineResults(context.Background(), &tgbotapi.InlineQuery{Query: "$x$", ChatType: "private", From: &tgbotapi.User{ID: 1}}) + if len(results) != 3 { + t.Fatalf("got %d results, want 3", len(results)) + } + photo, ok := results[0].(tgbotapi.InlineQueryResultCachedPhoto) + if !ok || photo.PhotoID != "photo-id" { + t.Fatalf("photo = %#v", results[0]) + } + document, ok := results[1].(tgbotapi.InlineQueryResultCachedDocument) + if !ok || document.DocumentID != "doc-id" { + t.Fatalf("document = %#v", results[1]) + } +} + +func TestInlineResultsNoFileStore(t *testing.T) { + b := testBot() + b.files = nil + b.ren = &stubRenderer{err: &render.LatexError{Excerpt: "must not render"}} + results := b.inlineResults(context.Background(), &tgbotapi.InlineQuery{Query: "$x$", ChatType: "private", From: &tgbotapi.User{ID: 1}}) + if len(results) != 1 || article(t, results[0]).Title != "Rich text" { + t.Fatalf("results = %#v", results) + } +} + +func TestInlineResultsRichDisabled(t *testing.T) { + b := testBot() + b.cfg.RichTextEnabled = false + results := b.inlineResults(context.Background(), &tgbotapi.InlineQuery{Query: "$x$", ChatType: "private", From: &tgbotapi.User{ID: 1}}) + if len(results) != 2 { + t.Fatalf("got %d results, want 2", len(results)) + } + if _, ok := results[0].(tgbotapi.InlineQueryResultPhoto); !ok { + t.Fatalf("result 0 is %T, want InlineQueryResultPhoto", results[0]) + } +} + +func TestInlineResultsRenderError(t *testing.T) { + b := testBot() + b.ren = &stubRenderer{err: &render.LatexError{Excerpt: "! Undefined control sequence."}} + results := b.inlineResults(context.Background(), &tgbotapi.InlineQuery{Query: "\\bad", ChatType: "private", From: &tgbotapi.User{ID: 1}}) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if got := article(t, results[0]).Title; got != "LaTeX error" { + t.Errorf("title = %q, want LaTeX error", got) + } +} + +func TestInlineResultsEmptyQuery(t *testing.T) { + b := testBot() + results := b.inlineResults(context.Background(), &tgbotapi.InlineQuery{Query: " ", ChatType: "private", From: &tgbotapi.User{ID: 1}}) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if got := article(t, results[0]).Title; got != "Type a LaTeX expression" { + t.Errorf("title = %q", got) + } +} + +func TestContentHashStability(t *testing.T) { + a := contentHash("pre", "x^2", 600) + b := contentHash("pre", "x^2", 600) + c := contentHash("pre", "x^2", 300) + if a != b || len(a) != 64 { + t.Fatalf("hash must be stable: %q %q", a, b) + } + if a == c { + t.Fatal("different DPI must change the hash") + } +} + +func TestStatsText(t *testing.T) { + text := statsText(store.Stats{ + Renders: 42, ChatRenders: 30, InlineRenders: 12, + FailedRenders: 3, Renders24h: 7, Users: 9, Groups: 2, + }) + for _, fragment := range []string{ + "Renders: 42 (chat 30, inline 12)", + "Failed renders: 3", + "Renders in the last 24h: 7", + "Users: 9", + "Groups: 2", + } { + if !strings.Contains(text, fragment) { + t.Errorf("stats text missing %q:\n%s", fragment, text) + } + } +} + +func TestTruncate(t *testing.T) { + if got := truncate("hello", 5); got != "hello" { + t.Errorf("truncate = %q", got) + } + if got := truncate("hello", 3); got != "hel..." { + t.Errorf("truncate = %q", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8f301c0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,192 @@ +// Package config reads the latexgrambot configuration from environment variables. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Config holds every runtime setting. +type Config struct { + TelegramBotToken string + + // MongoURI is the shared cluster connection string. MongoDatabase is + // the bot's database inside it. + MongoURI string + MongoDatabase string + + // S3 hosts rendered inline files. All required values must be set + // together; otherwise inline mode offers only native rich text. + S3Endpoint string + S3Region string + S3Bucket string + S3AccessKeyID string + S3SecretAccessKey string + S3Prefix string + S3PublicEndpoint string + S3PresignTTL time.Duration + + HealthAddr string + + // RichTextEnabled controls the rich-message replies (native math block). + // The format needs a recent Bot API server; when disabled the bot falls + // back to PNG and PDF only. + RichTextEnabled bool + + // RichDefaultMath controls how the rich format treats a bare source + // (no math delimiters, no text-mode command): true renders it as a math + // expression, false parses it as text so formulas need $...$ delimiters. + RichDefaultMath bool + + RenderDPI int + RenderTimeout time.Duration + MaxConcurrency int + + // MaxExpressionLen caps chat-mode expressions. MaxPreambleLen caps the + // per-user preamble (Telegram messages are at most 4096 characters). + MaxExpressionLen int + MaxPreambleLen int + + PdflatexBin string + PdftoppmBin string + + // LatexFormat is the preloaded TeX format name (built from the default + // preamble at image build time). Empty disables it. + LatexFormat string +} + +// Load builds the Config and validates the mandatory keys. +func Load() (*Config, error) { + cfg := &Config{ + TelegramBotToken: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")), + MongoURI: strings.TrimSpace(os.Getenv("MONGODB_URI")), + MongoDatabase: getenv("MONGODB_DATABASE", "latexgram"), + S3Endpoint: strings.TrimRight(strings.TrimSpace(os.Getenv("S3_ENDPOINT_URL")), "/"), + S3Region: getenv("S3_REGION", "us-east-1"), + S3Bucket: strings.TrimSpace(os.Getenv("S3_BUCKET")), + S3AccessKeyID: strings.TrimSpace(os.Getenv("AWS_ACCESS_KEY_ID")), + S3SecretAccessKey: strings.TrimSpace(os.Getenv("AWS_SECRET_ACCESS_KEY")), + S3Prefix: strings.Trim(getenv("S3_PREFIX", "content"), "/"), + S3PublicEndpoint: strings.TrimRight(strings.TrimSpace(os.Getenv("S3_PUBLIC_ENDPOINT_URL")), "/"), + HealthAddr: getenv("HEALTH_ADDR", ":8080"), + PdflatexBin: getenv("PDFLATEX_BIN", "pdflatex"), + PdftoppmBin: getenv("PDFTOPPM_BIN", "pdftoppm"), + LatexFormat: getenv("LATEX_FORMAT", ""), + } + + var err error + if cfg.RichTextEnabled, err = getenvBool("RICH_TEXT_ENABLED", true); err != nil { + return nil, err + } + if cfg.RichDefaultMath, err = getenvBool("RICH_DEFAULT_MATH", true); err != nil { + return nil, err + } + if cfg.RenderDPI, err = getenvInt("RENDER_DPI", 600); err != nil { + return nil, err + } + if cfg.MaxConcurrency, err = getenvInt("RENDER_MAX_CONCURRENCY", 2); err != nil { + return nil, err + } + if cfg.MaxExpressionLen, err = getenvInt("MAX_EXPRESSION_LEN", 4000); err != nil { + return nil, err + } + if cfg.MaxPreambleLen, err = getenvInt("MAX_PREAMBLE_LEN", 4000); err != nil { + return nil, err + } + if cfg.RenderTimeout, err = getenvDuration("RENDER_TIMEOUT", 30*time.Second); err != nil { + return nil, err + } + if cfg.S3PresignTTL, err = getenvDuration("S3_PRESIGN_TTL", time.Hour); err != nil { + return nil, err + } + + if cfg.TelegramBotToken == "" { + return nil, fmt.Errorf("missing required environment variable: TELEGRAM_BOT_TOKEN") + } + if cfg.MongoURI == "" { + return nil, fmt.Errorf("missing required environment variable: MONGODB_URI") + } + if cfg.RenderDPI < 72 || cfg.RenderDPI > 1200 { + return nil, fmt.Errorf("RENDER_DPI must be between 72 and 1200") + } + if cfg.RenderTimeout <= 0 { + return nil, fmt.Errorf("RENDER_TIMEOUT must be positive") + } + if cfg.MaxConcurrency < 1 { + return nil, fmt.Errorf("RENDER_MAX_CONCURRENCY must be at least 1") + } + if cfg.MaxExpressionLen < 1 || cfg.MaxPreambleLen < 1 { + return nil, fmt.Errorf("MAX_EXPRESSION_LEN and MAX_PREAMBLE_LEN must be positive") + } + s3Set := 0 + for _, value := range []string{ + cfg.S3Endpoint, cfg.S3Bucket, cfg.S3AccessKeyID, + cfg.S3SecretAccessKey, cfg.S3PublicEndpoint, + } { + if value != "" { + s3Set++ + } + } + if s3Set != 0 && s3Set != 5 { + return nil, fmt.Errorf( + "incomplete S3 configuration: set S3_ENDPOINT_URL, S3_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and S3_PUBLIC_ENDPOINT_URL together") + } + if cfg.S3PresignTTL <= 0 { + return nil, fmt.Errorf("S3_PRESIGN_TTL must be positive") + } + return cfg, nil +} + +// S3Enabled reports whether private inline PNG/PDF assets can be stored and +// exposed through short-lived presigned URLs. +func (c *Config) S3Enabled() bool { + return c.S3Endpoint != "" && c.S3Bucket != "" && + c.S3AccessKeyID != "" && c.S3SecretAccessKey != "" && + c.S3PublicEndpoint != "" +} + +func getenv(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func getenvInt(key string, fallback int) (int, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("invalid %s: %w", key, err) + } + return parsed, nil +} + +func getenvBool(key string, fallback bool) (bool, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("invalid %s: %w", key, err) + } + return parsed, nil +} + +func getenvDuration(key string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("invalid %s: %w", key, err) + } + return parsed, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..a77ed10 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,143 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +func setRequired(t *testing.T) { + t.Helper() + t.Setenv("TELEGRAM_BOT_TOKEN", "123:abc") + t.Setenv("MONGODB_URI", "mongodb://example:27017") +} + +func TestLoadDefaults(t *testing.T) { + setRequired(t) + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.RenderDPI != 600 { + t.Errorf("RenderDPI = %d, want 600", cfg.RenderDPI) + } + if cfg.RenderTimeout != 30*time.Second { + t.Errorf("RenderTimeout = %s, want 30s", cfg.RenderTimeout) + } + if cfg.MaxConcurrency != 2 { + t.Errorf("MaxConcurrency = %d, want 2", cfg.MaxConcurrency) + } + if cfg.MaxExpressionLen != 4000 || cfg.MaxPreambleLen != 4000 { + t.Errorf("unexpected length caps: %d/%d", cfg.MaxExpressionLen, cfg.MaxPreambleLen) + } + if cfg.HealthAddr != ":8080" { + t.Errorf("HealthAddr = %q, want :8080", cfg.HealthAddr) + } + if cfg.MongoDatabase != "latexgram" { + t.Errorf("MongoDatabase = %q, want latexgram", cfg.MongoDatabase) + } + if !cfg.RichTextEnabled { + t.Error("RichTextEnabled = false, want true by default") + } + if !cfg.RichDefaultMath { + t.Error("RichDefaultMath = false, want true by default") + } + if cfg.S3PresignTTL != time.Hour { + t.Errorf("S3PresignTTL = %s, want 1h", cfg.S3PresignTTL) + } + if cfg.S3Enabled() { + t.Error("S3Enabled = true without S3 configuration") + } +} + +func TestLoadS3(t *testing.T) { + setRequired(t) + t.Setenv("S3_ENDPOINT_URL", "http://minio:9000/") + t.Setenv("S3_BUCKET", "latexgrambot") + t.Setenv("AWS_ACCESS_KEY_ID", "access") + t.Setenv("AWS_SECRET_ACCESS_KEY", "secret") + t.Setenv("S3_PUBLIC_ENDPOINT_URL", "https://s3.example/") + t.Setenv("S3_PRESIGN_TTL", "45m") + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.S3Enabled() || cfg.S3Endpoint != "http://minio:9000" || cfg.S3PublicEndpoint != "https://s3.example" { + t.Fatalf("unexpected S3 config: %+v", cfg) + } + if cfg.S3PresignTTL != 45*time.Minute { + t.Errorf("S3PresignTTL = %s", cfg.S3PresignTTL) + } +} + +func TestLoadRejectsIncompleteS3(t *testing.T) { + setRequired(t) + t.Setenv("S3_BUCKET", "latexgrambot") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "incomplete S3 configuration") { + t.Fatalf("expected incomplete S3 error, got %v", err) + } +} + +func TestLoadRichDefaultMathToggle(t *testing.T) { + setRequired(t) + t.Setenv("RICH_DEFAULT_MATH", "false") + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.RichDefaultMath { + t.Error("RichDefaultMath = true, want false") + } + + t.Setenv("RICH_DEFAULT_MATH", "banana") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "RICH_DEFAULT_MATH") { + t.Fatalf("expected RICH_DEFAULT_MATH validation error, got %v", err) + } +} + +func TestLoadRichTextToggle(t *testing.T) { + setRequired(t) + t.Setenv("RICH_TEXT_ENABLED", "false") + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.RichTextEnabled { + t.Error("RichTextEnabled = true, want false") + } + + t.Setenv("RICH_TEXT_ENABLED", "banana") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "RICH_TEXT_ENABLED") { + t.Fatalf("expected RICH_TEXT_ENABLED validation error, got %v", err) + } +} + +func TestLoadMissingToken(t *testing.T) { + t.Setenv("MONGODB_URI", "mongodb://example:27017") + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "TELEGRAM_BOT_TOKEN") { + t.Fatalf("expected missing token error, got %v", err) + } +} + +func TestLoadMissingMongoURI(t *testing.T) { + t.Setenv("TELEGRAM_BOT_TOKEN", "123:abc") + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "MONGODB_URI") { + t.Fatalf("expected missing MONGODB_URI error, got %v", err) + } +} + +func TestLoadInvalidValues(t *testing.T) { + setRequired(t) + t.Setenv("RENDER_DPI", "10") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "RENDER_DPI") { + t.Fatalf("expected RENDER_DPI validation error, got %v", err) + } + + t.Setenv("RENDER_DPI", "300") + t.Setenv("RENDER_TIMEOUT", "banana") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "RENDER_TIMEOUT") { + t.Fatalf("expected RENDER_TIMEOUT validation error, got %v", err) + } +} diff --git a/internal/files/files.go b/internal/files/files.go new file mode 100644 index 0000000..c906414 --- /dev/null +++ b/internal/files/files.go @@ -0,0 +1,145 @@ +// Package files stores rendered outputs in a private S3-compatible bucket +// and issues short-lived presigned URLs for Telegram inline-query results. +package files + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/google/uuid" +) + +// Config points the store at the internal S3-compatible API and the public +// API endpoint used only when signing download URLs. +type Config struct { + Endpoint string + Region string + Bucket string + AccessKeyID string + SecretAccessKey string + Prefix string + PublicEndpoint string + PresignTTL time.Duration + HTTPClient aws.HTTPClient +} + +// Store keeps content-addressed rendered assets in S3. +type Store struct { + client *s3.Client + presigner *s3.PresignClient + bucket string + prefix string + presignTTL time.Duration +} + +// New builds an S3-compatible object store. +func New(ctx context.Context, cfg Config) (*Store, error) { + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(cfg.Region), + awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + ), + ) + if err != nil { + return nil, fmt.Errorf("load aws config: %w", err) + } + if cfg.HTTPClient != nil { + awsCfg.HTTPClient = cfg.HTTPClient + } + client := s3.NewFromConfig(awsCfg, func(options *s3.Options) { + options.BaseEndpoint = aws.String(cfg.Endpoint) + options.UsePathStyle = true + }) + publicClient := s3.NewFromConfig(awsCfg, func(options *s3.Options) { + options.BaseEndpoint = aws.String(cfg.PublicEndpoint) + options.UsePathStyle = true + }) + return &Store{ + client: client, + presigner: s3.NewPresignClient(publicClient), + bucket: cfg.Bucket, + prefix: strings.Trim(cfg.Prefix, "/"), + presignTTL: cfg.PresignTTL, + }, nil +} + +// Put uploads the data under a fresh UUIDv4 object name and returns a +// short-lived presigned GET URL. +func (s *Store) Put(ctx context.Context, ext, contentType string, data []byte) (string, error) { + key := s.key(uuid.NewString(), ext) + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentType: aws.String(contentType), + CacheControl: aws.String("private, max-age=86400"), + }) + if err != nil { + return "", fmt.Errorf("put %s object: %w", ext, err) + } + + request, err := s.presigner.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }, func(options *s3.PresignOptions) { + options.Expires = s.presignTTL + }) + if err != nil { + return "", fmt.Errorf("presign %s object: %w", ext, err) + } + return request.URL, nil +} + +// DeleteExpired removes objects older than age and returns how many were +// deleted. Telegram downloads the file when an inline result is picked and +// the presigned link expires after the presign TTL; objects past their +// lifetime can no longer be fetched, so they are garbage collected. +func (s *Store) DeleteExpired(ctx context.Context, age time.Duration) (int, error) { + cutoff := time.Now().Add(-age) + prefix := "" + if s.prefix != "" { + prefix = s.prefix + "/" + } + deleted := 0 + var token *string + for { + list, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(s.bucket), + Prefix: aws.String(prefix), + ContinuationToken: token, + }) + if err != nil { + return deleted, fmt.Errorf("list objects: %w", err) + } + for _, object := range list.Contents { + if object.LastModified == nil || !object.LastModified.Before(cutoff) { + continue + } + if _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: object.Key, + }); err != nil { + return deleted, fmt.Errorf("delete %s: %w", aws.ToString(object.Key), err) + } + deleted++ + } + if list.IsTruncated == nil || !*list.IsTruncated { + return deleted, nil + } + token = list.NextContinuationToken + } +} + +func (s *Store) key(name, ext string) string { + if s.prefix == "" { + return name + "." + ext + } + return s.prefix + "/" + name + "." + ext +} diff --git a/internal/files/files_test.go b/internal/files/files_test.go new file mode 100644 index 0000000..184e37a --- /dev/null +++ b/internal/files/files_test.go @@ -0,0 +1,176 @@ +package files + +import ( + "context" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestPutUploadsUniqueUUIDObjectsWithPresignedURLs(t *testing.T) { + var puts atomic.Int32 + keys := make(chan string, 4) + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + status := http.StatusOK + switch request.Method { + case http.MethodPut: + puts.Add(1) + keys <- request.URL.Path + } + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("")), + Request: request, + }, nil + })} + + store, err := New(context.Background(), Config{ + Endpoint: "https://s3.internal", + PublicEndpoint: "https://s3.example", + Region: "us-east-1", + Bucket: "latexgrambot", + AccessKeyID: "access", + SecretAccessKey: "secret", + Prefix: "content", + PresignTTL: time.Hour, + HTTPClient: client, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + seen := map[string]bool{} + for range 2 { + signed, err := store.Put(context.Background(), "jpg", "image/jpeg", []byte("image")) + if err != nil { + t.Fatalf("Put: %v", err) + } + parsed, err := url.Parse(signed) + if err != nil { + t.Fatalf("parse signed URL: %v", err) + } + base := strings.TrimSuffix(strings.TrimPrefix(parsed.Path, "/latexgrambot/content/"), ".jpg") + if _, err := uuid.Parse(base); err != nil || uuid.MustParse(base).Version() != 4 { + t.Errorf("object name %q is not a UUIDv4", base) + } + if seen[parsed.Path] { + t.Errorf("object name %q was reused", parsed.Path) + } + seen[parsed.Path] = true + if parsed.Query().Get("X-Amz-Signature") == "" || parsed.Query().Get("X-Amz-Expires") != "3600" { + t.Errorf("URL is not signed for one hour: %s", signed) + } + } + if got := puts.Load(); got != 2 { + t.Fatalf("PUT requests = %d, want 2 (one object per request)", got) + } +} + +func TestDeleteExpired(t *testing.T) { + var deleted []string + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + header := make(http.Header) + body := "" + status := http.StatusOK + switch request.Method { + case http.MethodGet: // ListObjectsV2 + header.Set("Content-Type", "application/xml") + body = ` + +latexgrambotcontent/21000false +content/old.jpg2000-01-01T00:00:00.000Z"a"1STANDARD +content/fresh.pdf2099-01-01T00:00:00.000Z"b"1STANDARD +` + case http.MethodDelete: + deleted = append(deleted, request.URL.Path) + status = http.StatusNoContent + } + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: header, + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil + })} + + store, err := New(context.Background(), Config{ + Endpoint: "https://s3.internal", + PublicEndpoint: "https://s3.example", + Region: "us-east-1", + Bucket: "latexgrambot", + AccessKeyID: "access", + SecretAccessKey: "secret", + Prefix: "content", + PresignTTL: time.Hour, + HTTPClient: client, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + removed, err := store.DeleteExpired(context.Background(), time.Hour) + if err != nil { + t.Fatalf("DeleteExpired: %v", err) + } + if removed != 1 { + t.Fatalf("removed = %d, want 1", removed) + } + if len(deleted) != 1 || !strings.HasSuffix(deleted[0], "/content/old.jpg") { + t.Fatalf("deleted = %v, want the expired object only", deleted) + } +} + +func TestS3Integration(t *testing.T) { + endpoint := os.Getenv("LATEXGRAMBOT_S3_TEST_ENDPOINT") + accessKey := os.Getenv("LATEXGRAMBOT_S3_TEST_ACCESS_KEY") + secretKey := os.Getenv("LATEXGRAMBOT_S3_TEST_SECRET_KEY") + if endpoint == "" || accessKey == "" || secretKey == "" { + t.Skip("S3 integration environment is not configured") + } + store, err := New(context.Background(), Config{ + Endpoint: endpoint, + PublicEndpoint: endpoint, + Region: "us-east-1", + Bucket: "latexgrambot", + AccessKeyID: accessKey, + SecretAccessKey: secretKey, + Prefix: "integration", + PresignTTL: 5 * time.Minute, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + const payload = "latexgrambot S3 integration probe\n" + signed, err := store.Put(context.Background(), "txt", "text/plain", []byte(payload)) + if err != nil { + t.Fatalf("Put: %v", err) + } + response, err := http.Get(signed) + if err != nil { + t.Fatalf("GET presigned URL: %v", err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("read presigned response: %v", err) + } + if response.StatusCode != http.StatusOK || string(body) != payload { + t.Fatalf("presigned GET = %s, body %q", response.Status, body) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..2dd238b --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,446 @@ +// Package render turns LaTeX expressions into PNG and PDF files using a +// locally installed TeX Live (pdflatex) and poppler (pdftoppm). No network +// service is involved. +package render + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "math" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "latexgrambot/internal/rich" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +var renderTracer = otel.Tracer("latexgrambot/render") + +const ( + // minRenderedSide is the minimum size of the smallest PNG side. Small + // formulas rasterize to a few hundred pixels at any DPI; Telegram then + // upscales and JPEG-recompresses them, blurring thin glyphs. Upscaling + // the raster beforehand keeps the final picture crisp. + minRenderedSide = 2560 + + // maxRenderedSide guards against pathologically tall formulas. + maxRenderedSide = 4096 +) + +// documentClass is the fixed preview layout: the standalone class crops +// tightly around the content. +const documentClass = `\documentclass[preview,varwidth,border=4pt]{standalone}` + +// defaultPackages is inserted into the document when the user has no custom +// preamble: the broad standard package set so documents compile without a +// custom preamble. +const defaultPackages = `\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage{lmodern} +\usepackage{textcomp} +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{amsfonts} +\usepackage{amsthm} +\usepackage{amscd} +\usepackage{mathtools} +\usepackage{mathrsfs} +\usepackage{bm} +\usepackage{cancel} +\usepackage{braket} +\usepackage{siunitx} +\usepackage{mhchem} +\usepackage{chemfig} +\usepackage{systeme} +\usepackage{polynom} +\usepackage{wasysym} +\usepackage{stmaryrd} +\usepackage{pifont} +\usepackage{eurosym} +\usepackage{graphicx} +\usepackage{xcolor} +\usepackage{colortbl} +\usepackage{tikz} +\usepackage{pgfplots} +\pgfplotsset{compat=1.18} +\usepackage{tikz-cd} +\usepackage{tcolorbox} +\usepackage{qrcode} +\usepackage{float} +\usepackage{array} +\usepackage{multirow} +\usepackage{makecell} +\usepackage{booktabs} +\usepackage{tabularx} +\usepackage{longtable} +\usepackage{multicol} +\usepackage{enumitem} +\usepackage{caption} +\usepackage{subcaption} +\usepackage{wrapfig} +\usepackage{rotating} +\usepackage[normalem]{ulem} +\usepackage{soul} +\usepackage{listings} +\usepackage{algorithm} +\usepackage{algpseudocode} +\usepackage{microtype} +\usepackage{xspace} +\usepackage{etoolbox} +\usepackage{hyperref}` + +// DefaultPreamble returns the preamble file used at image build time to +// preload the default class and packages into the "latexgrambot" TeX +// format, so renders no longer pay the package-load cost. +func DefaultPreamble() string { + return documentClass + "\n" + defaultPackages + "\n\\endofdump\n" +} + +// Result carries the compiled output. JPEG is used for URL-based Telegram +// inline photos, while PNG is used for direct chat replies. +type Result struct { + PNG []byte + JPEG []byte + PDF []byte +} + +// LatexError wraps a LaTeX compile failure and carries the log excerpt shown +// to the user. +type LatexError struct { + Excerpt string +} + +func (e *LatexError) Error() string { return "latex compilation failed" } + +// Renderer compiles LaTeX documents locally. +type Renderer struct { + Pdflatex string + Pdftoppm string + Timeout time.Duration + WorkDir string // base directory for temp dirs; os.TempDir() when empty + + // Format is the preloaded TeX format (built from DefaultPreamble) used + // when the default preamble is in effect. Empty disables it. + Format string + + sem chan struct{} +} + +// New creates a Renderer that allows at most maxConcurrent simultaneous +// pdflatex runs. +func New(pdflatex, pdftoppm string, timeout time.Duration, maxConcurrent int, workDir string) *Renderer { + if maxConcurrent < 1 { + maxConcurrent = 1 + } + return &Renderer{ + Pdflatex: pdflatex, + Pdftoppm: pdftoppm, + Timeout: timeout, + WorkDir: workDir, + sem: make(chan struct{}, maxConcurrent), + } +} + +// dangerousCommands are TeX primitives that read or write files; user input +// must never reach them. +var dangerousCommands = regexp.MustCompile(`\\(input|include|openin|openout|write|read|catcode|newwrite|immediate)\b`) + +// normalizeExpression validates and trims user LaTeX without changing its +// mode. The bot has already applied the RICH_DEFAULT_MATH policy before it +// calls the renderer; wrapping everything here would incorrectly place text +// commands such as \hyperlink and \section inside display math. +func normalizeExpression(expression string) (string, error) { + expr := strings.TrimSpace(expression) + if expr == "" { + return "", &LatexError{Excerpt: "the expression is empty"} + } + if dangerousCommands.MatchString(expr) { + return "", &LatexError{Excerpt: "unsupported TeX command in the expression"} + } + return expr, nil +} + +// stripOuterMathDelimiters removes matching outer math delimiters, so a +// forced math retry never nests delimiters. +func stripOuterMathDelimiters(expr string) string { + for _, pair := range [][2]string{{`$$`, `$$`}, {`$`, `$`}, {`\[`, `\]`}, {`\(`, `\)`}} { + if len(expr) >= len(pair[0])+len(pair[1]) && + strings.HasPrefix(expr, pair[0]) && strings.HasSuffix(expr, pair[1]) { + return strings.TrimSpace(expr[len(pair[0]) : len(expr)-len(pair[1])]) + } + } + return expr +} + +// Formats selects which outputs a render produces. +type Formats uint8 + +const ( + FormatPNG Formats = 1 << iota + FormatJPEG + FormatPDF +) + +// Render compiles the expression and produces every format. +func (r *Renderer) Render(ctx context.Context, preamble, expression string, dpi int) (*Result, error) { + return r.RenderFormats(ctx, preamble, expression, dpi, FormatPNG|FormatJPEG|FormatPDF) +} + +// RenderFormats compiles the expression inside the given preamble and +// produces only the requested formats, so callers never pay for +// rasterization they do not use. The expression is normalised (math +// delimiters stripped, plain expressions wrapped in math mode) and inserted +// between \begin{document} and \end{document}. +func (r *Renderer) RenderFormats(ctx context.Context, preamble, expression string, dpi int, formats Formats) (result *Result, err error) { + normalized, err := normalizeExpression(expression) + if err != nil { + return nil, err + } + ctx, span := renderTracer.Start(ctx, "render", + trace.WithAttributes( + attribute.Int("dpi", dpi), + attribute.Int("expression.length", len(expression)), + attribute.Int("expression.normalized_length", len(normalized)), + attribute.Int("preamble.length", len(preamble)), + ), + ) + defer span.End() + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "render failed") + } + }() + + select { + case r.sem <- struct{}{}: + defer func() { <-r.sem }() + case <-ctx.Done(): + return nil, ctx.Err() + } + + workdir, err := os.MkdirTemp(r.WorkDir, "latexgrambot-") + if err != nil { + return nil, fmt.Errorf("create workdir: %w", err) + } + defer os.RemoveAll(workdir) + + texPath := filepath.Join(workdir, "document.tex") + // Preloaded format: with the default preamble the class and packages + // are already in the format, so only the body is compiled. + useFormat := r.Format != "" && strings.TrimSpace(preamble) == "" + compile := func(body string) error { + document := buildDocument(preamble, body) + if useFormat { + document = "\\begin{document}\n" + body + "\n\\end{document}\n" + } + if err := os.WriteFile(texPath, []byte(document), 0o600); err != nil { + return fmt.Errorf("write document: %w", err) + } + // -no-shell-escape is the default, spelled out here on purpose: the + // \write18 escapes must never be enabled for user-supplied LaTeX. + args := []string{ + "-interaction=nonstopmode", "-halt-on-error", "-no-shell-escape", + "-output-directory=" + workdir, + } + if useFormat { + args = append(args, "-fmt="+r.Format) + } + return r.run(ctx, workdir, r.Pdflatex, append(args, "document.tex")...) + } + + // Entirely-math expressions are put into math mode up front: waiting + // for the "Missing $ inserted" error would compile every formula twice. + firstAttempt := normalized + if rich.IsMathExpression(expression) && !rich.ContainsMathEnvironment(expression) { + firstAttempt = `\[` + stripOuterMathDelimiters(strings.TrimSpace(normalized)) + `\]` + } + + if err := compile(firstAttempt); err != nil { + logContent, readErr := os.ReadFile(filepath.Join(workdir, "document.log")) + if readErr != nil { + return nil, err + } + // One retry for cases the up-front check missed: when the compiler + // only complained about missing math delimiters and the whole + // expression is math. If the retry fails too, the original error is + // reported so the user decides what to do. + if strings.Contains(string(logContent), "Missing $ inserted") && + rich.IsMathExpression(expression) && firstAttempt == normalized { + forced := `\[` + stripOuterMathDelimiters(strings.TrimSpace(expression)) + `\]` + for _, name := range []string{"document.aux", "document.log", "document.pdf"} { + _ = os.Remove(filepath.Join(workdir, name)) + } + if retryErr := compile(forced); retryErr != nil { + return nil, &LatexError{Excerpt: extractError(string(logContent))} + } + } else { + return nil, &LatexError{Excerpt: extractError(string(logContent))} + } + } + + pdfPath := filepath.Join(workdir, "document.pdf") + result = &Result{} + if formats&FormatPDF != 0 { + result.PDF, err = os.ReadFile(pdfPath) + if err != nil { + return nil, fmt.Errorf("read compiled pdf: %w", err) + } + } + if formats&FormatPNG != 0 { + pngPath := filepath.Join(workdir, "document.png") + if err := r.renderRaster(ctx, workdir, dpi, pdfPath, pngPath, "png"); err != nil { + return nil, err + } + result.PNG, err = os.ReadFile(pngPath) + if err != nil { + return nil, fmt.Errorf("read rendered png: %w", err) + } + } + if formats&FormatJPEG != 0 { + jpegPath := filepath.Join(workdir, "document.jpg") + if err := r.renderRaster(ctx, workdir, dpi, pdfPath, jpegPath, "jpeg"); err != nil { + return nil, err + } + result.JPEG, err = os.ReadFile(jpegPath) + if err != nil { + return nil, fmt.Errorf("read rendered jpeg: %w", err) + } + } + return result, nil +} + +// renderRaster rasterizes the first PDF page to a PNG or JPEG, re-rendering +// it upscaled when the natural size is too small for a crisp Telegram +// display. Rasterizing the JPEG directly avoids decoding and re-encoding a +// full-size PNG in Go. +func (r *Renderer) renderRaster(ctx context.Context, workdir string, dpi int, pdfPath, outPath, format string) error { + base := filepath.Join(workdir, "document") + args := []string{"-" + format, "-r", strconv.Itoa(dpi), "-singlefile", "-f", "1", "-l", "1"} + if err := r.run(ctx, workdir, r.Pdftoppm, append(args, pdfPath, base)...); err != nil { + return err + } + width, height, err := imageSize(outPath) + if err != nil { + return fmt.Errorf("inspect rendered %s: %w", format, err) + } + small := min(width, height) + large := max(width, height) + scale := 1.0 + if small > 0 && small < minRenderedSide { + scale = float64(minRenderedSide) / float64(small) + if scale*float64(large) > maxRenderedSide { + scale = float64(maxRenderedSide) / float64(large) + } + } + if scale <= 1 { + return nil + } + scaledWidth := int(math.Round(float64(width) * scale)) + args = append(args, + "-scale-to-x", strconv.Itoa(scaledWidth), "-scale-to-y", "-1") + return r.run(ctx, workdir, r.Pdftoppm, append(args, pdfPath, base)...) +} + +func imageSize(path string) (int, int, error) { + file, err := os.Open(path) + if err != nil { + return 0, 0, err + } + defer file.Close() + config, _, err := image.DecodeConfig(file) + if err != nil { + return 0, 0, err + } + return config.Width, config.Height, nil +} + +// buildDocument assembles the full .tex source. User preamble additions are +// loaded after the broad default package set, so setting custom definitions +// cannot accidentally remove core packages such as hyperref. +func buildDocument(preamble, expression string) string { + pkgs := defaultPackages + if strings.TrimSpace(preamble) != "" { + pkgs += "\n" + strings.TrimSpace(preamble) + } + return documentClass + "\n" + + pkgs + "\n" + + "\\begin{document}\n" + + expression + "\n" + + "\\end{document}\n" +} + +// extractError pulls the first "! ..." error line and its "l.NN ..." context +// line from the pdflatex log. +func extractError(log string) string { + var first, context string + for _, line := range strings.Split(log, "\n") { + trimmed := strings.TrimSpace(line) + if first == "" && strings.HasPrefix(trimmed, "!") { + first = trimmed + continue + } + if first != "" && strings.HasPrefix(trimmed, "l.") { + context = trimmed + break + } + } + if first == "" { + return "compilation failed without an explicit error message" + } + if context == "" { + return first + } + return first + "\n" + context +} + +// run executes a command in dir, inheriting the timeout from ctx. Home, +// TMPDIR and TEXMFOUTPUT point into the workdir so TeX never needs to write +// outside it. +func (r *Renderer) run(ctx context.Context, dir, bin string, args ...string) error { + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "HOME="+dir, + "TMPDIR="+dir, + "TEXMFOUTPUT="+dir, + ) + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("%s: %w", filepath.Base(bin), ctx.Err()) + } + var execErr *exec.Error + if errors.As(err, &execErr) { + return fmt.Errorf("%s is not available: %w", bin, execErr) + } + return fmt.Errorf("%s failed: %w (%s)", filepath.Base(bin), err, firstLine(output.String())) + } + return nil +} + +// firstLine returns the first non-empty line of s, or an empty string. +func firstLine(s string) string { + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) != "" { + return strings.TrimSpace(line) + } + } + return "" +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..f5244cc --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,243 @@ +package render + +import ( + "bytes" + "context" + "image" + _ "image/png" + "os/exec" + "strings" + "testing" + "time" +) + +func TestBuildDocument(t *testing.T) { + t.Run("default preamble", func(t *testing.T) { + doc := buildDocument("", `$x^2$`) + if !strings.Contains(doc, "\\documentclass[preview,varwidth,border=4pt]{standalone}") { + t.Errorf("missing document class:\n%s", doc) + } + if !strings.Contains(doc, defaultPackages) { + t.Errorf("missing default packages:\n%s", doc) + } + if !strings.Contains(doc, "\\begin{document}\n$x^2$\n\\end{document}") { + t.Errorf("expression not placed in the document body:\n%s", doc) + } + }) + + t.Run("custom preamble extends packages", func(t *testing.T) { + doc := buildDocument(`\usepackage{chemist}`, `H_2O`) + if !strings.Contains(doc, defaultPackages) { + t.Errorf("default packages missing:\n%s", doc) + } + if !strings.Contains(doc, "\\usepackage{chemist}") { + t.Errorf("custom preamble missing:\n%s", doc) + } + if !strings.Contains(doc, "\\begin{document}\nH_2O\n\\end{document}") { + t.Errorf("expression not placed in the document body:\n%s", doc) + } + }) +} + +func TestExtractError(t *testing.T) { + log := strings.Join([]string{ + "This is pdfTeX, Version 3.141592653-2.6-1.40.26", + "! Undefined control sequence.", + "l.4 \\begin{document}", + " \\badcommand", + "", + "! LaTeX Error: File `missing.sty' not found.", + "l.5 \\usepackage{missing}", + }, "\n") + + excerpt := extractError(log) + if !strings.Contains(excerpt, "! Undefined control sequence.") { + t.Errorf("missing first error line in %q", excerpt) + } + if !strings.Contains(excerpt, "l.4 \\begin{document}") { + t.Errorf("missing context line in %q", excerpt) + } + if strings.Contains(excerpt, "missing.sty") { + t.Errorf("second error must not be reported with -halt-on-error behavior: %q", excerpt) + } +} + +func TestExtractErrorWithoutErrorLine(t *testing.T) { + excerpt := extractError("some unrelated log content") + if !strings.Contains(excerpt, "without an explicit error") { + t.Errorf("unexpected excerpt: %q", excerpt) + } +} + +func TestRenderIntegration(t *testing.T) { + if _, err := exec.LookPath("pdflatex"); err != nil { + t.Skip("pdflatex not installed") + } + if _, err := exec.LookPath("pdftoppm"); err != nil { + t.Skip("pdftoppm not installed") + } + + r := New("pdflatex", "pdftoppm", 30*time.Second, 2, "") + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + result, err := r.Render(ctx, "", `$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$`, 150) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if len(result.PNG) == 0 { + t.Fatal("empty PNG") + } + if !strings.HasPrefix(string(result.PNG), "\x89PNG") { + t.Fatal("output is not a PNG file") + } + width, height, err := pngSizeBytes(result.PNG) + if err != nil { + t.Fatalf("decode png size: %v", err) + } + if width < minRenderedSide && height < minRenderedSide { + t.Errorf("png is %dx%d, want the smallest side at least %d", width, height, minRenderedSide) + } + if width > maxRenderedSide || height > maxRenderedSide { + t.Errorf("png is %dx%d, want no side beyond %d", width, height, maxRenderedSide) + } + if len(result.PDF) == 0 || !strings.HasPrefix(string(result.PDF), "%PDF") { + t.Fatal("output is not a PDF file") + } + if len(result.JPEG) == 0 || !strings.HasPrefix(string(result.JPEG), "\xff\xd8") { + t.Fatal("output is not a JPEG file") + } + + linked, err := r.Render(ctx, "", `\hypertarget{details}{Details}\par\hyperlink{details}{Go to details}`, 150) + if err != nil { + t.Fatalf("hyperlink render failed: %v", err) + } + if len(linked.PDF) == 0 || len(linked.PNG) == 0 || len(linked.JPEG) == 0 { + t.Fatal("hyperlink render returned an empty format") + } +} + +func pngSizeBytes(data []byte) (int, int, error) { + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return 0, 0, err + } + return config.Width, config.Height, nil +} + +func TestRenderErrorIntegration(t *testing.T) { + if _, err := exec.LookPath("pdflatex"); err != nil { + t.Skip("pdflatex not installed") + } + + r := New("pdflatex", "pdftoppm", 30*time.Second, 2, "") + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + _, err := r.Render(ctx, "", `\undefinedcommandxyz`, 150) + latexErr, ok := err.(*LatexError) + if !ok { + t.Fatalf("expected *LatexError, got %T: %v", err, err) + } + if !strings.Contains(latexErr.Excerpt, "! Undefined control sequence") { + t.Errorf("unexpected excerpt: %q", latexErr.Excerpt) + } +} + +func TestRenderPreWrapsBareMath(t *testing.T) { + if _, err := exec.LookPath("pdflatex"); err != nil { + t.Skip("pdflatex not installed") + } + r := New("pdflatex", "pdftoppm", 30*time.Second, 2, "") + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Bare math is wrapped up front, so a single compile succeeds. + result, err := r.Render(ctx, "", `\int_0^1 x\,dx`, 150) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if len(result.PNG) == 0 || len(result.PDF) == 0 { + t.Fatal("empty output") + } +} + +func TestRenderRetriesMissingDollarForMath(t *testing.T) { + if _, err := exec.LookPath("pdflatex"); err != nil { + t.Skip("pdflatex not installed") + } + r := New("pdflatex", "pdftoppm", 30*time.Second, 2, "") + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // A math environment is not pre-wrapped; the compile fails once with + // "Missing $ inserted" and the retry wraps the whole expression. + result, err := r.Render(ctx, "", "\\begin{matrix}a&b\\\\c&d\\end{matrix} \\int_0^1 x\\,dx", 150) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if len(result.PNG) == 0 || len(result.PDF) == 0 { + t.Fatal("empty output") + } +} + +func TestRenderNoRetryForMixedInput(t *testing.T) { + if _, err := exec.LookPath("pdflatex"); err != nil { + t.Skip("pdflatex not installed") + } + r := New("pdflatex", "pdftoppm", 30*time.Second, 2, "") + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Mixed text with a math-only command inside is not retried: the error + // is reported to the user as-is. + _, err := r.Render(ctx, "", "\\begin{itemize}\\item \\int\\end{itemize}", 150) + latexErr, ok := err.(*LatexError) + if !ok { + t.Fatalf("expected *LatexError, got %T: %v", err, err) + } + if !strings.Contains(latexErr.Excerpt, "Missing $ inserted") { + t.Errorf("unexpected excerpt: %q", latexErr.Excerpt) + } +} + +func TestDefaultPreamble(t *testing.T) { + preamble := DefaultPreamble() + for _, fragment := range []string{ + "\\documentclass[preview,varwidth,border=4pt]{standalone}", + "\\usepackage{amsmath}", + "\\usepackage{tikz}", + "\\endofdump", + } { + if !strings.Contains(preamble, fragment) { + t.Errorf("preamble missing %q", fragment) + } + } +} + +func TestNormalizeExpression(t *testing.T) { + cases := map[string]string{ + `\mathbb{ R }`: `\mathbb{ R }`, + `$\mathbb{R}$`: `$\mathbb{R}$`, + `$$x^2 + y^2$$`: `$$x^2 + y^2$$`, + `\[ \frac{1}{2} \]`: `\[ \frac{1}{2} \]`, + `\(a+b\)`: `\(a+b\)`, + " x ": `x`, + `\begin{align} a &= b \\ c &= d \end{align}`: `\begin{align} a &= b \\ c &= d \end{align}`, + } + for input, want := range cases { + got, err := normalizeExpression(input) + if err != nil { + t.Fatalf("normalizeExpression(%q): %v", input, err) + } + if got != want { + t.Fatalf("normalizeExpression(%q) = %q, want %q", input, got, want) + } + } + if _, err := normalizeExpression(`\input{/etc/passwd}`); err == nil { + t.Fatal("dangerous command was accepted") + } + if _, err := normalizeExpression(" "); err == nil { + t.Fatal("empty expression was accepted") + } +} diff --git a/internal/rich/latex.go b/internal/rich/latex.go new file mode 100644 index 0000000..a1683d2 --- /dev/null +++ b/internal/rich/latex.go @@ -0,0 +1,742 @@ +package rich + +import ( + "strings" + "unicode" +) + +// textCommands marks a document as text-mode LaTeX (as opposed to a bare +// math expression) when any of these is present. +var textCommands = map[string]bool{ + "textbf": true, "textit": true, "emph": true, "underline": true, + "uline": true, "texttt": true, "textsuperscript": true, + "textsubscript": true, "textrm": true, "textsf": true, "textnormal": true, + "textup": true, "textmd": true, "textcolor": true, + "href": true, "url": true, "hyperlink": true, "hypertarget": true, + "section": true, "subsection": true, + "subsubsection": true, "item": true, "maketitle": true, "title": true, + "author": true, "documentclass": true, "par": true, +} + +// wrapperEnvironments are environments whose markers are dropped from text +// mode; their content is rendered inline. +var wrapperEnvironments = map[string]bool{ + "itemize": true, "enumerate": true, "center": true, "flushleft": true, + "flushright": true, "quote": true, "quotation": true, "description": true, + "small": true, "footnotesize": true, "large": true, "Large": true, + "document": true, +} + +// documentClassEnvironments are math environments that keep a \begin{...} +// source in math mode. +var mathEnvironments = map[string]bool{ + "equation": true, "equation*": true, "align": true, "align*": true, + "aligned": true, "gather": true, "gather*": true, "multline": true, + "multline*": true, "eqnarray": true, "eqnarray*": true, "array": true, + "matrix": true, "pmatrix": true, "bmatrix": true, "vmatrix": true, + "Vmatrix": true, "Bmatrix": true, "cases": true, "split": true, + "displaymath": true, "math": true, +} + +// symbolCommands maps exotic symbol commands that client math engines +// commonly lack to Unicode. Standard LaTeX/AMS math commands (\int, \sum, +// Greek letters, arrows, relations, ...) are deliberately NOT translated: +// the client engine renders them natively, while Unicode replacements are +// not drawn by every math font. +var symbolCommands = map[string]string{ + "heartsuit": "♡", "diamondsuit": "♢", "spadesuit": "♠", "clubsuit": "♣", + "checkmark": "✓", "textbullet": "•", "degree": "°", "celsius": "°C", + "copyright": "©", "registered": "®", "circledR": "®", "pounds": "£", + "euro": "€", "yen": "¥", "male": "♂", "female": "♀", + "flat": "♭", "natural": "♮", "sharp": "♯", + "Box": "□", "square": "□", "blacksquare": "■", + "triangle": "△", "blacktriangle": "▲", "blacktriangledown": "▼", + "bigstar": "★", "lozenge": "◊", "blacklozenge": "⧫", + "measuredangle": "∡", "sphericalangle": "∢", + "therefore": "∴", "because": "∵", "maltese": "✠", + "diagup": "╱", "diagdown": "╲", "complement": "∁", "eth": "ð", + "Finv": "Ⅎ", "Game": "⅁", "circledS": "Ⓢ", "mho": "℧", +} + +// translateSymbols replaces known symbol commands with Unicode. Font +// commands such as \mathbb are left untouched: client math engines render +// them themselves and their Unicode math-alphanumeric fallbacks are not +// supported everywhere. +func translateSymbols(source string) string { + if !strings.Contains(source, `\`) { + return source + } + var out strings.Builder + for index := 0; index < len(source); { + if source[index] != '\\' { + out.WriteByte(source[index]) + index++ + continue + } + name, next := readCommand(source, index) + if name == "" { + out.WriteString(source[index:next]) + index = next + continue + } + if replacement, ok := symbolCommands[name]; ok { + out.WriteString(replacement) + index = skipEmptyGroup(source, next) + continue + } + out.WriteString(source[index:next]) + index = next + } + return out.String() +} + +// Document converts a LaTeX source into a rich message. Math segments +// ($...$, $$...$$, \(...\), \[...\]) become math nodes; text paragraphs keep +// their line structure and map the common text-mode formatting commands to +// rich text nodes. With defaultMath, a source with no math delimiters and +// no known text-mode command is treated as a single math expression; +// otherwise such a source is parsed as text and formulas need delimiters. +func Document(source string, defaultMath bool) *Message { + source = documentBody(source) + source = translateSymbols(source) + trimmed := strings.TrimSpace(source) + if trimmed == "" { + return &Message{Blocks: []Block{{Type: "paragraph", Text: " "}}} + } + if defaultMath && isPureMath(trimmed) { + if isSymbolsOnly(trimmed) { + // Client math fonts often lack exotic symbols; the regular + // text font renders them everywhere. + return &Message{Blocks: []Block{{Type: "paragraph", Text: trimmed}}} + } + return &Message{Blocks: []Block{{Type: "mathematical_expression", Expression: trimmed}}} + } + parser := &parser{src: source} + blocks := parser.parseBlocks() + if len(blocks) == 0 { + blocks = []Block{{Type: "paragraph", Text: " "}} + } + return &Message{Blocks: blocks} +} + +// IsMathExpression reports whether the source is a math expression: bare +// math commands with no text-mode command, a delimited math segment, or a +// math environment. Used to decide whether a failed compile may be retried +// with the expression forced into math mode. +func IsMathExpression(source string) bool { + trimmed := strings.TrimSpace(source) + if trimmed == "" { + return false + } + if stripOuterMathDelimiters(trimmed) != trimmed { + return true + } + // Delimiters somewhere inside mean mixed text and math: not entirely + // math, so no retry. + if strings.Contains(trimmed, "$") || strings.Contains(trimmed, `\(`) || strings.Contains(trimmed, `\[`) { + return false + } + for _, command := range commandsIn(trimmed) { + if textCommands[command] { + return false + } + } + for _, environment := range environmentsIn(trimmed) { + if !mathEnvironments[environment] { + return false + } + } + return true +} + +// ContainsMathEnvironment reports whether the source contains a math +// environment such as align or matrix; those compile standalone and must +// not be wrapped again. +func ContainsMathEnvironment(source string) bool { + for _, environment := range environmentsIn(source) { + if mathEnvironments[environment] { + return true + } + } + return false +} + +// stripOuterMathDelimiters removes matching outer math delimiters. +func stripOuterMathDelimiters(expr string) string { + for _, pair := range [][2]string{{`$$`, `$$`}, {`$`, `$`}, {`\[`, `\]`}, {`\(`, `\)`}} { + if len(expr) >= len(pair[0])+len(pair[1]) && + strings.HasPrefix(expr, pair[0]) && strings.HasSuffix(expr, pair[1]) { + return strings.TrimSpace(expr[len(pair[0]) : len(expr)-len(pair[1])]) + } + } + return expr +} + +// isSymbolsOnly reports whether the source consists only of symbols and +// punctuation: no letters, digits or LaTeX math structure. +func isSymbolsOnly(source string) bool { + for _, r := range source { + switch r { + case '\\', '_', '{', '}', '$', '&', '#', '^': + return false + } + if r < 128 && !unicode.IsPunct(r) && !unicode.IsSpace(r) { + return false + } + } + return true +} + +// InlineDocument is Document prepared for inline answers: a leading math +// expression renders inline one space after the bot mention instead of as a +// display block, and a leading paragraph starts with one space. +func InlineDocument(source string, defaultMath bool) *Message { + message := Document(source, defaultMath) + if len(message.Blocks) == 0 { + return message + } + first := &message.Blocks[0] + switch first.Type { + case "mathematical_expression": + *first = Block{Type: "paragraph", Text: []any{ + " ", + Inline{Type: "mathematical_expression", Expression: first.Expression}, + }} + case "paragraph": + first.Text = prependSpace(first.Text) + } + return message +} + +func prependSpace(text any) any { + switch value := text.(type) { + case string: + return " " + value + case []any: + return append([]any{" "}, value...) + default: + return text + } +} + +// documentBody reduces a full document to the content of its document +// environment. +func documentBody(source string) string { + const begin = `\begin{document}` + const end = `\end{document}` + index := strings.Index(source, begin) + if index < 0 { + return source + } + source = source[index+len(begin):] + if index = strings.Index(source, end); index >= 0 { + source = source[:index] + } + return source +} + +// isPureMath reports whether the source should be rendered as one math +// expression: no math delimiters, no known text-mode command and no +// non-math environment. +func isPureMath(source string) bool { + if strings.Contains(source, "$") || strings.Contains(source, `\(`) || strings.Contains(source, `\[`) { + return false + } + for _, command := range commandsIn(source) { + if textCommands[command] { + return false + } + } + for _, env := range environmentsIn(source) { + if !mathEnvironments[env] { + return false + } + } + return true +} + +func commandsIn(source string) []string { + var commands []string + for i := 0; i < len(source); i++ { + if source[i] != '\\' || i+1 >= len(source) { + continue + } + if source[i+1] == '\\' { + commands = append(commands, `\\`) + i++ + continue + } + name := "" + next := i + 1 + for next < len(source) && isCommandLetter(source[next]) { + name += string(source[next]) + next++ + } + if name != "" { + commands = append(commands, name) + i = next - 1 + } + } + return commands +} + +func environmentsIn(source string) []string { + var envs []string + for index := 0; index < len(source); { + position := strings.Index(source[index:], `\begin{`) + if position < 0 { + break + } + position += index + start := position + len(`\begin{`) + end := strings.IndexByte(source[start:], '}') + if end < 0 { + break + } + envs = append(envs, source[start:start+end]) + index = start + } + return envs +} + +// readEnvironment reads \begin{name} or \end{name} at index and returns the +// environment name and the position after the closing brace. +func readEnvironment(source string, index int) (string, int, bool) { + prefix := `\begin{` + if strings.HasPrefix(source[index:], `\end{`) { + prefix = `\end{` + } else if !strings.HasPrefix(source[index:], prefix) { + return "", index, false + } + start := index + len(prefix) + end := strings.IndexByte(source[start:], '}') + if end < 0 { + return "", index, false + } + return source[start : start+end], start + end + 1, true +} + +func isCommandLetter(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +type parser struct { + src string + pos int +} + +func (p *parser) eof() bool { return p.pos >= len(p.src) } + +func (p *parser) startsWith(prefix string) bool { + return strings.HasPrefix(p.src[p.pos:], prefix) +} + +func (p *parser) parseBlocks() []Block { + var blocks []Block + var text strings.Builder + flush := func() { + trimmed := strings.TrimSpace(text.String()) + text.Reset() + if trimmed == "" { + return + } + blocks = append(blocks, Block{Type: "paragraph", Text: parseInline(trimmed)}) + } + + for !p.eof() { + switch { + case p.startsWith(`\[`): + flush() + p.pos += 2 + expression := strings.TrimSpace(p.readUntil(`\]`)) + if isSymbolsOnly(expression) { + blocks = append(blocks, Block{Type: "paragraph", Text: strings.TrimSpace(expression)}) + } else { + blocks = append(blocks, Block{Type: "mathematical_expression", Expression: expression}) + } + case p.startsWith("$$"): + flush() + p.pos += 2 + expression := strings.TrimSpace(p.readUntil("$$")) + if isSymbolsOnly(expression) { + blocks = append(blocks, Block{Type: "paragraph", Text: strings.TrimSpace(expression)}) + } else { + blocks = append(blocks, Block{Type: "mathematical_expression", Expression: expression}) + } + case p.startsWith(`\section`): + flush() + p.pos += len(`\section`) + if title, ok := p.readOptionalStarAndGroup(); ok { + blocks = append(blocks, Block{Type: "heading", Text: parseInline(title), Size: 2}) + } + case p.startsWith(`\subsection`): + flush() + p.pos += len(`\subsection`) + if title, ok := p.readOptionalStarAndGroup(); ok { + blocks = append(blocks, Block{Type: "heading", Text: parseInline(title), Size: 3}) + } + case p.startsWith(`\subsubsection`): + flush() + p.pos += len(`\subsubsection`) + if title, ok := p.readOptionalStarAndGroup(); ok { + blocks = append(blocks, Block{Type: "heading", Text: parseInline(title), Size: 4}) + } + case p.startsWith(`\item`): + flush() + text.WriteString("• ") + p.pos += len(`\item`) + for p.pos < len(p.src) && (p.src[p.pos] == ' ' || p.src[p.pos] == '\t') { + p.pos++ + } + case p.startsWith(`\begin{`), p.startsWith(`\end{`): + environment, after, ok := readEnvironment(p.src, p.pos) + if ok && wrapperEnvironments[environment] { + flush() + p.pos = after + } else { + text.WriteByte(p.src[p.pos]) + p.pos++ + } + case p.startsWith(`\par`), p.startsWith(`\\`): + flush() + if p.startsWith(`\par`) { + p.pos += len(`\par`) + } else { + p.pos += 2 + } + case p.src[p.pos] == '\n' && p.blankLineAhead(): + flush() + for !p.eof() && (p.src[p.pos] == '\n' || p.src[p.pos] == ' ' || p.src[p.pos] == '\t' || p.src[p.pos] == '\r') { + p.pos++ + } + default: + text.WriteByte(p.src[p.pos]) + p.pos++ + } + } + flush() + return blocks +} + +// blankLineAhead reports whether the newline at the current position is +// followed by a blank line (two or more newlines, spaces allowed between). +func (p *parser) blankLineAhead() bool { + index := p.pos + 1 + if index < len(p.src) && p.src[index] == '\r' { + index++ + } + for index < len(p.src) && (p.src[index] == ' ' || p.src[index] == '\t') { + index++ + } + return index < len(p.src) && p.src[index] == '\n' +} + +func (p *parser) readUntil(terminator string) string { + index := strings.Index(p.src[p.pos:], terminator) + if index < 0 { + result := p.src[p.pos:] + p.pos = len(p.src) + return result + } + result := p.src[p.pos : p.pos+index] + p.pos += index + len(terminator) + return result +} + +// readOptionalStarAndGroup consumes an optional star and the following +// {group}; spaces are allowed before the brace. +func (p *parser) readOptionalStarAndGroup() (string, bool) { + if p.pos < len(p.src) && p.src[p.pos] == '*' { + p.pos++ + } + return p.readGroup() +} + +// readGroup reads a balanced {group} at the current position, skipping +// leading spaces. +func (p *parser) readGroup() (string, bool) { + for p.pos < len(p.src) && (p.src[p.pos] == ' ' || p.src[p.pos] == '\t') { + p.pos++ + } + if p.pos >= len(p.src) || p.src[p.pos] != '{' { + return "", false + } + depth := 0 + start := p.pos + 1 + for index := p.pos; index < len(p.src); index++ { + switch p.src[index] { + case '\\': + index++ // skip the escaped character + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + result := p.src[start:index] + p.pos = index + 1 + return result, true + } + } + } + return "", false +} + +// parseInline converts a text-mode fragment into a RichText value: a plain +// string when nothing needs formatting, or a slice of strings and inline +// nodes otherwise. +func parseInline(source string) any { + var parts []any + var text strings.Builder + flush := func() { + if text.Len() > 0 { + parts = append(parts, text.String()) + text.Reset() + } + } + appendParsed := func(value string) { + flush() + switch parsed := parseInline(value).(type) { + case string: + if parsed != "" { + parts = append(parts, parsed) + } + case []any: + parts = append(parts, parsed...) + } + } + + for index := 0; index < len(source); { + char := source[index] + switch { + case char == '\\': + if index+1 >= len(source) { + text.WriteByte(char) + index++ + continue + } + switch source[index+1] { + case '&', '%', '$', '#', '_', '{', '}': + text.WriteByte(source[index+1]) + index += 2 + continue + case '\\': + text.WriteByte('\n') + index += 2 + continue + case '(': + end := strings.Index(source[index+2:], `\)`) + if end >= 0 { + expression := source[index+2 : index+2+end] + if isSymbolsOnly(expression) { + text.WriteString(strings.TrimSpace(expression)) + } else { + flush() + parts = append(parts, Inline{Type: "mathematical_expression", Expression: expression}) + } + index += 2 + end + 2 + continue + } + } + name, next := readCommand(source, index) + if name == "" { + text.WriteByte(char) + index++ + continue + } + arg, after, hasArg := readGroupAt(source, next) + switch name { + case "textbf": + if hasArg { + flush() + parts = append(parts, Inline{Type: "bold", Text: parseInline(arg)}) + index = after + continue + } + case "textit", "emph": + if hasArg { + flush() + parts = append(parts, Inline{Type: "italic", Text: parseInline(arg)}) + index = after + continue + } + case "underline", "uline": + if hasArg { + flush() + parts = append(parts, Inline{Type: "underline", Text: parseInline(arg)}) + index = after + continue + } + case "texttt": + if hasArg { + flush() + parts = append(parts, Inline{Type: "code", Text: parseInline(arg)}) + index = after + continue + } + case "textsuperscript": + if hasArg { + flush() + parts = append(parts, Inline{Type: "superscript", Text: parseInline(arg)}) + index = after + continue + } + case "textsubscript": + if hasArg { + flush() + parts = append(parts, Inline{Type: "subscript", Text: parseInline(arg)}) + index = after + continue + } + case "textrm", "textsf", "textnormal", "textup", "textmd", "text": + if hasArg { + appendParsed(arg) + index = after + continue + } + case "textcolor": + if _, afterColor, ok := readGroupAt(source, next); ok { + if value, afterValue, ok := readGroupAt(source, afterColor); ok { + appendParsed(value) + index = afterValue + continue + } + } + case "href": + if url, afterURL, ok := readGroupAt(source, next); ok { + if label, afterLabel, ok := readGroupAt(source, afterURL); ok { + flush() + parts = append(parts, Inline{Type: "url", Text: parseInline(label), URL: url}) + index = afterLabel + continue + } + } + case "hyperlink", "hypertarget": + // Telegram rich text has no named-anchor equivalent. Keep + // the visible label and discard the internal PDF target; the + // PNG/PDF renderer preserves the actual hyperref command. + if _, afterTarget, ok := readGroupAt(source, next); ok { + if label, afterLabel, ok := readGroupAt(source, afterTarget); ok { + appendParsed(label) + index = afterLabel + continue + } + } + case "url": + if hasArg { + flush() + parts = append(parts, Inline{Type: "url", Text: arg, URL: arg}) + index = after + continue + } + case "item": + text.WriteString("• ") + index = skipEmptyGroup(source, next) + continue + case "quad", "qquad", ",", ";", ":", "!", " ": + text.WriteByte(' ') + index = skipEmptyGroup(source, next) + continue + case "ldots", "dots": + text.WriteString("…") + index = skipEmptyGroup(source, next) + continue + case "LaTeX": + text.WriteString("LaTeX") + index = skipEmptyGroup(source, next) + continue + case "TeX": + text.WriteString("TeX") + index = skipEmptyGroup(source, next) + continue + } + text.WriteString(`\` + name) + index = next + case char == '$': + end := strings.IndexByte(source[index+1:], '$') + if end >= 0 && end > 0 { + expression := source[index+1 : index+1+end] + if isSymbolsOnly(expression) { + // Client math fonts often lack exotic symbols; the + // regular text font renders them everywhere. + text.WriteString(strings.TrimSpace(expression)) + } else { + flush() + parts = append(parts, Inline{Type: "mathematical_expression", Expression: expression}) + } + index += end + 2 + continue + } + text.WriteByte(char) + index++ + case char == '~': + text.WriteByte(' ') + index++ + default: + text.WriteByte(char) + index++ + } + } + flush() + + if len(parts) == 1 { + if value, ok := parts[0].(string); ok { + return value + } + } + if len(parts) == 0 { + return "" + } + return parts +} + +// skipEmptyGroup skips an empty {} group immediately following a control +// word, as in \LaTeX{}. +func skipEmptyGroup(source string, index int) int { + if strings.HasPrefix(source[index:], "{}") { + return index + 2 + } + return index +} + +// readCommand reads a backslash command name at position index. +func readCommand(source string, index int) (string, int) { + next := index + 1 + name := "" + for next < len(source) && isCommandLetter(source[next]) { + name += string(source[next]) + next++ + } + if name == "" && next < len(source) { + // Single-character commands such as \, or \; . + name = string(source[next]) + next++ + } + return name, next +} + +// readGroupAt reads a balanced {group} starting at position index, skipping +// spaces. +func readGroupAt(source string, index int) (string, int, bool) { + for index < len(source) && (source[index] == ' ' || source[index] == '\t') { + index++ + } + if index >= len(source) || source[index] != '{' { + return "", index, false + } + depth := 0 + start := index + 1 + for position := index; position < len(source); position++ { + switch source[position] { + case '\\': + position++ // skip the escaped character + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return source[start:position], position + 1, true + } + } + } + return "", index, false +} diff --git a/internal/rich/latex_test.go b/internal/rich/latex_test.go new file mode 100644 index 0000000..035027c --- /dev/null +++ b/internal/rich/latex_test.go @@ -0,0 +1,404 @@ +package rich + +import ( + "encoding/json" + "testing" +) + +func TestDocumentPureMath(t *testing.T) { + message := Document(`\int_0^\infty e^{-x^2}\,dx`, true) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "mathematical_expression" { + t.Fatalf("blocks = %+v", message.Blocks) + } + if message.Blocks[0].Expression != `\int_0^\infty e^{-x^2}\,dx` { + t.Fatalf("expression = %q", message.Blocks[0].Expression) + } +} + +func TestDocumentDollarMath(t *testing.T) { + message := Document(`$F=ma$`, true) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" { + t.Fatalf("blocks = %+v", message.Blocks) + } +} + +func TestDocumentDisplayMath(t *testing.T) { + message := Document("before\n\n$$F=ma$$\n\nafter", true) + if len(message.Blocks) != 3 { + t.Fatalf("blocks = %+v", message.Blocks) + } + if message.Blocks[0].Type != "paragraph" || message.Blocks[1].Type != "mathematical_expression" || message.Blocks[2].Type != "paragraph" { + t.Fatalf("block types = %s/%s/%s", message.Blocks[0].Type, message.Blocks[1].Type, message.Blocks[2].Type) + } + if message.Blocks[1].Expression != "F=ma" { + t.Fatalf("expression = %q", message.Blocks[1].Expression) + } +} + +func TestDocumentTextFormatting(t *testing.T) { + message := Document(`Hello \textbf{world} and \emph{italics}`, true) + if len(message.Blocks) != 1 { + t.Fatalf("blocks = %+v", message.Blocks) + } + parts, ok := message.Blocks[0].Text.([]any) + if !ok || len(parts) != 4 { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } + if parts[0] != "Hello " { + t.Errorf("parts[0] = %#v", parts[0]) + } + bold, ok := parts[1].(Inline) + if !ok || bold.Type != "bold" || bold.Text != "world" { + t.Errorf("parts[1] = %#v", parts[1]) + } + if parts[2] != " and " { + t.Errorf("parts[2] = %#v", parts[2]) + } + italic, ok := parts[3].(Inline) + if !ok || italic.Type != "italic" || italic.Text != "italics" { + t.Errorf("parts[3] = %#v", parts[3]) + } +} + +func TestDocumentNestedFormatting(t *testing.T) { + message := Document(`\textbf{bold \emph{and italic}}`, true) + parts, ok := message.Blocks[0].Text.([]any) + if !ok || len(parts) != 1 { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } + bold, ok := parts[0].(Inline) + if !ok || bold.Type != "bold" { + t.Fatalf("parts[0] = %#v", parts[0]) + } + inner, ok := bold.Text.([]any) + if !ok || len(inner) != 2 { + t.Fatalf("bold text = %#v", bold.Text) + } + italic, ok := inner[1].(Inline) + if !ok || italic.Type != "italic" || italic.Text != "and italic" { + t.Fatalf("inner = %#v", inner[1]) + } +} + +func TestDocumentMixedTextAndMath(t *testing.T) { + message := Document(`The area is $\pi r^2$ exactly`, true) + parts, ok := message.Blocks[0].Text.([]any) + if !ok || len(parts) != 3 { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } + math, ok := parts[1].(Inline) + if !ok || math.Type != "mathematical_expression" || math.Expression != `\pi r^2` { + t.Fatalf("math = %#v", parts[1]) + } +} + +func TestDocumentSections(t *testing.T) { + message := Document("\\section{Intro}\n\nSome text", true) + if len(message.Blocks) != 2 { + t.Fatalf("blocks = %+v", message.Blocks) + } + if message.Blocks[0].Type != "heading" || message.Blocks[0].Size != 2 { + t.Fatalf("heading = %+v", message.Blocks[0]) + } + if message.Blocks[0].Text != "Intro" { + t.Fatalf("heading text = %#v", message.Blocks[0].Text) + } +} + +func TestDocumentList(t *testing.T) { + message := Document("\\begin{itemize}\n\\item one\n\\item two\n\\end{itemize}", true) + if len(message.Blocks) != 2 { + t.Fatalf("blocks = %+v", message.Blocks) + } + if message.Blocks[0].Text != "• one" || message.Blocks[1].Text != "• two" { + t.Fatalf("items = %#v / %#v", message.Blocks[0].Text, message.Blocks[1].Text) + } +} + +func TestDocumentEscapesAndCommands(t *testing.T) { + message := Document(`100\% \& more \LaTeX{} \url{https://example.com}`, true) + parts, ok := message.Blocks[0].Text.([]any) + if !ok { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } + joined := "" + for _, part := range parts { + if text, ok := part.(string); ok { + joined += text + } + } + if joined != "100% & more LaTeX " { + t.Fatalf("joined = %q", joined) + } + url, ok := parts[len(parts)-1].(Inline) + if !ok || url.Type != "url" || url.URL != "https://example.com" { + t.Fatalf("url = %#v", parts[len(parts)-1]) + } +} + +func TestDocumentHyperrefCommands(t *testing.T) { + message := Document(`See \hyperlink{details}{the \textbf{details}}.`, true) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" { + t.Fatalf("blocks = %+v", message.Blocks) + } + parts, ok := message.Blocks[0].Text.([]any) + if !ok || len(parts) != 4 { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } + if parts[0] != "See " || parts[1] != "the " || parts[3] != "." { + t.Fatalf("parts = %#v", parts) + } + bold, ok := parts[2].(Inline) + if !ok || bold.Type != "bold" || bold.Text != "details" { + t.Fatalf("bold = %#v", parts[2]) + } + + target := Document(`\hypertarget{details}{Details}`, true) + if len(target.Blocks) != 1 || target.Blocks[0].Type != "paragraph" || target.Blocks[0].Text != "Details" { + t.Fatalf("target blocks = %+v", target.Blocks) + } +} + +func TestDocumentFullDocumentBody(t *testing.T) { + source := "\\documentclass{article}\n\\begin{document}\nHello \\textbf{world}\n\\end{document}" + message := Document(source, true) + if len(message.Blocks) != 1 { + t.Fatalf("blocks = %+v", message.Blocks) + } + if message.Blocks[0].Type != "paragraph" { + t.Fatalf("block = %+v", message.Blocks[0]) + } +} + +func TestInlineDocumentLeadingMath(t *testing.T) { + message := InlineDocument(`$F=ma$`, true) + if len(message.Blocks) != 1 { + t.Fatalf("blocks = %+v", message.Blocks) + } + parts, ok := message.Blocks[0].Text.([]any) + if !ok || len(parts) != 2 || parts[0] != " " { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } + math, ok := parts[1].(Inline) + if !ok || math.Type != "mathematical_expression" || math.Expression != "F=ma" { + t.Fatalf("math = %#v", parts[1]) + } +} + +func TestInlineDocumentDisplayMath(t *testing.T) { + message := InlineDocument("$$F=ma$$", true) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" { + t.Fatalf("blocks = %+v", message.Blocks) + } + parts, ok := message.Blocks[0].Text.([]any) + if !ok || len(parts) != 2 || parts[0] != " " { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } +} + +func TestInlineDocumentLeadingParagraph(t *testing.T) { + message := InlineDocument(`Hello \textbf{world}`, true) + parts, ok := message.Blocks[0].Text.([]any) + if !ok || len(parts) == 0 || parts[0] != " " { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } +} + +func TestDocumentJSONMarshal(t *testing.T) { + data, err := json.Marshal(Document(`Hello \textbf{world}`, true)) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + expected := `{"blocks":[{"type":"paragraph","text":["Hello ",{"type":"bold","text":"world"}]}]}` + if string(data) != expected { + t.Fatalf("unexpected JSON:\n got %s\nwant %s", data, expected) + } +} + +// blockText returns the math expression or the paragraph text of a block. +func blockText(block Block) string { + if block.Expression != "" { + return block.Expression + } + if text, ok := block.Text.(string); ok { + return text + } + return "" +} + +func TestDocumentSymbolCommands(t *testing.T) { + // A lone symbol renders as text: client math fonts often lack exotic + // symbols, while the regular text font has them. + message := Document(`\heartsuit`, true) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" { + t.Fatalf("blocks = %+v", message.Blocks) + } + if blockText(message.Blocks[0]) != "♡" { + t.Fatalf("text = %q", blockText(message.Blocks[0])) + } + + // With surrounding letters it stays a math expression. + message = Document(`I \heartsuit{} you`, true) + if message.Blocks[0].Type != "mathematical_expression" || blockText(message.Blocks[0]) != "I ♡ you" { + t.Fatalf("blocks = %+v", message.Blocks) + } + + // Standard math commands are left to the client engine, which renders + // them natively (Unicode replacements are not drawn by every font). + message = Document(`\int_0^\infty e^{-x^2}\,dx`, true) + if blockText(message.Blocks[0]) != `\int_0^\infty e^{-x^2}\,dx` { + t.Fatalf("expression = %q", blockText(message.Blocks[0])) + } + + for source, want := range map[string]string{ + `\alpha + \beta = \gamma`: `\alpha + \beta = \gamma`, + `\sum_{i=1}^n i`: `\sum_{i=1}^n i`, + `a \leq b \to c`: `a \leq b \to c`, + `\mathbb{R}^n`: `\mathbb{R}^n`, + `\mathcal{L}(f)`: `\mathcal{L}(f)`, + `\mathfrak{g} \otimes \mathfrak{h}`: `\mathfrak{g} \otimes \mathfrak{h}`, + `\mathbf{v} \cdot \nabla f`: `\mathbf{v} \cdot \nabla f`, + `\heartsuit`: "♡", + } { + message := Document(source, true) + if got := blockText(message.Blocks[0]); got != want { + t.Errorf("source %q: got %q, want %q", source, got, want) + } + } +} + +func TestDocumentIntegralCommandPreserved(t *testing.T) { + source := `$$\int_0^\infty e^{-x^{porcodio}}\,dx$$` + message := Document(source, true) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "mathematical_expression" { + t.Fatalf("blocks = %+v", message.Blocks) + } + if got := message.Blocks[0].Expression; got != `\int_0^\infty e^{-x^{porcodio}}\,dx` { + t.Fatalf("expression = %q, integral command must stay for the client engine", got) + } +} + +func TestDocumentSymbolMathSegments(t *testing.T) { + // A math segment containing only symbols is hoisted to text: the math + // font does not carry exotic glyphs, the regular font does. + for _, source := range []string{`$\heartsuit$`, `$$\heartsuit$$`, `\( \heartsuit \)`, `\[ \heartsuit \]`} { + message := Document(source, true) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" { + t.Errorf("source %q: blocks = %+v", source, message.Blocks) + continue + } + if got := blockText(message.Blocks[0]); got != "♡" { + t.Errorf("source %q: text = %q, want ♡", source, got) + } + } + + // Symbols mixed with real math stay in a math node. + message := Document(`$x \heartsuit y$`, true) + if message.Blocks[0].Type != "paragraph" { + t.Fatalf("blocks = %+v", message.Blocks) + } + parts, ok := message.Blocks[0].Text.([]any) + if !ok || len(parts) != 1 { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } + math, ok := parts[0].(Inline) + if !ok || math.Type != "mathematical_expression" || math.Expression != "x ♡ y" { + t.Fatalf("math = %#v", parts[0]) + } +} + +func TestIsMathExpression(t *testing.T) { + for source, want := range map[string]bool{ + `\heartsuit`: true, + `\int_0^\infty x`: true, + `F = ma`: true, + `$F=ma$`: true, + `\begin{matrix} a & b \end{matrix}`: true, + `Hello \textbf{x}`: false, + `\hyperlink{x}{go}`: false, + `\begin{itemize} \item a`: false, + `\begin{itemize} \item $x$`: false, + } { + if got := IsMathExpression(source); got != want { + t.Errorf("IsMathExpression(%q) = %v, want %v", source, got, want) + } + } +} + +func TestContainsMathEnvironment(t *testing.T) { + for source, want := range map[string]bool{ + `\begin{align} a &= b \end{align}`: true, + `\begin{matrix} a & b \end{matrix}`: true, + `\begin{itemize} a`: false, + `x^2`: false, + } { + if got := ContainsMathEnvironment(source); got != want { + t.Errorf("ContainsMathEnvironment(%q) = %v, want %v", source, got, want) + } + } +} + +func TestDocumentAlphabetFallback(t *testing.T) { + // A font command with non-alphanumeric content is left to the engine. + message := Document(`\mathbb{R_1}`, true) + if message.Blocks[0].Expression != `\mathbb{R_1}` { + t.Fatalf("expression = %q", message.Blocks[0].Expression) + } +} + +func TestPureMathKeepsPlainText(t *testing.T) { + message := Document("F = ma", true) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "mathematical_expression" { + t.Fatalf("blocks = %+v", message.Blocks) + } +} + +func TestDocumentTextDefault(t *testing.T) { + // With defaultMath disabled a bare source is text, not math. + message := Document("F = ma", false) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" { + t.Fatalf("blocks = %+v", message.Blocks) + } + if message.Blocks[0].Text != "F = ma" { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } + + // Delimited math still renders as math. + message = Document("$F = ma$", false) + parts, ok := message.Blocks[0].Text.([]any) + if !ok || len(parts) != 1 { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } + if math, ok := parts[0].(Inline); !ok || math.Type != "mathematical_expression" { + t.Fatalf("parts[0] = %#v", parts[0]) + } + + // Text-mode commands still work. + message = Document(`Hello \textbf{world}`, false) + if _, ok := message.Blocks[0].Text.([]any); !ok { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } +} + +func TestInlineDocumentTextDefault(t *testing.T) { + message := InlineDocument("F = ma", false) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" { + t.Fatalf("blocks = %+v", message.Blocks) + } + if message.Blocks[0].Text != " F = ma" { + t.Fatalf("text = %#v", message.Blocks[0].Text) + } +} + +func TestDocumentBareMathCommands(t *testing.T) { + for _, source := range []string{ + `\frac{1}{2}`, + `\begin{align} a &= b \\ c &= d \end{align}`, + `x = \text{hello}`, + } { + message := Document(source, true) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "mathematical_expression" { + t.Errorf("source %q: blocks = %+v", source, message.Blocks) + } + } +} diff --git a/internal/rich/rich.go b/internal/rich/rich.go new file mode 100644 index 0000000..ef2c6c1 --- /dev/null +++ b/internal/rich/rich.go @@ -0,0 +1,28 @@ +// Package rich builds rich-message payloads for the Bot API 10.x rich +// message endpoints (sendRichMessage and the InputRichMessageContent used in +// inline answers). The pinned telegram-bot-api library predates the rich +// message API, so these are plain JSON-encodable values sent through +// api.MakeRequest. +package rich + +// Message is the payload for InputRichMessage (and structurally for +// RichMessage). Only the blocks representation is used. +type Message struct { + Blocks []Block `json:"blocks"` +} + +// Block is a RichBlock/InputRichBlock. +type Block struct { + Type string `json:"type"` + Text any `json:"text,omitempty"` + Expression string `json:"expression,omitempty"` + Size int `json:"size,omitempty"` +} + +// Inline is a RichText inline node (mathematical_expression, bold, url...). +type Inline struct { + Type string `json:"type"` + Text any `json:"text,omitempty"` + Expression string `json:"expression,omitempty"` + URL string `json:"url,omitempty"` +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..8adec24 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,296 @@ +// Package store persists settings, users, group memberships and render +// statistics in the shared MongoDB cluster. +package store + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.mongodb.org/mongo-driver/mongo/readpref" +) + +// Settings are the per-chat overrides. Zero values mean "use the default". +type Settings struct { + Preamble string `json:"preamble,omitempty" bson:"preamble,omitempty"` + DPI int `json:"dpi,omitempty" bson:"dpi,omitempty"` +} + +// User is the caller information recorded on every interaction. +type User struct { + ID int64 + Username string + FirstName string + LastName string +} + +// Chat is the group information recorded on membership changes. +type Chat struct { + ID int64 + Type string + Title string + Username string +} + +// RenderStat is one render event, kept for statistics. +type RenderStat struct { + Source string // chat | inline + Format string // rich | png | pdf | inline + ChatID int64 + UserID int64 + DPI int + ExpressionLen int + OK bool + Error string + DurationMs int64 +} + +// Store provides all persistence for the bot. +type Store struct { + client *mongo.Client + settings *mongo.Collection + users *mongo.Collection + groups *mongo.Collection + stats *mongo.Collection + files *mongo.Collection +} + +// New connects a Store to the given database. +func New(client *mongo.Client, database string) *Store { + db := client.Database(database) + return &Store{ + client: client, + settings: db.Collection("settings"), + users: db.Collection("users"), + groups: db.Collection("groups"), + stats: db.Collection("stats"), + files: db.Collection("files"), + } +} + +// Ping reports whether the cluster is reachable. +func (s *Store) Ping(ctx context.Context) error { + return s.client.Ping(ctx, readpref.PrimaryPreferred()) +} + +// EnsureIndexes creates the indexes the bot relies on. +func (s *Store) EnsureIndexes(ctx context.Context) error { + _, err := s.stats.Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "ts", Value: -1}}, Options: options.Index().SetName("stats_ts")}, + {Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "ts", Value: -1}}, Options: options.Index().SetName("stats_user_ts")}, + }) + return err +} + +// Get returns the settings for chatID, or the zero value. +func (s *Store) Get(chatID int64) Settings { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var settings Settings + err := s.settings.FindOne(ctx, bson.M{"_id": chatID}).Decode(&settings) + if err != nil { + if err != mongo.ErrNoDocuments { + log.Printf("store: read settings for %d: %v", chatID, err) + } + return Settings{} + } + return settings +} + +// Set stores settings for chatID. +func (s *Store) Set(chatID int64, settings Settings) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := s.settings.UpdateOne(ctx, + bson.M{"_id": chatID}, + bson.M{"$set": settings, "$setOnInsert": bson.M{"created_at": time.Now().UTC()}}, + options.Update().SetUpsert(true), + ) + if err != nil { + return fmt.Errorf("store: write settings for %d: %w", chatID, err) + } + return nil +} + +// RecordUser upserts the interacting user and refreshes last_seen_at. +func (s *Store) RecordUser(user User) { + if user.ID == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + now := time.Now().UTC() + set := bson.M{"last_seen_at": now} + for key, value := range map[string]string{ + "username": user.Username, "first_name": user.FirstName, "last_name": user.LastName, + } { + if strings.TrimSpace(value) != "" { + set[key] = value + } + } + _, err := s.users.UpdateOne(ctx, + bson.M{"_id": user.ID}, + bson.M{"$set": set, "$setOnInsert": bson.M{"first_seen_at": now, "renders": int64(0)}}, + options.Update().SetUpsert(true), + ) + if err != nil { + log.Printf("store: record user %d: %v", user.ID, err) + } +} + +// RecordGroup upserts a chat the bot is a member of (or left). +func (s *Store) RecordGroup(chat Chat, member bool) { + if chat.ID == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + now := time.Now().UTC() + status := "left" + if member { + status = "member" + } + set := bson.M{"status": status, "updated_at": now} + for key, value := range map[string]string{ + "type": chat.Type, "title": chat.Title, "username": chat.Username, + } { + if strings.TrimSpace(value) != "" { + set[key] = value + } + } + _, err := s.groups.UpdateOne(ctx, + bson.M{"_id": chat.ID}, + bson.M{"$set": set, "$setOnInsert": bson.M{"added_at": now}}, + options.Update().SetUpsert(true), + ) + if err != nil { + log.Printf("store: record group %d: %v", chat.ID, err) + } +} + +// Stats are the aggregate numbers reported by the /stats command. +type Stats struct { + Renders int64 + ChatRenders int64 + InlineRenders int64 + FailedRenders int64 + Renders24h int64 + Users int64 + Groups int64 +} + +// Stats computes the aggregate counts shown by /stats. Counting is cheap +// enough for a command that is used rarely. +func (s *Store) Stats() Stats { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var stats Stats + if count, err := s.stats.CountDocuments(ctx, bson.M{}); err == nil { + stats.Renders = count + } else { + log.Printf("store: count renders: %v", err) + } + if count, err := s.stats.CountDocuments(ctx, bson.M{"source": "chat"}); err == nil { + stats.ChatRenders = count + } + if count, err := s.stats.CountDocuments(ctx, bson.M{"source": "inline"}); err == nil { + stats.InlineRenders = count + } + if count, err := s.stats.CountDocuments(ctx, bson.M{"ok": false}); err == nil { + stats.FailedRenders = count + } + if count, err := s.stats.CountDocuments(ctx, bson.M{"ts": bson.M{"$gte": time.Now().UTC().Add(-24 * time.Hour)}}); err == nil { + stats.Renders24h = count + } + if count, err := s.users.CountDocuments(ctx, bson.M{}); err == nil { + stats.Users = count + } + if count, err := s.groups.CountDocuments(ctx, bson.M{"status": "member"}); err == nil { + stats.Groups = count + } + return stats +} + +// RecordRender appends a render event to the statistics collection. +func (s *Store) RecordRender(stat RenderStat) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := s.stats.InsertOne(ctx, bson.M{ + "ts": time.Now().UTC(), + "source": stat.Source, + "format": stat.Format, + "chat_id": stat.ChatID, + "user_id": stat.UserID, + "dpi": stat.DPI, + "expression_len": stat.ExpressionLen, + "ok": stat.OK, + "error": stat.Error, + "duration_ms": stat.DurationMs, + }) + if err != nil { + log.Printf("store: record render stat: %v", err) + } +} + +// FileRefs are correctly typed Telegram file_ids captured from normal chat +// sends. They can be reused in inline results without another upload. +type FileRefs struct { + PhotoID string + DocID string +} + +// FileRefs returns cached Telegram references for a rendered content hash. +func (s *Store) FileRefs(hash string) FileRefs { + if hash == "" { + return FileRefs{} + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var doc struct { + PhotoFileID string `bson:"photo_file_id"` + DocFileID string `bson:"document_file_id"` + } + err := s.files.FindOne(ctx, bson.M{"_id": hash}).Decode(&doc) + if err != nil { + if err != mongo.ErrNoDocuments { + log.Printf("store: read file refs for %s: %v", hash, err) + } + return FileRefs{} + } + return FileRefs{PhotoID: doc.PhotoFileID, DocID: doc.DocFileID} +} + +// SavePhotoID caches a Photo-type file_id for a content hash. +func (s *Store) SavePhotoID(hash, fileID string) { + s.saveFileID(hash, "photo_file_id", fileID) +} + +// SaveDocID caches a Document-type file_id for a content hash. +func (s *Store) SaveDocID(hash, fileID string) { + s.saveFileID(hash, "document_file_id", fileID) +} + +func (s *Store) saveFileID(hash, field, fileID string) { + if hash == "" || fileID == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + now := time.Now().UTC() + _, err := s.files.UpdateOne(ctx, + bson.M{"_id": hash}, + bson.M{ + "$set": bson.M{field: fileID, "updated_at": now}, + "$setOnInsert": bson.M{"created_at": now}, + }, + options.Update().SetUpsert(true), + ) + if err != nil { + log.Printf("store: save %s for %s: %v", field, hash, err) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..1d3a3e2 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,55 @@ +package store + +import ( + "context" + "os" + "testing" + "time" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// integrationStore connects to the cluster from MONGODB_URI (integration +// tests only; CI runs without a database and skips them). +func integrationStore(t *testing.T) *Store { + t.Helper() + uri := os.Getenv("MONGODB_URI") + if uri == "" { + t.Skip("MONGODB_URI not set; skipping MongoDB integration test") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = client.Disconnect(context.Background()) }) + if err := client.Ping(ctx, nil); err != nil { + t.Skipf("MongoDB not reachable, skipping integration test: %v", err) + } + return New(client, "latexgram_test_"+time.Now().Format("20060102150405")) +} + +func TestSettingsRoundTrip(t *testing.T) { + s := integrationStore(t) + if got := s.Get(42); got != (Settings{}) { + t.Fatalf("unexpected initial settings: %+v", got) + } + settings := Settings{Preamble: `\usepackage{mhchem}`, DPI: 200} + if err := s.Set(42, settings); err != nil { + t.Fatalf("Set: %v", err) + } + if got := s.Get(42); got != settings { + t.Fatalf("got %+v, want %+v", got, settings) + } +} + +func TestRecordUserAndGroup(t *testing.T) { + s := integrationStore(t) + userID := time.Now().UnixNano() + s.RecordUser(User{ID: userID, Username: "someone", FirstName: "A", LastName: "B"}) + s.RecordUser(User{ID: userID}) + s.RecordGroup(Chat{ID: -1001, Type: "supergroup", Title: "Test", Username: "test"}, true) + s.RecordGroup(Chat{ID: -1001}, false) +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..e8c8689 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,65 @@ +// Package telemetry wires the OpenTelemetry SDK to an OTLP collector +// (OTEL_EXPORTER_OTLP_ENDPOINT). With no endpoint configured the global +// tracer provider stays a no-op. +package telemetry + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// Setup installs the global tracer provider backed by the OTLP collector. +// The returned shutdown flushes pending spans; call it on process exit. +func Setup(ctx context.Context) (shutdown func(context.Context) error, err error) { + endpoint := strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) + if endpoint == "" { + otel.SetTracerProvider(sdktrace.NewTracerProvider()) + return func(context.Context) error { return nil }, nil + } + insecure, parseErr := strconv.ParseBool(getenv("OTEL_EXPORTER_OTLP_INSECURE", "true")) + if parseErr != nil { + return nil, fmt.Errorf("invalid OTEL_EXPORTER_OTLP_INSECURE: %w", parseErr) + } + options := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(endpoint)} + if insecure { + options = append(options, otlptracegrpc.WithInsecure()) + } + exporter, err := otlptracegrpc.New(ctx, options...) + if err != nil { + return nil, fmt.Errorf("create OTLP exporter: %w", err) + } + res, err := resource.New(ctx, + resource.WithFromEnv(), + resource.WithAttributes(attribute.String("service.name", getenv("OTEL_SERVICE_NAME", "latexgrambot"))), + ) + if err != nil { + return nil, fmt.Errorf("create resource: %w", err) + } + provider := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(provider) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + return provider.Shutdown, nil +} + +func getenv(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..b24afb9 --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,48 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: latexgrambot + namespace: latexgrambot + labels: + app.kubernetes.io/name: latexgrambot +data: + HEALTH_ADDR: ":8080" + + # MongoDB is the only hard dependency: per-chat settings, users, group + # memberships, render statistics and the inline file-id cache live there. + MONGODB_URI: "mongodb://mongo:27017" + MONGODB_DATABASE: "latexgram" + + # Private S3-compatible bucket for the inline PNG/PDF assets. Telegram + # receives short-lived presigned GET URLs, so the public endpoint must be + # reachable from the internet. Credentials come from the + # latexgrambot-s3 secret. Leave all S3 values empty to disable the inline + # picture/PDF results (rich text still works). + S3_ENDPOINT_URL: "http://minio:9000" + S3_REGION: "us-east-1" + S3_BUCKET: "latexgrambot" + S3_PREFIX: "content" + S3_PUBLIC_ENDPOINT_URL: "https://s3.example.com" + S3_PRESIGN_TTL: "1h" + + # Preloaded TeX format shipped in the image: skips re-loading the default + # package set on every render. + LATEX_FORMAT: "latexgrambot" + + RENDER_DPI: "600" + RENDER_TIMEOUT: "30s" + RENDER_MAX_CONCURRENCY: "2" + MAX_EXPRESSION_LEN: "4000" + MAX_PREAMBLE_LEN: "4000" + + # Rich message replies (Bot API 10.x). Set to false for older Bot API + # servers; chat and inline fall back to PNG/PDF only. + RICH_TEXT_ENABLED: "true" + # A bare source without delimiters/text commands renders as math (true) + # or as text (false, formulas then need $...$). + RICH_DEFAULT_MATH: "true" + + # OpenTelemetry traces (OTLP gRPC). Leave the endpoint empty to disable. + OTEL_EXPORTER_OTLP_ENDPOINT: "" + OTEL_EXPORTER_OTLP_INSECURE: "true" + OTEL_SERVICE_NAME: "latexgrambot" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..898f82c --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: latexgrambot + namespace: latexgrambot + labels: + app.kubernetes.io/name: latexgrambot +spec: + # Exactly one replica: the bot long-polls Telegram (getUpdates) and + # Telegram allows a single consumer per bot token. + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: latexgrambot + template: + metadata: + labels: + app.kubernetes.io/name: latexgrambot + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: latexgrambot + # Replace with the image built by CI, e.g. your registry plus the + # commit SHA tag. + image: latexgrambot:latest + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: latexgrambot + - secretRef: + name: latexgrambot-secrets + - secretRef: + name: latexgrambot-s3 + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 15 + periodSeconds: 30 + # pdflatex is CPU-bound; the memory limit has to cover a TeX Live + # run (a few hundred MB per concurrent render). + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "2" + memory: 1Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL diff --git a/k8s/secret.example.yaml b/k8s/secret.example.yaml new file mode 100644 index 0000000..65db6ae --- /dev/null +++ b/k8s/secret.example.yaml @@ -0,0 +1,31 @@ +# Copy to a private file, fill in the values and apply it manually: +# kubectl apply -f latexgrambot-secrets.yaml +# +# Two secrets are used so the S3 credentials can be managed separately from +# the Telegram token. +--- +apiVersion: v1 +kind: Secret +metadata: + name: latexgrambot-secrets + namespace: latexgrambot + labels: + app.kubernetes.io/name: latexgrambot +type: Opaque +stringData: + # The token must not be long-polled by any other process (Telegram + # delivers updates to a single consumer per token). + TELEGRAM_BOT_TOKEN: "replace-with-bot-token" +--- +apiVersion: v1 +kind: Secret +metadata: + name: latexgrambot-s3 + namespace: latexgrambot + labels: + app.kubernetes.io/name: latexgrambot +type: Opaque +stringData: + # Credentials of an S3-compatible bucket (read/write on S3_BUCKET). + AWS_ACCESS_KEY_ID: "replace-with-s3-access-key" + AWS_SECRET_ACCESS_KEY: "replace-with-s3-secret-key" diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..5a2bfbc --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: latexgrambot + namespace: latexgrambot + labels: + app.kubernetes.io/name: latexgrambot +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: latexgrambot + ports: + - name: http + port: 8080 + targetPort: http