diff --git a/pkg/sendMessage/service/send_service.go b/pkg/sendMessage/service/send_service.go index c6ecdcdd..51bd4d80 100644 --- a/pkg/sendMessage/service/send_service.go +++ b/pkg/sendMessage/service/send_service.go @@ -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 @@ -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" @@ -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 @@ -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" @@ -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"` @@ -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{ diff --git a/pkg/sendMessage/service/thumbnail_test.go b/pkg/sendMessage/service/thumbnail_test.go new file mode 100644 index 00000000..088cc754 --- /dev/null +++ b/pkg/sendMessage/service/thumbnail_test.go @@ -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) + + 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