Skip to content

feat(send): upload a high-quality thumbnail for link previews - #207

Open
cateim wants to merge 2 commits into
evolution-foundation:mainfrom
cateim:feat/hq-link-preview
Open

cateim wants to merge 2 commits into
evolution-foundation:mainfrom
cateim:feat/hq-link-preview

Conversation

@cateim

@cateim cateim commented Sep 26, 2026 •

Copy link
Copy Markdown

Description

POST /send/link only ever produced the small link preview. The message carried the image as an inline JPEGThumbnail and never uploaded a high-quality link thumbnail, so WhatsApp rendered the small card no matter how large the image was.

This PR uploads the preview image with whatsmeow.MediaLinkThumbnail and fills ThumbnailDirectPath, ThumbnailSHA256, ThumbnailEncSHA256, MediaKey, MediaKeyTimestamp, ThumbnailWidth and ThumbnailHeight. With those fields WhatsApp renders the large card (image on top, tappable through to the URL), the same card a phone produces when a user pastes a link.

Other changes in the same path:

  • Caller metadata is honored. The url field was accepted but ignored (the first URL of the text was always used), and a fetched page always overwrote the caller's title, description and imgUrl. The page is now fetched only for the fields that are missing, so a caller that already has the metadata skips the fetch entirely.
  • Bounded, crawler-like fetches. The page and the image are fetched with a link-preview user agent, a 10 s timeout, a size limit and a non-2xx check. With Go's default client, several storefronts answer with a bot wall or an HTTP 403 (for example meli.la short links), whose <title> ended up in the card, and a slow site could hang the request.
  • A preview failure no longer fails the send. The text still goes out, with the inline thumbnail only or without a card, and the reason is logged. Before, a failed metadata fetch was retried and then returned an error.
  • PreviewType is NONE instead of VIDEO, og:title is preferred over <title>, and relative og:image URLs are resolved against the page URL.
  • Channels (@newsletter) keep the inline thumbnail only, since they take unencrypted uploads.

Related Issue

Closes #103

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement

Testing

  • Manual testing completed

  • Functionality verified in development environment

  • No breaking changes introduced

  • Unit tests: pkg/sendMessage/service/link_preview_test.go, 14 tests covering the target URL choice, caller metadata precedence, user agent and status handling, the size limit, relative og:image, JPEG encoding and downscaling, and the upload field mapping. go build ./..., go vet ./pkg/sendMessage/... and go test ./... pass on golang:1.25.0-alpine (linux/arm64).

  • Manual test (2026-09-26): image built from this branch, instance paired to a real phone, two messages sent to another number. Both rendered the large card: one with caller-supplied title and imgUrl (no page fetch), one with only url (metadata fetched from a Mercado Livre product page).

Screenshots (if applicable)

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have tested my changes thoroughly
  • Any dependent changes have been merged and published

Additional Notes

  • No new dependencies: golang.org/x/image/draw comes from a module already in go.mod.
  • The /send/link request body is unchanged, so the Swagger docs did not need regenerating.
  • gofmt is clean and golangci-lint v2.1.6 reports no findings in the new code. The two errcheck findings the replaced code had are gone with it.

Summary by Sourcery

Enable reliable high-quality link previews for link messages while keeping preview failures non-blocking.

New Features:

  • Upload high-quality link-preview thumbnails so WhatsApp can render large image cards.

Bug Fixes:

  • Honor caller-provided link and preview metadata while allowing sends to proceed when preview retrieval or upload fails.

Enhancements:

  • Use bounded crawler-like requests and safer image processing for link metadata and thumbnails, including relative image resolution and improved title selection.
  • Preserve inline-only thumbnails for newsletter channels and avoid rebuilding previews across send retries.

Tests:

  • Add unit coverage for metadata precedence, bounded fetching, image processing, and thumbnail upload field mapping.

- added the high-quality link thumbnail upload so whatsapp renders the large preview card instead of the small one
- used the url, title, description and imgUrl sent by the caller, fetching the page only for missing fields
- fetched the page with a link-preview user agent, a 10s timeout and a size limit, rejecting non-2xx responses
- kept the send going when the preview fails, with a smaller card or none
- changed the preview type from video to none and read og:title and relative og:image urls

Closes evolution-foundation#103
@sourcery-ai

sourcery-ai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR replaces the small inline-only link preview flow with a bounded metadata/image pipeline that preserves caller metadata, uploads a properly populated high-quality WhatsApp thumbnail for direct chats, keeps newsletter previews inline-only, and allows sends to proceed when preview generation fails.

Sequence diagram for high-quality link thumbnail delivery

sequenceDiagram
    participant Caller
    participant SendService
    participant Page
    participant ImageHost
    participant WhatsApp

    Caller->>SendService: SendLink(data)
    SendService->>SendService: linkPreviewTarget(data)
    opt title or imgUrl is missing
        SendService->>Page: fetchLinkMetadata(target)
        Page-->>SendService: title, description, imageURL
    end
    opt imageURL is available
        SendService->>ImageHost: fetchLinkPreviewResource(imageURL)
        ImageHost-->>SendService: image bytes
        SendService->>SendService: prepareLinkPreviewImage(raw)
        SendService->>WhatsApp: Upload(highQuality, MediaLinkThumbnail)
        WhatsApp-->>SendService: UploadResponse
        SendService->>SendService: applyLinkThumbnailUpload(ext, uploaded, width, height)
    end
    SendService->>WhatsApp: SendMessage(ExtendedTextMessage)
    WhatsApp-->>Caller: Large link card or inline-only preview
Loading

Flow diagram for resilient link preview generation

flowchart TD
    A[SendLink] --> B[linkPreviewTarget]
    B --> C{Metadata complete?}
    C -->|Yes| D[Preserve caller metadata]
    C -->|No| E[fetchLinkMetadata]
    E --> F{Fetch succeeds?}
    F -->|No| G[Log warning and continue with text]
    F -->|Yes| H[Merge only missing metadata]
    D --> I{Image URL available?}
    H --> I
    I -->|No| J[Send text without thumbnail]
    I -->|Yes| K[fetchLinkPreviewResource]
    K --> L{Image valid and within limit?}
    L -->|No| M[Log warning and send text or inline-only preview]
    L -->|Yes| N[prepareLinkPreviewImage]
    N --> O{Newsletter?}
    O -->|Yes| P[Set JPEGThumbnail only]
    O -->|No| Q[Upload MediaLinkThumbnail]
    Q --> R{Upload succeeds?}
    R -->|No| S[Log warning and keep inline thumbnail]
    R -->|Yes| T[applyLinkThumbnailUpload]
    P --> U[SendMessage]
    S --> U
    T --> U
    J --> U
    G --> U
    M --> U
Loading

File-Level Changes

Change Details Files
Build a WhatsApp-compatible high-quality link thumbnail alongside the inline thumbnail.
  • Download and decode preview images with bounded, crawler-like HTTP requests.
  • Resize and JPEG-encode high-quality and 72px inline variants, including WebP input.
  • Upload the high-quality variant as MediaLinkThumbnail and map upload hashes, key, path, timestamp, and dimensions onto the message.
  • Retain inline-only behavior for newsletter channels.
pkg/sendMessage/service/link_preview.go
Make link metadata selection caller-aware and resilient to preview failures.
  • Prefer the explicit request URL and preserve caller-supplied title, description, and image values.
  • Fetch only missing metadata, prefer og:title, and resolve relative image URLs.
  • Treat fetch, image, and upload failures as warnings so the text send can continue.
  • Reject non-2xx responses, oversized bodies, and slow requests.
pkg/sendMessage/service/link_preview.go
pkg/sendMessage/service/send_service.go
Refactor link sending to prepare the preview once and reuse it across send retries.
  • Construct the message after client connection and cache it across retry attempts.
  • Set PreviewType to NONE and populate matched text from the selected target URL.
  • Replace unbounded direct HTTP fetching and retry-triggering preview errors with the shared preview pipeline.
pkg/sendMessage/service/send_service.go
Add focused unit coverage for metadata, image processing, request bounds, and upload field mapping.
  • Test URL precedence, metadata precedence, user agent, status handling, size limits, and relative image resolution.
  • Test JPEG conversion, downscaling, invalid images, and high-quality upload metadata.
pkg/sendMessage/service/link_preview_test.go

Assessment against linked issues

Issue Objective Addressed Explanation
#103 Upload the link preview image as a MediaLinkThumbnail and provide all required high-resolution thumbnail metadata and the upload handle when sending the message, so WhatsApp renders the large link-preview card. ❌ The PR uploads the image and populates the thumbnail path, hashes, media key, and dimensions, but the diff does not pass the resulting upload handle through SendRequestExtra.MediaHandle when calling SendMessage. Without that handle, the high-resolution media upload may not be associated with the outgoing message as required by the issue.
#103 Use the appropriate image preview type for image-based link previews instead of hardcoding the VIDEO preview type. ❌ The PR removes VIDEO but sets PreviewType to NONE. The issue specifically calls for the IMAGE enum for image links, so this change does not implement the requested preview-type correction.
#103 Preserve and improve link-preview metadata handling, including honoring caller-provided URL and metadata while making preview fetching bounded and non-fatal. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="pkg/sendMessage/service/link_preview.go" line_range="74" />
<code_context>
+// page that refuses bots.
+func resolveLinkPreview(data *LinkStruct, target string, fetch func(string) (string, string, string, error)) (linkPreviewData, error) {
+	preview := linkPreviewData{Title: data.Title, Description: data.Description, ImageURL: data.ImgUrl}
+	if target == "" || (preview.Title != "" && preview.ImageURL != "") {
+		return preview, nil
+	}
+
</code_context>
<issue_to_address>
**issue (bug_risk):** When the caller supplies both `Title` and `ImgUrl` but omits `Description`, the condition returns before fetching the page, so the preview description is always left empty even though the page could provide it.

**Triggers:** When caller metadata includes a title and image URL but no description.

**Suggested fix:** Also fetch when `preview.Description == ""`, or otherwise fetch the individual missing metadata fields.

```suggestion
	if target == "" || (preview.Title != "" && preview.Description != "" && preview.ImageURL != "") {
```
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 1 finding to address first, and the service now follows caller- and page-supplied image URLs, decodes untrusted image bodies, and uploads the result to WhatsApp; redirects or an attacker-controlled og:image could enable SSRF/data exposure, while crafted compressed images could cause excessive memory use. Reverting stops future fetches, but already uploaded or sent thumbnails and any resulting outage or data exposure cannot be undone.

Blocking findings: pkg/sendMessage/service/link_preview.go:74


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

// page that refuses bots.
func resolveLinkPreview(data *LinkStruct, target string, fetch func(string) (string, string, string, error)) (linkPreviewData, error) {
preview := linkPreviewData{Title: data.Title, Description: data.Description, ImageURL: data.ImgUrl}
if target == "" || (preview.Title != "" && preview.ImageURL != "") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): When the caller supplies both Title and ImgUrl but omits Description, the condition returns before fetching the page, so the preview description is always left empty even though the page could provide it.

Triggers: When caller metadata includes a title and image URL but no description.

Suggested fix: Also fetch when preview.Description == "", or otherwise fetch the individual missing metadata fields.

Suggested change
if target == "" || (preview.Title != "" && preview.ImageURL != "") {
if target == "" || (preview.Title != "" && preview.Description != "" && preview.ImageURL != "") {

- added a header-only dimension check that rejects preview images above 25 megapixels, so a small file declaring a huge bitmap can no longer exhaust memory
- simplified decoding to image.Decode, since the webp decoder registers itself in the image package
- added tests for the dimension guard and for webp input
@cateim

cateim commented Sep 26, 2026

Copy link
Copy Markdown
Author

Review notes:

  1. Description is not fetched when the caller sends title and imgUrl. Intentional: a caller that already has the title and the image has chosen not to depend on the page. The fetch adds latency to every send, and some pages refuse bots or return a generic description (a Mercado Livre affiliate link returns the same one for every product). Callers that want a description can send it. Covered by TestResolveLinkPreview_CallerValuesSkipTheFetch.
  2. MediaHandle is not passed. whatsmeow only uses SendRequestExtra.MediaHandle for newsletters ("When sending media to newsletters, the Handle field returned by the file upload", read only by sendNewsletter). Newsletters keep the inline thumbnail in this PR, and the large card rendered in the device test without a handle.
  3. PreviewType is NONE, not IMAGE. NONE is what Baileys sends for link previews, and the large card rendered with it in the device test. Switching to IMAGE is a one-line change if you prefer it.
  4. Untrusted image bodies. Fixed in 72ee1b3: the image header is checked before decoding and anything above 25 megapixels is rejected, so a small file that declares a huge bitmap can no longer exhaust memory. Covered by TestCheckLinkPreviewDimensions_RejectsDecompressionBomb. On SSRF: the previous code already fetched the caller's URL and the page's og:image with an unbounded http.Get. This PR bounds those requests (timeout, size limit, non-2xx check) and only uploads bytes that decode as an image. Blocking private addresses would break setups that serve imgUrl from an internal bucket, so that decision is left to the maintainers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/send/link only produces a small link preview — high-res thumbnail (MediaLinkThumbnail) is not uploaded

1 participant