Files
latexgrambot/internal/bot/bot.go
2026-10-04 10:32:58 +02:00

929 lines
28 KiB
Go

// 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-v2-inline-thumbnail"
)
// 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
thumbnailURL string
photoWidth int
photoHeight int
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.cache.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.cache.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 and photo geometry of a recent render
// of the same content, so repeated identical queries skip render and upload.
func (b *Bot) cachedURLs(hash string) (urlEntry, bool) {
b.urlsMu.Lock()
defer b.urlsMu.Unlock()
entry, found := b.urlsCache[hash]
if !found || time.Now().After(entry.expires) {
return urlEntry{}, false
}
return entry, true
}
func (b *Bot) cacheURLs(hash string, entry urlEntry) {
ttl := b.cfg.S3PresignTTL - 10*time.Minute
if ttl < 5*time.Minute {
ttl = 5 * time.Minute
}
entry.expires = time.Now().Add(ttl)
b.urlsMu.Lock()
if b.urlsCache == nil {
b.urlsCache = map[string]urlEntry{}
}
b.urlsCache[hash] = entry
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 urls urlEntry
if needPhoto || needDoc {
if cached, ok := b.cachedURLs(hash); ok {
urls = cached
if cached.photoURL != "" && cached.thumbnailURL != "" {
needPhoto = false
}
if cached.pdfURL != "" {
needDoc = false
}
}
}
if b.files != nil && (needPhoto || needDoc) {
renderCtx, cancel := context.WithTimeout(ctx, b.cfg.RenderTimeout)
defer cancel()
started := time.Now()
var formats render.Formats
if needPhoto {
formats |= render.FormatJPEG | render.FormatThumbnail
}
if needDoc {
formats |= render.FormatPDF
}
result, err := b.ren.RenderFormats(renderCtx, settings.Preamble, expr, dpi, formats)
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 the requested formats concurrently: each Put is an object
// store round-trip. A photo is only offered if both its full image and
// its dedicated thumbnail were stored successfully.
var wg sync.WaitGroup
var photoPutURL, thumbnailPutURL, pdfPutURL string
if needPhoto {
wg.Add(2)
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
}()
go func() {
defer wg.Done()
storedURL, putErr := b.files.Put(ctx, "thumb.jpg", "image/jpeg", result.Thumbnail)
if putErr != nil {
log.Printf("store inline thumbnail: %v", putErr)
return
}
thumbnailPutURL = 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 != "" && thumbnailPutURL != "" {
urls.photoURL = photoPutURL
urls.thumbnailURL = thumbnailPutURL
urls.photoWidth = result.JPEGWidth
urls.photoHeight = result.JPEGHeight
}
if pdfPutURL != "" {
urls.pdfURL = pdfPutURL
}
if urls.photoURL != "" || urls.pdfURL != "" {
b.cacheURLs(hash, urls)
}
}
results := []interface{}{}
if refs.PhotoID != "" {
photo := tgbotapi.NewInlineQueryResultCachedPhoto("photo-"+id, refs.PhotoID)
photo.Title = "PNG"
results = append(results, photo)
} else if urls.photoURL != "" && urls.thumbnailURL != "" {
results = append(results, inlineQueryResultPhoto{
Type: "photo",
ID: "photo-" + id,
PhotoURL: urls.photoURL,
ThumbnailURL: urls.thumbnailURL,
PhotoWidth: urls.photoWidth,
PhotoHeight: urls.photoHeight,
Title: "PNG",
})
}
if refs.DocID != "" {
document := tgbotapi.NewInlineQueryResultCachedDocument("doc-"+id, refs.DocID, "PDF")
document.Title = "PDF"
results = append(results, document)
} else if urls.pdfURL != "" {
document := tgbotapi.NewInlineQueryResultDocument("doc-"+id, urls.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"`
}
// inlineQueryResultPhoto follows the current Bot API field names. The pinned
// Telegram library predates thumbnail_url and still serializes thumb_url.
type inlineQueryResultPhoto struct {
Type string `json:"type"`
ID string `json:"id"`
PhotoURL string `json:"photo_url"`
ThumbnailURL string `json:"thumbnail_url"`
PhotoWidth int `json:"photo_width,omitempty"`
PhotoHeight int `json:"photo_height,omitempty"`
Title string `json:"title,omitempty"`
}
// 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)
}