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