Work around truncated Telegram inline photos

This commit is contained in:
corrado.mulas
2026-10-04 10:32:58 +02:00
parent 0212d48555
commit d5b6ab0c24
4 changed files with 205 additions and 55 deletions

View File

@@ -39,7 +39,7 @@ const (
// Included in content hashes so renderer/template changes never reuse
// stale Telegram file_ids or S3 objects from an older deployment.
renderCacheVersion = "presigned-v1"
renderCacheVersion = "presigned-v2-inline-thumbnail"
)
// outputFormat is one of the three generation formats.
@@ -130,9 +130,12 @@ type refsEntry struct {
// urlEntry caches the presigned URLs of a recent render so repeated
// identical inline queries skip rendering and uploading again.
type urlEntry struct {
photoURL string
pdfURL string
expires time.Time
photoURL string
thumbnailURL string
photoWidth int
photoHeight int
pdfURL string
expires time.Time
}
// New authenticates the bot and instruments its HTTP client.
@@ -480,7 +483,7 @@ func (b *Bot) savePhotoID(hash, fileID string) {
if b.cache == nil {
return
}
b.savePhotoID(hash, fileID)
b.cache.SavePhotoID(hash, fileID)
b.refsMu.Lock()
entry := b.refsCache[hash]
entry.refs.PhotoID = fileID
@@ -493,7 +496,7 @@ func (b *Bot) saveDocID(hash, fileID string) {
if b.cache == nil {
return
}
b.saveDocID(hash, fileID)
b.cache.SaveDocID(hash, fileID)
b.refsMu.Lock()
entry := b.refsCache[hash]
entry.refs.DocID = fileID
@@ -502,28 +505,29 @@ func (b *Bot) saveDocID(hash, fileID string) {
b.refsMu.Unlock()
}
// cachedURLs returns the presigned URLs of a recent render of the same
// content, so repeated identical queries skip render and upload entirely.
func (b *Bot) cachedURLs(hash string) (photoURL, pdfURL string, ok bool) {
// 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 "", "", false
return urlEntry{}, false
}
return entry.photoURL, entry.pdfURL, true
return entry, true
}
func (b *Bot) cacheURLs(hash, photoURL, pdfURL string) {
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] = urlEntry{photoURL: photoURL, pdfURL: pdfURL, expires: time.Now().Add(ttl)}
b.urlsCache[hash] = entry
b.urlsMu.Unlock()
}
@@ -557,18 +561,30 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) []
refs := b.fileRefs(hash)
needPhoto := refs.PhotoID == ""
needDoc := refs.DocID == ""
var photoURL, pdfURL string
var urls urlEntry
if needPhoto || needDoc {
if cachedPhoto, cachedPDF, ok := b.cachedURLs(hash); ok {
photoURL, pdfURL = cachedPhoto, cachedPDF
needPhoto, needDoc = false, false
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()
result, err := b.ren.RenderFormats(renderCtx, settings.Preamble, expr, dpi, render.FormatJPEG|render.FormatPDF)
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,
@@ -581,12 +597,13 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) []
}
go b.rec.RecordRender(stat)
// Upload both formats concurrently: each Put is an object store
// round-trip.
// 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, pdfPutURL string
var photoPutURL, thumbnailPutURL, pdfPutURL string
if needPhoto {
wg.Add(1)
wg.Add(2)
go func() {
defer wg.Done()
storedURL, putErr := b.files.Put(ctx, "jpg", "image/jpeg", result.JPEG)
@@ -596,6 +613,15 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) []
}
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)
@@ -610,14 +636,17 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) []
}()
}
wg.Wait()
if photoPutURL != "" {
photoURL = photoPutURL
if photoPutURL != "" && thumbnailPutURL != "" {
urls.photoURL = photoPutURL
urls.thumbnailURL = thumbnailPutURL
urls.photoWidth = result.JPEGWidth
urls.photoHeight = result.JPEGHeight
}
if pdfPutURL != "" {
pdfURL = pdfPutURL
urls.pdfURL = pdfPutURL
}
if photoPutURL != "" || pdfPutURL != "" {
b.cacheURLs(hash, photoURL, pdfURL)
if urls.photoURL != "" || urls.pdfURL != "" {
b.cacheURLs(hash, urls)
}
}
@@ -626,18 +655,23 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) []
photo := tgbotapi.NewInlineQueryResultCachedPhoto("photo-"+id, refs.PhotoID)
photo.Title = "PNG"
results = append(results, photo)
} else if photoURL != "" {
photo := tgbotapi.NewInlineQueryResultPhoto("photo-"+id, photoURL)
photo.ThumbURL = photoURL
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 pdfURL != "" {
document := tgbotapi.NewInlineQueryResultDocument("doc-"+id, pdfURL, "formula.pdf", "application/pdf")
} else if urls.pdfURL != "" {
document := tgbotapi.NewInlineQueryResultDocument("doc-"+id, urls.pdfURL, "formula.pdf", "application/pdf")
document.Title = "PDF"
results = append(results, document)
}
@@ -668,6 +702,18 @@ 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 {

View File

@@ -2,6 +2,7 @@ package bot
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
@@ -74,9 +75,13 @@ func (f *fakeFiles) Put(_ context.Context, ext, _ string, data []byte) (string,
func testBot() *Bot {
return &Bot{
api: &tgbotapi.BotAPI{Self: tgbotapi.User{UserName: "latexgrambot"}},
cfg: &config.Config{MaxExpressionLen: 4000, RichTextEnabled: true, RichDefaultMath: true, RenderDPI: 600},
ren: &stubRenderer{results: &render.Result{PNG: []byte("png"), JPEG: []byte("jpg"), PDF: []byte("pdf")}},
api: &tgbotapi.BotAPI{Self: tgbotapi.User{UserName: "latexgrambot"}},
cfg: &config.Config{MaxExpressionLen: 4000, RichTextEnabled: true, RichDefaultMath: true, RenderDPI: 600},
ren: &stubRenderer{results: &render.Result{
PNG: []byte("png"), JPEG: []byte("jpg"),
JPEGWidth: 1600, JPEGHeight: 600,
Thumbnail: []byte("thumbnail"), PDF: []byte("pdf"),
}},
user: &fakeSettings{},
rec: &fakeRecorder{},
files: &fakeFiles{},
@@ -103,16 +108,29 @@ func TestInlineResultsUsesPresignedFiles(t *testing.T) {
t.Fatalf("got %d results, want 3", len(results))
}
photo, ok := results[0].(tgbotapi.InlineQueryResultPhoto)
photo, ok := results[0].(inlineQueryResultPhoto)
if !ok {
t.Fatalf("result 0 is %T, want InlineQueryResultPhoto", results[0])
t.Fatalf("result 0 is %T, want inlineQueryResultPhoto", results[0])
}
if !strings.Contains(photo.URL, ".jpg?") || photo.ThumbURL != photo.URL {
t.Errorf("photo URL/thumb = %q/%q", photo.URL, photo.ThumbURL)
if !strings.Contains(photo.PhotoURL, ".jpg?") || !strings.Contains(photo.ThumbnailURL, ".thumb.jpg?") {
t.Errorf("photo URL/thumbnail = %q/%q", photo.PhotoURL, photo.ThumbnailURL)
}
if photo.PhotoURL == photo.ThumbnailURL {
t.Error("full photo and thumbnail must use different objects")
}
if photo.PhotoWidth != 1600 || photo.PhotoHeight != 600 {
t.Errorf("photo dimensions = %dx%d", photo.PhotoWidth, photo.PhotoHeight)
}
if photo.Title != "PNG" {
t.Errorf("photo title = %q", photo.Title)
}
encoded, err := json.Marshal(photo)
if err != nil {
t.Fatalf("marshal photo result: %v", err)
}
if !strings.Contains(string(encoded), `"thumbnail_url"`) || strings.Contains(string(encoded), `"thumb_url"`) {
t.Errorf("unexpected photo JSON: %s", encoded)
}
document, ok := results[1].(tgbotapi.InlineQueryResultDocument)
if !ok {
@@ -145,7 +163,7 @@ func TestInlineResultsUsesPresignedFiles(t *testing.T) {
t.Fatalf("unexpected inline math node: %+v", parts[1])
}
files := b.files.(*fakeFiles)
if string(files.puts["jpg"]) != "jpg" || string(files.puts["pdf"]) != "pdf" {
if string(files.puts["jpg"]) != "jpg" || string(files.puts["thumb.jpg"]) != "thumbnail" || string(files.puts["pdf"]) != "pdf" {
t.Fatalf("stored files = %#v", files.puts)
}
}
@@ -156,13 +174,13 @@ func TestInlineResultsCachesPresignedURLs(t *testing.T) {
first := b.inlineResults(context.Background(), query)
second := b.inlineResults(context.Background(), query)
files := b.files.(*fakeFiles)
if files.putCalls != 2 {
t.Fatalf("put calls = %d, want 2 (one per format, cached afterwards)", files.putCalls)
if files.putCalls != 3 {
t.Fatalf("put calls = %d, want 3 (photo, thumbnail and PDF, cached afterwards)", files.putCalls)
}
firstPhoto := first[0].(tgbotapi.InlineQueryResultPhoto)
secondPhoto := second[0].(tgbotapi.InlineQueryResultPhoto)
if firstPhoto.URL != secondPhoto.URL {
t.Fatalf("second query re-rendered: %q vs %q", firstPhoto.URL, secondPhoto.URL)
firstPhoto := first[0].(inlineQueryResultPhoto)
secondPhoto := second[0].(inlineQueryResultPhoto)
if firstPhoto.PhotoURL != secondPhoto.PhotoURL || firstPhoto.ThumbnailURL != secondPhoto.ThumbnailURL {
t.Fatalf("second query re-rendered: %+v vs %+v", firstPhoto, secondPhoto)
}
}
@@ -201,8 +219,22 @@ func TestInlineResultsRichDisabled(t *testing.T) {
if len(results) != 2 {
t.Fatalf("got %d results, want 2", len(results))
}
if _, ok := results[0].(tgbotapi.InlineQueryResultPhoto); !ok {
t.Fatalf("result 0 is %T, want InlineQueryResultPhoto", results[0])
if _, ok := results[0].(inlineQueryResultPhoto); !ok {
t.Fatalf("result 0 is %T, want inlineQueryResultPhoto", results[0])
}
}
func TestSaveFileIDsUsesPersistentCache(t *testing.T) {
b := testBot()
cache := &fakeCache{}
b.cache = cache
b.savePhotoID("hash", "photo-id")
b.saveDocID("hash", "doc-id")
if cache.refs.PhotoID != "photo-id" || cache.refs.DocID != "doc-id" {
t.Fatalf("saved refs = %+v", cache.refs)
}
if got := b.refsCache["hash"].refs; got != cache.refs {
t.Fatalf("memory refs = %+v, persistent refs = %+v", got, cache.refs)
}
}

View File

@@ -39,6 +39,9 @@ const (
// maxRenderedSide guards against pathologically tall formulas.
maxRenderedSide = 4096
// inlineThumbnailSide is Telegram's conventional maximum thumbnail side.
inlineThumbnailSide = 320
)
// documentClass is the fixed preview layout: the standalone class crops
@@ -113,9 +116,12 @@ func DefaultPreamble() string {
// Result carries the compiled output. JPEG is used for URL-based Telegram
// inline photos, while PNG is used for direct chat replies.
type Result struct {
PNG []byte
JPEG []byte
PDF []byte
PNG []byte
JPEG []byte
JPEGWidth int
JPEGHeight int
Thumbnail []byte
PDF []byte
}
// LatexError wraps a LaTeX compile failure and carries the log excerpt shown
@@ -193,11 +199,12 @@ const (
FormatPNG Formats = 1 << iota
FormatJPEG
FormatPDF
FormatThumbnail
)
// Render compiles the expression and produces every format.
func (r *Renderer) Render(ctx context.Context, preamble, expression string, dpi int) (*Result, error) {
return r.RenderFormats(ctx, preamble, expression, dpi, FormatPNG|FormatJPEG|FormatPDF)
return r.RenderFormats(ctx, preamble, expression, dpi, FormatPNG|FormatJPEG|FormatPDF|FormatThumbnail)
}
// RenderFormats compiles the expression inside the given preamble and
@@ -316,11 +323,25 @@ func (r *Renderer) RenderFormats(ctx context.Context, preamble, expression strin
if err := r.renderRaster(ctx, workdir, dpi, pdfPath, jpegPath, "jpeg"); err != nil {
return nil, err
}
result.JPEGWidth, result.JPEGHeight, err = imageSize(jpegPath)
if err != nil {
return nil, fmt.Errorf("inspect rendered jpeg: %w", err)
}
result.JPEG, err = os.ReadFile(jpegPath)
if err != nil {
return nil, fmt.Errorf("read rendered jpeg: %w", err)
}
}
if formats&FormatThumbnail != 0 {
thumbnailPath := filepath.Join(workdir, "document-thumb.jpg")
if err := r.renderThumbnail(ctx, workdir, dpi, pdfPath, thumbnailPath); err != nil {
return nil, err
}
result.Thumbnail, err = os.ReadFile(thumbnailPath)
if err != nil {
return nil, fmt.Errorf("read rendered thumbnail: %w", err)
}
}
return result, nil
}
@@ -331,6 +352,12 @@ func (r *Renderer) RenderFormats(ctx context.Context, preamble, expression strin
func (r *Renderer) renderRaster(ctx context.Context, workdir string, dpi int, pdfPath, outPath, format string) error {
base := filepath.Join(workdir, "document")
args := []string{"-" + format, "-r", strconv.Itoa(dpi), "-singlefile", "-f", "1", "-l", "1"}
if format == "jpeg" {
// Baseline JPEGs avoid partial/progressive decode artifacts in some
// Telegram clients while remaining comfortably below the 5 MB inline
// photo limit for normal formula renders.
args = append(args, "-jpegopt", "quality=90,progressive=n,optimize=y")
}
if err := r.run(ctx, workdir, r.Pdftoppm, append(args, pdfPath, base)...); err != nil {
return err
}
@@ -356,6 +383,30 @@ func (r *Renderer) renderRaster(ctx context.Context, workdir string, dpi int, pd
return r.run(ctx, workdir, r.Pdftoppm, append(args, pdfPath, base)...)
}
// renderThumbnail creates the dedicated small JPEG used only by Telegram's
// inline-result picker. Reusing the full-resolution photo URL as its thumbnail
// can leave clients displaying a 320-pixel partial decode over the full photo
// canvas even though the downloaded media itself is intact.
func (r *Renderer) renderThumbnail(ctx context.Context, workdir string, dpi int, pdfPath, outPath string) error {
base := strings.TrimSuffix(outPath, filepath.Ext(outPath))
args := []string{
"-jpeg", "-jpegopt", "quality=82,progressive=n,optimize=y",
"-r", strconv.Itoa(dpi), "-singlefile", "-f", "1", "-l", "1",
"-scale-to", strconv.Itoa(inlineThumbnailSide), pdfPath, base,
}
if err := r.run(ctx, workdir, r.Pdftoppm, args...); err != nil {
return fmt.Errorf("render inline thumbnail: %w", err)
}
width, height, err := imageSize(outPath)
if err != nil {
return fmt.Errorf("inspect inline thumbnail: %w", err)
}
if width > inlineThumbnailSide || height > inlineThumbnailSide {
return fmt.Errorf("inline thumbnail is unexpectedly large: %dx%d", width, height)
}
return nil
}
func imageSize(path string) (int, int, error) {
file, err := os.Open(path)
if err != nil {

View File

@@ -107,17 +107,38 @@ func TestRenderIntegration(t *testing.T) {
if len(result.JPEG) == 0 || !strings.HasPrefix(string(result.JPEG), "\xff\xd8") {
t.Fatal("output is not a JPEG file")
}
jpegWidth, jpegHeight, err := imageSizeBytes(result.JPEG)
if err != nil {
t.Fatalf("decode jpeg size: %v", err)
}
if result.JPEGWidth != jpegWidth || result.JPEGHeight != jpegHeight {
t.Errorf("reported JPEG size %dx%d, decoded %dx%d", result.JPEGWidth, result.JPEGHeight, jpegWidth, jpegHeight)
}
if len(result.Thumbnail) == 0 || !strings.HasPrefix(string(result.Thumbnail), "\xff\xd8") {
t.Fatal("output is not a JPEG thumbnail")
}
thumbWidth, thumbHeight, err := imageSizeBytes(result.Thumbnail)
if err != nil {
t.Fatalf("decode thumbnail size: %v", err)
}
if thumbWidth > inlineThumbnailSide || thumbHeight > inlineThumbnailSide {
t.Errorf("thumbnail is %dx%d, want each side <= %d", thumbWidth, thumbHeight, inlineThumbnailSide)
}
linked, err := r.Render(ctx, "", `\hypertarget{details}{Details}\par\hyperlink{details}{Go to details}`, 150)
if err != nil {
t.Fatalf("hyperlink render failed: %v", err)
}
if len(linked.PDF) == 0 || len(linked.PNG) == 0 || len(linked.JPEG) == 0 {
if len(linked.PDF) == 0 || len(linked.PNG) == 0 || len(linked.JPEG) == 0 || len(linked.Thumbnail) == 0 {
t.Fatal("hyperlink render returned an empty format")
}
}
func pngSizeBytes(data []byte) (int, int, error) {
return imageSizeBytes(data)
}
func imageSizeBytes(data []byte) (int, int, error) {
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return 0, 0, err