diff --git a/handlers.go b/handlers.go index ef64235b..9bb904ad 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(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 { + 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..ba12b4db 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,51 @@ 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) { + // 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") - 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 +796,24 @@ 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 - } + 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()) - return buf.Bytes() + // 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) {