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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user