From 73956ca03e924035cb9a69ba2e6ec818ac4c143a Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 9 Jul 2026 11:20:23 -0300 Subject: [PATCH 1/3] fix(preview): send User-Agent on OG fetch and upload HQ thumbnail for large preview card Link previews (LinkPreview: true on /chat/send/text) failed or rendered poorly in three ways: 1. Open Graph requests were sent without a User-Agent, so Go's default "Go-http-client/2.0" was used. Bot-protected sites (e.g. Mercado Livre) answer 403 and the message went out with an empty preview. Requests now send the WhatsApp preview fetcher UA plus Accept and Accept-Language headers. 2. Only a small inline JPEGThumbnail (100px) was embedded, so clients always rendered the compact preview card. Now a high-res thumbnail (up to 600px) is also generated, uploaded to WhatsApp media servers (MediaLinkThumbnail) and referenced via ThumbnailDirectPath, ThumbnailSHA256/EncSHA256, MediaKey and ThumbnailWidth/Height, which makes clients render the large preview card. If the upload fails the message falls back to the inline thumbnail. 3. MediaKeyTimestamp is required by WhatsApp Web to download the hosted thumbnail; without it the card shows the stretched inline thumbnail and looks blurry. It is now set, and the inline thumbnail size was raised from 100px to 192px (the size official clients embed). --- handlers.go | 31 +++++++++++++++----- helpers.go | 84 +++++++++++++++++++++++++++++++++-------------------- 2 files changed, 76 insertions(+), 39 deletions(-) diff --git a/handlers.go b/handlers.go index ef64235b..6595d0c7 100644 --- a/handlers.go +++ b/handlers.go @@ -2650,26 +2650,41 @@ func (s *server) SendMessage() http.HandlerFunc { msgid = t.Id } var ( - url string - title string - description string - imageData []byte + url string + og openGraphResult ) if t.LinkPreview { url = extractFirstURL(t.Body) if url != "" { - title, description, imageData = getOpenGraphData(r.Context(), url, txtid) + og = getOpenGraphData(r.Context(), url, txtid) } } msg := &waE2E.Message{ ExtendedTextMessage: &waE2E.ExtendedTextMessage{ Text: proto.String(t.Body), MatchedText: proto.String(url), - Title: proto.String(title), - Description: proto.String(description), - JPEGThumbnail: imageData, + Title: proto.String(og.Title), + Description: proto.String(og.Description), + JPEGThumbnail: og.ImageData, }, } + // Upload the high-res thumbnail so clients render the large preview + // card; without these fields only the small inline thumbnail shows. + if len(og.HQImageData) > 0 { + uploaded, upErr := clientManager.GetWhatsmeowClient(txtid).Upload(context.Background(), og.HQImageData, whatsmeow.MediaLinkThumbnail) + if upErr != nil { + log.Warn().Err(upErr).Str("url", url).Msg("Failed to upload link preview thumbnail, sending inline thumbnail only") + } else { + etm := msg.ExtendedTextMessage + etm.ThumbnailDirectPath = proto.String(uploaded.DirectPath) + etm.ThumbnailSHA256 = uploaded.FileSHA256 + etm.ThumbnailEncSHA256 = uploaded.FileEncSHA256 + etm.MediaKey = uploaded.MediaKey + etm.MediaKeyTimestamp = proto.Int64(time.Now().Unix()) + etm.ThumbnailWidth = proto.Uint32(og.HQWidth) + etm.ThumbnailHeight = proto.Uint32(og.HQHeight) + } + } if t.ContextInfo.StanzaID != nil { var qm *waE2E.Message diff --git a/helpers.go b/helpers.go index c7f87f46..8fb5c820 100644 --- a/helpers.go +++ b/helpers.go @@ -50,8 +50,9 @@ const ( fetchDocumentMaxBytes = 100 * 1024 * 1024 // 100MB openGraphPageMaxBytes = 2 * 1024 * 1024 // 2MB openGraphImageMaxBytes = 10 * 1024 * 1024 // 10MB - openGraphThumbnailWidth = 100 - openGraphThumbnailHeight = 100 + openGraphThumbnailWidth = 192 + openGraphThumbnailHeight = 192 + openGraphHQThumbnailDim = 600 // Max dimension of the uploaded thumbnail used for the large preview card openGraphJpegQuality = 80 openGraphMaxImageDim = 4000 // Max width or height for Open Graph images openGraphUserFetchLimit = 20 // Limit concurrent Open Graph fetches per user @@ -116,7 +117,10 @@ func proxyConfigResponse(proxyURL string, webhookUseProxy bool) map[string]inter type openGraphResult struct { Title string Description string - ImageData []byte + ImageData []byte // small inline thumbnail (JPEGThumbnail field) + HQImageData []byte // larger thumbnail uploaded to WA media servers for the big preview card + HQWidth uint32 + HQHeight uint32 } type UserSemaphoreManager struct { @@ -170,6 +174,15 @@ func fetchURLBytes(ctx context.Context, resourceURL string, limit int64) ([]byte return nil, "", err } + // Sites with bot protection (e.g. Mercado Livre) return 403 to Go's + // default "Go-http-client" agent; WhatsApp's own preview fetcher UA is + // widely allowed since sites want their links previewed in WhatsApp. + req.Header.Set("User-Agent", "WhatsApp/2.23.20.0") + // Do not advertise image/avif: CDNs then serve AVIF, which Go's image + // package cannot decode (gif/png/jpeg/webp decoders are registered). + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + req.Header.Set("Accept-Language", "pt-BR,pt;q=0.9,en;q=0.8") + resp, err := globalHTTPClient.Do(req) if err != nil { return nil, "", err @@ -197,12 +210,12 @@ func fetchURLBytes(ctx context.Context, resourceURL string, limit int64) ([]byte return data, contentType, nil } -func getOpenGraphData(ctx context.Context, urlStr string, userID string) (title, description string, imageData []byte) { +func getOpenGraphData(ctx context.Context, urlStr string, userID string) openGraphResult { // Check cache first if cachedData, found := openGraphCache.Get(urlStr); found { if data, ok := cachedData.(openGraphResult); ok { log.Debug().Str("url", urlStr).Msg("Open Graph data fetched from cache") - return data.Title, data.Description, data.ImageData + return data } } @@ -234,25 +247,24 @@ func getOpenGraphData(ctx context.Context, urlStr string, userID string) (title, }() // Fetch Open Graph data - title, description, imageData := fetchOpenGraphData(ctx, urlStr) + result := fetchOpenGraphData(ctx, urlStr) // Store in cache - openGraphCache.Set(urlStr, openGraphResult{title, description, imageData}, cache.DefaultExpiration) + openGraphCache.Set(urlStr, result, cache.DefaultExpiration) - return openGraphResult{title, description, imageData}, nil + return result, nil }) if err != nil { log.Error().Err(err).Str("url", urlStr).Msg("Error fetching Open Graph data via singleflight") - return "", "", nil + return openGraphResult{} } if v == nil { - return "", "", nil + return openGraphResult{} } - data := v.(openGraphResult) - return data.Title, data.Description, data.ImageData + return v.(openGraphResult) } // Update entry in User map @@ -690,17 +702,17 @@ func extractFirstURL(text string) string { return match } -func fetchOpenGraphData(ctx context.Context, urlStr string) (string, string, []byte) { +func fetchOpenGraphData(ctx context.Context, urlStr string) openGraphResult { pageData, _, err := fetchURLBytes(ctx, urlStr, openGraphPageMaxBytes) if err != nil { log.Warn().Err(err).Str("url", urlStr).Msg("Failed to fetch URL for Open Graph data") - return "", "", nil + return openGraphResult{} } doc, err := goquery.NewDocumentFromReader(bytes.NewReader(pageData)) if err != nil { log.Warn().Err(err).Str("url", urlStr).Msg("Failed to parse HTML for Open Graph data") - return "", "", nil + return openGraphResult{} } title := doc.Find(`meta[property="og:title"]`).AttrOr("content", "") @@ -731,34 +743,45 @@ func fetchOpenGraphData(ctx context.Context, urlStr string) (string, string, []b } } + result := openGraphResult{Title: title, Description: description} + pageURL, err := url.Parse(urlStr) if err != nil { log.Warn().Err(err).Str("url", urlStr).Msg("Failed to parse page URL for resolving image URL") - return title, description, nil + return result } - imageData := fetchOpenGraphImage(ctx, pageURL, imageURLStr) - return title, description, imageData + fetchOpenGraphImage(ctx, pageURL, imageURLStr, &result) + return result +} + +func encodeJPEGThumbnail(img image.Image) []byte { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: openGraphJpegQuality}); err != nil { + log.Warn().Err(err).Msg("Failed to encode thumbnail to JPEG") + return nil + } + return buf.Bytes() } -func fetchOpenGraphImage(ctx context.Context, pageURL *url.URL, imageURLStr string) []byte { +func fetchOpenGraphImage(ctx context.Context, pageURL *url.URL, imageURLStr string, result *openGraphResult) { imageURL, err := url.Parse(imageURLStr) if err != nil { log.Warn().Err(err).Str("imageURL", imageURLStr).Msg("Failed to parse Open Graph image URL") - return nil + return } resolvedImageURL := pageURL.ResolveReference(imageURL).String() imgBytes, _, err := fetchURLBytes(ctx, resolvedImageURL, openGraphImageMaxBytes) if err != nil { log.Warn().Err(err).Str("imageURL", resolvedImageURL).Msg("Failed to fetch Open Graph image") - return nil + return } imgConfig, _, err := image.DecodeConfig(bytes.NewReader(imgBytes)) if err != nil { log.Warn().Err(err).Str("imageURL", resolvedImageURL).Msg("Failed to decode Open Graph image config") - return nil + return } if imgConfig.Width > openGraphMaxImageDim || imgConfig.Height > openGraphMaxImageDim { @@ -767,23 +790,22 @@ func fetchOpenGraphImage(ctx context.Context, pageURL *url.URL, imageURLStr stri Int("height", imgConfig.Height). Str("imageURL", resolvedImageURL). Msg("Open Graph image dimensions too large") - return nil + return } img, _, err := image.Decode(bytes.NewReader(imgBytes)) if err != nil { log.Warn().Err(err).Str("imageURL", resolvedImageURL).Msg("Failed to decode Open Graph image") - return nil + return } - thumbnail := resize.Thumbnail(openGraphThumbnailWidth, openGraphThumbnailHeight, img, resize.Lanczos3) - var buf bytes.Buffer - if err := jpeg.Encode(&buf, thumbnail, &jpeg.Options{Quality: openGraphJpegQuality}); err != nil { - log.Warn().Err(err).Msg("Failed to encode thumbnail to JPEG") - return nil - } + result.ImageData = encodeJPEGThumbnail(resize.Thumbnail(openGraphThumbnailWidth, openGraphThumbnailHeight, img, resize.Lanczos3)) - return buf.Bytes() + hqThumb := resize.Thumbnail(openGraphHQThumbnailDim, openGraphHQThumbnailDim, img, resize.Lanczos3) + result.HQImageData = encodeJPEGThumbnail(hqThumb) + bounds := hqThumb.Bounds() + result.HQWidth = uint32(bounds.Dx()) + result.HQHeight = uint32(bounds.Dy()) } func runFFmpegConversion(input []byte, inputExt string, ffmpegArgs func(inPath, outPath string) []string, errMsg string) ([]byte, error) { From 4ee91f8081bff75f011641fdb1aad1a7c5f56216 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 9 Jul 2026 11:29:24 -0300 Subject: [PATCH 2/3] fix(preview): skip image fetch when the page declares no image When none of the og:image/twitter:image/icon selectors matched, imageURLStr was empty and pageURL.ResolveReference resolved it to the page itself, so the HTML page was downloaded a second time and fed to the image decoder ("image: unknown format"). Skip the image step entirely in that case. --- helpers.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/helpers.go b/helpers.go index 8fb5c820..2d589c85 100644 --- a/helpers.go +++ b/helpers.go @@ -765,6 +765,12 @@ func encodeJPEGThumbnail(img image.Image) []byte { } func fetchOpenGraphImage(ctx context.Context, pageURL *url.URL, imageURLStr string, result *openGraphResult) { + // No image found on the page; an empty string would resolve to the page + // URL itself and we would try to decode HTML as an image. + if imageURLStr == "" { + return + } + imageURL, err := url.Parse(imageURLStr) if err != nil { log.Warn().Err(err).Str("imageURL", imageURLStr).Msg("Failed to parse Open Graph image URL") From 8c766c19b5fe3e88322e0d146d2299694b85f0a2 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 9 Jul 2026 11:36:19 -0300 Subject: [PATCH 3/3] refactor(preview): address review feedback - Use r.Context() for the thumbnail upload so a cancelled request aborts the upload instead of running in the background. - Downscale the inline thumbnail from the already resized 600px thumbnail instead of running Lanczos3 twice over the original image (which can be up to 4000x4000). --- handlers.go | 2 +- helpers.go | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/handlers.go b/handlers.go index 6595d0c7..9bb904ad 100644 --- a/handlers.go +++ b/handlers.go @@ -2671,7 +2671,7 @@ func (s *server) SendMessage() http.HandlerFunc { // Upload the high-res thumbnail so clients render the large preview // card; without these fields only the small inline thumbnail shows. if len(og.HQImageData) > 0 { - uploaded, upErr := clientManager.GetWhatsmeowClient(txtid).Upload(context.Background(), og.HQImageData, whatsmeow.MediaLinkThumbnail) + uploaded, upErr := clientManager.GetWhatsmeowClient(txtid).Upload(r.Context(), og.HQImageData, whatsmeow.MediaLinkThumbnail) if upErr != nil { log.Warn().Err(upErr).Str("url", url).Msg("Failed to upload link preview thumbnail, sending inline thumbnail only") } else { diff --git a/helpers.go b/helpers.go index 2d589c85..ba12b4db 100644 --- a/helpers.go +++ b/helpers.go @@ -805,13 +805,15 @@ func fetchOpenGraphImage(ctx context.Context, pageURL *url.URL, imageURLStr stri return } - result.ImageData = encodeJPEGThumbnail(resize.Thumbnail(openGraphThumbnailWidth, openGraphThumbnailHeight, img, resize.Lanczos3)) - hqThumb := resize.Thumbnail(openGraphHQThumbnailDim, openGraphHQThumbnailDim, img, resize.Lanczos3) result.HQImageData = encodeJPEGThumbnail(hqThumb) bounds := hqThumb.Bounds() result.HQWidth = uint32(bounds.Dx()) result.HQHeight = uint32(bounds.Dy()) + + // Downscale the inline thumbnail from hqThumb (max 600px) instead of + // resizing the original image (up to 4000px) a second time. + result.ImageData = encodeJPEGThumbnail(resize.Thumbnail(openGraphThumbnailWidth, openGraphThumbnailHeight, hqThumb, resize.Lanczos3)) } func runFFmpegConversion(input []byte, inputExt string, ffmpegArgs func(inPath, outPath string) []string, errMsg string) ([]byte, error) {