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:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.cache
|
||||||
|
README.md
|
||||||
|
k8s
|
||||||
|
argocd-application.yaml
|
||||||
54
.github/workflows/ci.yml
vendored
Normal file
54
.github/workflows/ci.yml
vendored
Normal 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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*/secret.yaml
|
||||||
|
.cache
|
||||||
39
Dockerfile
Normal file
39
Dockerfile
Normal 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
29
LICENSE
Normal 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
305
README.md
Normal 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
41
argocd-application.yaml
Normal 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
177
cmd/latexgrambot/main.go
Normal 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
37
docker-compose.yml
Normal 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
62
go.mod
Normal 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
139
go.sum
Normal 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
882
internal/bot/bot.go
Normal 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
269
internal/bot/bot_test.go
Normal 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
192
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
143
internal/config/config_test.go
Normal file
143
internal/config/config_test.go
Normal 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
145
internal/files/files.go
Normal 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
|
||||||
|
}
|
||||||
176
internal/files/files_test.go
Normal file
176
internal/files/files_test.go
Normal 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
446
internal/render/render.go
Normal 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 ""
|
||||||
|
}
|
||||||
243
internal/render/render_test.go
Normal file
243
internal/render/render_test.go
Normal 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
742
internal/rich/latex.go
Normal 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
404
internal/rich/latex_test.go
Normal 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
28
internal/rich/rich.go
Normal 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
296
internal/store/store.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
internal/store/store_test.go
Normal file
55
internal/store/store_test.go
Normal 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)
|
||||||
|
}
|
||||||
65
internal/telemetry/telemetry.go
Normal file
65
internal/telemetry/telemetry.go
Normal 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
48
k8s/configmap.yaml
Normal 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
70
k8s/deployment.yaml
Normal 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
31
k8s/secret.example.yaml
Normal 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
15
k8s/service.yaml
Normal 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
|
||||||
Reference in New Issue
Block a user