Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
92 changes: 61 additions & 31 deletions helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", "")
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down