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
107 changes: 72 additions & 35 deletions pkg/sendMessage/service/send_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1028,15 +1028,20 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte,

switch data.Type {
case "image":
// Generate a JPEG preview thumbnail for better client UX (iOS in
// particular). On failure jpegThumb is nil and the message is sent
// without a preview rather than failing the request.
jpegThumb := makeJPEGThumbnail(fileData, 72)
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,
Caption: proto.String(data.Caption),
URL: &uploaded.URL,
DirectPath: &uploaded.DirectPath,
Mimetype: proto.String(mimeType),
FileSHA256: uploaded.FileSHA256,
FileLength: &uploaded.FileLength,
JPEGThumbnail: jpegThumb,
}}
} else {
// Normal: COM MediaKey e FileEncSHA256
Expand All @@ -1049,6 +1054,7 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte,
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(fileData))),
JPEGThumbnail: jpegThumb,
}}
}
mediaType = "ImageMessage"
Expand Down Expand Up @@ -1312,15 +1318,20 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc

switch data.Type {
case "image":
// Generate a JPEG preview thumbnail for better client UX (iOS in
// particular). On failure jpegThumb is nil and the message is sent
// without a preview rather than failing the request.
jpegThumb := makeJPEGThumbnail(fileData, 72)
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,
Caption: proto.String(data.Caption),
URL: &uploaded.URL,
DirectPath: &uploaded.DirectPath,
Mimetype: proto.String(mimeType),
FileSHA256: uploaded.FileSHA256,
FileLength: &uploaded.FileLength,
JPEGThumbnail: jpegThumb,
}}
} else {
// Normal: com criptografia
Expand All @@ -1333,6 +1344,7 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(fileData))),
JPEGThumbnail: jpegThumb,
}}
}
mediaType = "ImageMessage"
Expand Down Expand Up @@ -1875,6 +1887,53 @@ func stringPointer(s string) *string {
return &s
}

// makeJPEGThumbnail decodes raw image bytes and produces a small JPEG
// thumbnail suitable for the JPEGThumbnail field of WhatsApp media messages.
// The thumbnail keeps the original aspect ratio and is capped at maxWidth
// pixels wide. It returns nil if the image cannot be decoded so callers can
// fall back to sending the message without a preview thumbnail.
func makeJPEGThumbnail(fileData []byte, maxWidth int) []byte {
if maxWidth < 1 {
maxWidth = 72
}

img, _, err := image.Decode(bytes.NewReader(fileData))
if err != nil {
return nil
}

bounds := img.Bounds()
srcWidth := bounds.Dx()
srcHeight := bounds.Dy()
if srcWidth < 1 || srcHeight < 1 {
return nil
}

thumbWidth := maxWidth
if srcWidth < thumbWidth {
thumbWidth = srcWidth
}
thumbHeight := int(float64(srcHeight) * float64(thumbWidth) / float64(srcWidth))
if thumbHeight < 1 {
thumbHeight = 1
}

thumbImg := image.NewRGBA(image.Rect(0, 0, thumbWidth, thumbHeight))
for y := 0; y < thumbHeight; y++ {
for x := 0; x < thumbWidth; x++ {
srcX := x * srcWidth / thumbWidth
srcY := y * srcHeight / thumbHeight
thumbImg.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y))
}
}

var thumbBuf bytes.Buffer
if err := jpeg.Encode(&thumbBuf, thumbImg, &jpeg.Options{Quality: 50}); err != nil {
return nil
}
return thumbBuf.Bytes()
}

func sectionsToString(data *ListStruct) (string, error) {
type row struct {
Header string `json:"header"`
Expand Down Expand Up @@ -2515,29 +2574,7 @@ func (s *sendService) SendCarousel(data *CarouselStruct, instance *instance_mode
uploaded, err := client.Upload(context.Background(), fileData, whatsmeow.MediaImage)
if err == nil {
// Generate JPEG thumbnail for iOS compatibility
var jpegThumb []byte
img, _, decErr := image.Decode(bytes.NewReader(fileData))
if decErr == nil {
// Resize to 72px thumbnail
bounds := img.Bounds()
thumbWidth := 72
thumbHeight := int(float64(bounds.Dy()) * float64(thumbWidth) / float64(bounds.Dx()))
if thumbHeight < 1 {
thumbHeight = 1
}
thumbImg := image.NewRGBA(image.Rect(0, 0, thumbWidth, thumbHeight))
for y := 0; y < thumbHeight; y++ {
for x := 0; x < thumbWidth; x++ {
srcX := x * bounds.Dx() / thumbWidth
srcY := y * bounds.Dy() / thumbHeight
thumbImg.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y))
}
}
var thumbBuf bytes.Buffer
if jpeg.Encode(&thumbBuf, thumbImg, &jpeg.Options{Quality: 50}) == nil {
jpegThumb = thumbBuf.Bytes()
}
}
jpegThumb := makeJPEGThumbnail(fileData, 72)

header.HasMediaAttachment = proto.Bool(true)
header.Media = &waE2E.InteractiveMessage_Header_ImageMessage{
Expand Down
95 changes: 95 additions & 0 deletions pkg/sendMessage/service/thumbnail_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package send_service

import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"testing"
)

// encodePNG builds a simple solid-color PNG of the given size for testing.
func encodePNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("failed to encode test PNG: %v", err)
}
return buf.Bytes()
}

func TestMakeJPEGThumbnail_ResizesLandscape(t *testing.T) {
src := encodePNG(t, 800, 400)

thumb := makeJPEGThumbnail(src, 72)
if thumb == nil {
t.Fatal("expected a thumbnail, got nil")
}

cfg, format, err := image.DecodeConfig(bytes.NewReader(thumb))
if err != nil {
t.Fatalf("thumbnail is not a decodable image: %v", err)
}
if format != "jpeg" {
t.Fatalf("expected jpeg thumbnail, got %q", format)
}
if cfg.Width != 72 {
t.Fatalf("expected width 72, got %d", cfg.Width)
}
// Aspect ratio (2:1) must be preserved: 72 wide -> 36 tall.
if cfg.Height != 36 {
t.Fatalf("expected height 36 to preserve aspect ratio, got %d", cfg.Height)
}
}

func TestMakeJPEGThumbnail_DoesNotUpscaleSmallImages(t *testing.T) {
src := encodePNG(t, 40, 40)

thumb := makeJPEGThumbnail(src, 72)
if thumb == nil {
t.Fatal("expected a thumbnail, got nil")
}

cfg, _, err := image.DecodeConfig(bytes.NewReader(thumb))
if err != nil {
t.Fatalf("thumbnail is not a decodable image: %v", err)
}
if cfg.Width != 40 || cfg.Height != 40 {
t.Fatalf("expected the thumbnail to keep the original 40x40 size, got %dx%d", cfg.Width, cfg.Height)
}
}

func TestMakeJPEGThumbnail_InvalidInputReturnsNil(t *testing.T) {
if thumb := makeJPEGThumbnail([]byte("not an image"), 72); thumb != nil {
t.Fatalf("expected nil for non-image input, got %d bytes", len(thumb))
}
if thumb := makeJPEGThumbnail(nil, 72); thumb != nil {
t.Fatalf("expected nil for nil input, got %d bytes", len(thumb))
}
}

func TestMakeJPEGThumbnail_DefaultsInvalidMaxWidth(t *testing.T) {
src := encodePNG(t, 800, 800)

Comment on lines +78 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Also test negative maxWidth to match the helper’s documented behavior

This helper treats any maxWidth < 1 as invalid and defaults to 72, but this test only covers 0. Please either add coverage for a negative maxWidth (e.g. -5) asserting it still defaults to 72, or rename the test to indicate it specifically covers maxWidth == 0.

Suggested change
func TestMakeJPEGThumbnail_DefaultsInvalidMaxWidth(t *testing.T) {
src := encodePNG(t, 800, 800)
func TestMakeJPEGThumbnail_DefaultsInvalidMaxWidth(t *testing.T) {
src := encodePNG(t, 800, 800)
// maxWidth == 0 should default to 72
thumbZero := makeJPEGThumbnail(src, 0)
if thumbZero == nil {
t.Fatalf("expected non-nil thumbnail for maxWidth == 0")
}
imgZero, err := jpeg.Decode(bytes.NewReader(thumbZero))
if err != nil {
t.Fatalf("failed to decode thumbnail for maxWidth == 0: %v", err)
}
if got := imgZero.Bounds().Dx(); got != 72 {
t.Fatalf("expected width 72 for maxWidth == 0, got %d", got)
}
// negative maxWidth should also default to 72
thumbNegative := makeJPEGThumbnail(src, -5)
if thumbNegative == nil {
t.Fatalf("expected non-nil thumbnail for negative maxWidth")
}
imgNegative, err := jpeg.Decode(bytes.NewReader(thumbNegative))
if err != nil {
t.Fatalf("failed to decode thumbnail for negative maxWidth: %v", err)
}
if got := imgNegative.Bounds().Dx(); got != 72 {
t.Fatalf("expected width 72 for negative maxWidth, got %d", got)
}

thumb := makeJPEGThumbnail(src, 0)
if thumb == nil {
t.Fatal("expected a thumbnail with defaulted maxWidth, got nil")
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(thumb))
if err != nil {
t.Fatalf("thumbnail is not a decodable image: %v", err)
}
if cfg.Width != 72 {
t.Fatalf("expected defaulted width 72, got %d", cfg.Width)
}
}

// ensure the jpeg encoder import stays referenced even if the helper changes.
var _ = jpeg.DefaultQuality