latexgrambot: inline LaTeX bot for Telegram in Go

Renders LaTeX expressions into PNG pictures, PDF documents and native
Telegram rich text messages, entirely locally with TeX Live and poppler.

- Chat mode with /image, /pdf, /rich plus per-user preamble and DPI
- Inline mode delivering picture, PDF and rich text results
- Broad default package set preloaded into a TeX format at image build
- MongoDB persistence: settings, users, groups, render statistics
- Private S3-compatible bucket with short-lived presigned URLs
- OpenTelemetry traces over OTLP
- Kubernetes manifests and Argo CD application, Docker Compose, GitHub
  Actions CI, BSD 3-Clause license
This commit is contained in:
corrado.mulas
2026-10-04 03:38:08 +02:00
commit 98a3614d58
29 changed files with 5140 additions and 0 deletions

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

@@ -0,0 +1,192 @@
// Package config reads the latexgrambot configuration from environment variables.
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
// Config holds every runtime setting.
type Config struct {
TelegramBotToken string
// MongoURI is the shared cluster connection string. MongoDatabase is
// the bot's database inside it.
MongoURI string
MongoDatabase string
// S3 hosts rendered inline files. All required values must be set
// together; otherwise inline mode offers only native rich text.
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKeyID string
S3SecretAccessKey string
S3Prefix string
S3PublicEndpoint string
S3PresignTTL time.Duration
HealthAddr string
// RichTextEnabled controls the rich-message replies (native math block).
// The format needs a recent Bot API server; when disabled the bot falls
// back to PNG and PDF only.
RichTextEnabled bool
// RichDefaultMath controls how the rich format treats a bare source
// (no math delimiters, no text-mode command): true renders it as a math
// expression, false parses it as text so formulas need $...$ delimiters.
RichDefaultMath bool
RenderDPI int
RenderTimeout time.Duration
MaxConcurrency int
// MaxExpressionLen caps chat-mode expressions. MaxPreambleLen caps the
// per-user preamble (Telegram messages are at most 4096 characters).
MaxExpressionLen int
MaxPreambleLen int
PdflatexBin string
PdftoppmBin string
// LatexFormat is the preloaded TeX format name (built from the default
// preamble at image build time). Empty disables it.
LatexFormat string
}
// Load builds the Config and validates the mandatory keys.
func Load() (*Config, error) {
cfg := &Config{
TelegramBotToken: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")),
MongoURI: strings.TrimSpace(os.Getenv("MONGODB_URI")),
MongoDatabase: getenv("MONGODB_DATABASE", "latexgram"),
S3Endpoint: strings.TrimRight(strings.TrimSpace(os.Getenv("S3_ENDPOINT_URL")), "/"),
S3Region: getenv("S3_REGION", "us-east-1"),
S3Bucket: strings.TrimSpace(os.Getenv("S3_BUCKET")),
S3AccessKeyID: strings.TrimSpace(os.Getenv("AWS_ACCESS_KEY_ID")),
S3SecretAccessKey: strings.TrimSpace(os.Getenv("AWS_SECRET_ACCESS_KEY")),
S3Prefix: strings.Trim(getenv("S3_PREFIX", "content"), "/"),
S3PublicEndpoint: strings.TrimRight(strings.TrimSpace(os.Getenv("S3_PUBLIC_ENDPOINT_URL")), "/"),
HealthAddr: getenv("HEALTH_ADDR", ":8080"),
PdflatexBin: getenv("PDFLATEX_BIN", "pdflatex"),
PdftoppmBin: getenv("PDFTOPPM_BIN", "pdftoppm"),
LatexFormat: getenv("LATEX_FORMAT", ""),
}
var err error
if cfg.RichTextEnabled, err = getenvBool("RICH_TEXT_ENABLED", true); err != nil {
return nil, err
}
if cfg.RichDefaultMath, err = getenvBool("RICH_DEFAULT_MATH", true); err != nil {
return nil, err
}
if cfg.RenderDPI, err = getenvInt("RENDER_DPI", 600); err != nil {
return nil, err
}
if cfg.MaxConcurrency, err = getenvInt("RENDER_MAX_CONCURRENCY", 2); err != nil {
return nil, err
}
if cfg.MaxExpressionLen, err = getenvInt("MAX_EXPRESSION_LEN", 4000); err != nil {
return nil, err
}
if cfg.MaxPreambleLen, err = getenvInt("MAX_PREAMBLE_LEN", 4000); err != nil {
return nil, err
}
if cfg.RenderTimeout, err = getenvDuration("RENDER_TIMEOUT", 30*time.Second); err != nil {
return nil, err
}
if cfg.S3PresignTTL, err = getenvDuration("S3_PRESIGN_TTL", time.Hour); err != nil {
return nil, err
}
if cfg.TelegramBotToken == "" {
return nil, fmt.Errorf("missing required environment variable: TELEGRAM_BOT_TOKEN")
}
if cfg.MongoURI == "" {
return nil, fmt.Errorf("missing required environment variable: MONGODB_URI")
}
if cfg.RenderDPI < 72 || cfg.RenderDPI > 1200 {
return nil, fmt.Errorf("RENDER_DPI must be between 72 and 1200")
}
if cfg.RenderTimeout <= 0 {
return nil, fmt.Errorf("RENDER_TIMEOUT must be positive")
}
if cfg.MaxConcurrency < 1 {
return nil, fmt.Errorf("RENDER_MAX_CONCURRENCY must be at least 1")
}
if cfg.MaxExpressionLen < 1 || cfg.MaxPreambleLen < 1 {
return nil, fmt.Errorf("MAX_EXPRESSION_LEN and MAX_PREAMBLE_LEN must be positive")
}
s3Set := 0
for _, value := range []string{
cfg.S3Endpoint, cfg.S3Bucket, cfg.S3AccessKeyID,
cfg.S3SecretAccessKey, cfg.S3PublicEndpoint,
} {
if value != "" {
s3Set++
}
}
if s3Set != 0 && s3Set != 5 {
return nil, fmt.Errorf(
"incomplete S3 configuration: set S3_ENDPOINT_URL, S3_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and S3_PUBLIC_ENDPOINT_URL together")
}
if cfg.S3PresignTTL <= 0 {
return nil, fmt.Errorf("S3_PRESIGN_TTL must be positive")
}
return cfg, nil
}
// S3Enabled reports whether private inline PNG/PDF assets can be stored and
// exposed through short-lived presigned URLs.
func (c *Config) S3Enabled() bool {
return c.S3Endpoint != "" && c.S3Bucket != "" &&
c.S3AccessKeyID != "" && c.S3SecretAccessKey != "" &&
c.S3PublicEndpoint != ""
}
func getenv(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func getenvInt(key string, fallback int) (int, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("invalid %s: %w", key, err)
}
return parsed, nil
}
func getenvBool(key string, fallback bool) (bool, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf("invalid %s: %w", key, err)
}
return parsed, nil
}
func getenvDuration(key string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("invalid %s: %w", key, err)
}
return parsed, nil
}

View File

@@ -0,0 +1,143 @@
package config
import (
"strings"
"testing"
"time"
)
func setRequired(t *testing.T) {
t.Helper()
t.Setenv("TELEGRAM_BOT_TOKEN", "123:abc")
t.Setenv("MONGODB_URI", "mongodb://example:27017")
}
func TestLoadDefaults(t *testing.T) {
setRequired(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.RenderDPI != 600 {
t.Errorf("RenderDPI = %d, want 600", cfg.RenderDPI)
}
if cfg.RenderTimeout != 30*time.Second {
t.Errorf("RenderTimeout = %s, want 30s", cfg.RenderTimeout)
}
if cfg.MaxConcurrency != 2 {
t.Errorf("MaxConcurrency = %d, want 2", cfg.MaxConcurrency)
}
if cfg.MaxExpressionLen != 4000 || cfg.MaxPreambleLen != 4000 {
t.Errorf("unexpected length caps: %d/%d", cfg.MaxExpressionLen, cfg.MaxPreambleLen)
}
if cfg.HealthAddr != ":8080" {
t.Errorf("HealthAddr = %q, want :8080", cfg.HealthAddr)
}
if cfg.MongoDatabase != "latexgram" {
t.Errorf("MongoDatabase = %q, want latexgram", cfg.MongoDatabase)
}
if !cfg.RichTextEnabled {
t.Error("RichTextEnabled = false, want true by default")
}
if !cfg.RichDefaultMath {
t.Error("RichDefaultMath = false, want true by default")
}
if cfg.S3PresignTTL != time.Hour {
t.Errorf("S3PresignTTL = %s, want 1h", cfg.S3PresignTTL)
}
if cfg.S3Enabled() {
t.Error("S3Enabled = true without S3 configuration")
}
}
func TestLoadS3(t *testing.T) {
setRequired(t)
t.Setenv("S3_ENDPOINT_URL", "http://minio:9000/")
t.Setenv("S3_BUCKET", "latexgrambot")
t.Setenv("AWS_ACCESS_KEY_ID", "access")
t.Setenv("AWS_SECRET_ACCESS_KEY", "secret")
t.Setenv("S3_PUBLIC_ENDPOINT_URL", "https://s3.example/")
t.Setenv("S3_PRESIGN_TTL", "45m")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.S3Enabled() || cfg.S3Endpoint != "http://minio:9000" || cfg.S3PublicEndpoint != "https://s3.example" {
t.Fatalf("unexpected S3 config: %+v", cfg)
}
if cfg.S3PresignTTL != 45*time.Minute {
t.Errorf("S3PresignTTL = %s", cfg.S3PresignTTL)
}
}
func TestLoadRejectsIncompleteS3(t *testing.T) {
setRequired(t)
t.Setenv("S3_BUCKET", "latexgrambot")
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "incomplete S3 configuration") {
t.Fatalf("expected incomplete S3 error, got %v", err)
}
}
func TestLoadRichDefaultMathToggle(t *testing.T) {
setRequired(t)
t.Setenv("RICH_DEFAULT_MATH", "false")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.RichDefaultMath {
t.Error("RichDefaultMath = true, want false")
}
t.Setenv("RICH_DEFAULT_MATH", "banana")
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "RICH_DEFAULT_MATH") {
t.Fatalf("expected RICH_DEFAULT_MATH validation error, got %v", err)
}
}
func TestLoadRichTextToggle(t *testing.T) {
setRequired(t)
t.Setenv("RICH_TEXT_ENABLED", "false")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.RichTextEnabled {
t.Error("RichTextEnabled = true, want false")
}
t.Setenv("RICH_TEXT_ENABLED", "banana")
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "RICH_TEXT_ENABLED") {
t.Fatalf("expected RICH_TEXT_ENABLED validation error, got %v", err)
}
}
func TestLoadMissingToken(t *testing.T) {
t.Setenv("MONGODB_URI", "mongodb://example:27017")
_, err := Load()
if err == nil || !strings.Contains(err.Error(), "TELEGRAM_BOT_TOKEN") {
t.Fatalf("expected missing token error, got %v", err)
}
}
func TestLoadMissingMongoURI(t *testing.T) {
t.Setenv("TELEGRAM_BOT_TOKEN", "123:abc")
_, err := Load()
if err == nil || !strings.Contains(err.Error(), "MONGODB_URI") {
t.Fatalf("expected missing MONGODB_URI error, got %v", err)
}
}
func TestLoadInvalidValues(t *testing.T) {
setRequired(t)
t.Setenv("RENDER_DPI", "10")
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "RENDER_DPI") {
t.Fatalf("expected RENDER_DPI validation error, got %v", err)
}
t.Setenv("RENDER_DPI", "300")
t.Setenv("RENDER_TIMEOUT", "banana")
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "RENDER_TIMEOUT") {
t.Fatalf("expected RENDER_TIMEOUT validation error, got %v", err)
}
}