// 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-v4-rich-environments" ) // 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 ", 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 \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 — reply with the rich text message (default)\n"+ "- /image — reply with the PNG picture\n"+ "- /pdf — reply with the PDF document\n"+ "- Inline mode: in any chat, type @%s 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 — 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) }