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
This commit is contained in:
corrado.mulas
2026-10-04 03:38:08 +02:00
commit 98a3614d58
29 changed files with 5140 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
.cache
README.md
k8s
argocd-application.yaml

54
.github/workflows/ci.yml vendored Normal file
View File

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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*/secret.yaml
.cache

39
Dockerfile Normal file
View File

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

29
LICENSE Normal file
View File

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

305
README.md Normal file
View File

@@ -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 <expression>` 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: `<S3_PREFIX>/<uuidv4>.jpg` and `<S3_PREFIX>/<uuidv4>.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).

41
argocd-application.yaml Normal file
View File

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

177
cmd/latexgrambot/main.go Normal file
View File

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

37
docker-compose.yml Normal file
View File

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

62
go.mod Normal file
View File

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

139
go.sum Normal file
View File

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

882
internal/bot/bot.go Normal file
View File

@@ -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 <LaTeX code>", 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 <LaTeX packages and definitions>\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 <code> — reply with the rich text message (default)\n"+
"- /image <code> — reply with the PNG picture\n"+
"- /pdf <code> — reply with the PDF document\n"+
"- Inline mode: in any chat, type @%s <expression> 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 <code> — 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)
}

269
internal/bot/bot_test.go Normal file
View File

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

192
internal/config/config.go Normal file
View File

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

View File

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

145
internal/files/files.go Normal file
View File

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

View File

@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>latexgrambot</Name><Prefix>content/</Prefix><KeyCount>2</KeyCount><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated>
<Contents><Key>content/old.jpg</Key><LastModified>2000-01-01T00:00:00.000Z</LastModified><ETag>"a"</ETag><Size>1</Size><StorageClass>STANDARD</StorageClass></Contents>
<Contents><Key>content/fresh.pdf</Key><LastModified>2099-01-01T00:00:00.000Z</LastModified><ETag>"b"</ETag><Size>1</Size><StorageClass>STANDARD</StorageClass></Contents>
</ListBucketResult>`
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)
}

446
internal/render/render.go Normal file
View File

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

View File

@@ -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")
}
}

742
internal/rich/latex.go Normal file
View File

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

404
internal/rich/latex_test.go Normal file
View File

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

28
internal/rich/rich.go Normal file
View File

@@ -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"`
}

296
internal/store/store.go Normal file
View File

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

View File

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

View File

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

48
k8s/configmap.yaml Normal file
View File

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

70
k8s/deployment.yaml Normal file
View File

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

31
k8s/secret.example.yaml Normal file
View File

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

15
k8s/service.yaml Normal file
View File

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