Files
latexgrambot/internal/store/store.go
corrado.mulas 98a3614d58 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
2026-10-04 03:42:50 +02:00

297 lines
8.4 KiB
Go

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