From c7ffe05e8e96939f4bc5600d3eea1806aa29439b Mon Sep 17 00:00:00 2001 From: Samuel Nunes Date: Mon, 22 Jun 2026 22:44:59 -0300 Subject: [PATCH] feat(send): embed JPEG thumbnail in image messages WhatsApp renders the gray/green camera placeholder for ImageMessages sent without a JPEGThumbnail, only showing the picture after a manual download. This affects /send/media for both regular chats and newsletters (channels). Generate a small (~72px) JPEG preview from the source bytes and set it on ImageMessage.JPEGThumbnail across all media image paths (sendMediaFileWithRetry, sendMediaUrlWithRetry, sendStatusMedia) so the usual blurred inline preview is shown. Generation failures degrade gracefully: the message is still sent, just without the inline preview. Co-Authored-By: Claude Opus 4.8 --- pkg/sendMessage/service/send_service.go | 64 ++++++++++++++++++++----- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/pkg/sendMessage/service/send_service.go b/pkg/sendMessage/service/send_service.go index c6ecdcdd..4b537139 100644 --- a/pkg/sendMessage/service/send_service.go +++ b/pkg/sendMessage/service/send_service.go @@ -1028,19 +1028,22 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte, switch data.Type { case "image": + thumbnail := generateJpegThumbnail(fileData) if isNewsletter { // Newsletter: SEM MediaKey e FileEncSHA256 media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ - Caption: proto.String(data.Caption), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - Mimetype: proto.String(mimeType), - FileSHA256: uploaded.FileSHA256, - FileLength: &uploaded.FileLength, + JPEGThumbnail: thumbnail, + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, }} } else { // Normal: COM MediaKey e FileEncSHA256 media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ + JPEGThumbnail: thumbnail, Caption: proto.String(data.Caption), URL: proto.String(uploaded.URL), DirectPath: proto.String(uploaded.DirectPath), @@ -1312,19 +1315,22 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc switch data.Type { case "image": + thumbnail := generateJpegThumbnail(fileData) if isNewsletter { // Newsletter: sem criptografia (sem MediaKey e FileEncSHA256) media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ - Caption: proto.String(data.Caption), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - Mimetype: proto.String(mimeType), - FileSHA256: uploaded.FileSHA256, - FileLength: &uploaded.FileLength, + JPEGThumbnail: thumbnail, + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, }} } else { // Normal: com criptografia media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ + JPEGThumbnail: thumbnail, Caption: proto.String(data.Caption), URL: proto.String(uploaded.URL), DirectPath: proto.String(uploaded.DirectPath), @@ -2846,7 +2852,9 @@ func (s *sendService) sendStatusMedia(client *whatsmeow.Client, data *StatusMedi switch data.Type { case "image": + thumbnail := generateJpegThumbnail(fileData) media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ + JPEGThumbnail: thumbnail, Caption: proto.String(data.Caption), URL: proto.String(uploaded.URL), DirectPath: proto.String(uploaded.DirectPath), @@ -2957,3 +2965,35 @@ func NewSendService( loggerWrapper: loggerWrapper, } } + +// generateJpegThumbnail builds a small JPEG preview (~72px wide) to embed in +// ImageMessage.JPEGThumbnail. Without it WhatsApp shows the gray/green camera +// placeholder and only renders the image after a manual download; with it the +// usual blurred inline preview is shown. Returns nil on failure, in which case +// the message is still sent (just without the inline preview). +func generateJpegThumbnail(data []byte) []byte { + src, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil + } + b := src.Bounds() + if b.Dx() == 0 || b.Dy() == 0 { + return nil + } + const targetWidth = 72 + height := b.Dy() * targetWidth / b.Dx() + if height < 1 { + height = 1 + } + thumb := image.NewRGBA(image.Rect(0, 0, targetWidth, height)) + for y := 0; y < height; y++ { + for x := 0; x < targetWidth; x++ { + thumb.Set(x, y, src.At(b.Min.X+x*b.Dx()/targetWidth, b.Min.Y+y*b.Dy()/height)) + } + } + var buf bytes.Buffer + if jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 50}) != nil { + return nil + } + return buf.Bytes() +}