diff --git a/Dockerfile.custom b/Dockerfile.custom new file mode 100644 index 00000000..4fd0c43c --- /dev/null +++ b/Dockerfile.custom @@ -0,0 +1,40 @@ +# Dockerfile.custom +# +# Same build as the upstream Dockerfile, with poppler-utils added to the +# runtime image so PDF document thumbnails (pdftoppm) work in production. +# Kept separate from the upstream Dockerfile to keep the contribution diff clean. + +FROM golang:1.25.0-alpine AS build + +RUN apk update && apk add --no-cache git build-base libjpeg-turbo-dev libwebp-dev + +WORKDIR /build + +# Copy dependency files first to cache the module download layer. +COPY go.mod go.sum ./ + +# whatsmeow-lib is a local replace dependency (see go.mod). +COPY whatsmeow-lib/ ./whatsmeow-lib/ + +RUN go mod download + +# Copy the rest of the source. +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=1 go build -ldflags "-X main.version=${VERSION}" -o server ./cmd/evolution-go + +FROM alpine:3.19.1 AS final + +# poppler-utils provides pdftoppm, used to rasterize PDF page 1 for thumbnails. +RUN apk update && apk add --no-cache tzdata ffmpeg libjpeg-turbo libwebp poppler-utils + +WORKDIR /app + +COPY --from=build /build/server . +COPY --from=build /build/manager/dist ./manager/dist +COPY --from=build /build/VERSION ./VERSION + +ENV TZ=America/Sao_Paulo + +ENTRYPOINT ["/app/server"] diff --git a/pkg/sendMessage/service/send_service.go b/pkg/sendMessage/service/send_service.go index c6ecdcdd..d625cd3d 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" @@ -1122,15 +1128,23 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte, } mediaType = "AudioMessage" case "document": + // For PDF documents, rasterize page 1 into a JPEG preview thumbnail. + // A missing pdftoppm or a failure yields nil and the document is + // sent without a preview instead of failing the request. + var jpegThumb []byte + if mimeType == "application/pdf" { + jpegThumb = makePDFThumbnail(fileData, 200) + } if isNewsletter { media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ - FileName: &data.Filename, - Caption: proto.String(data.Caption), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - Mimetype: proto.String(mimeType), - FileSHA256: uploaded.FileSHA256, - FileLength: &uploaded.FileLength, + FileName: &data.Filename, + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, }} } else { media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ @@ -1143,6 +1157,7 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte, FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, }} } @@ -1312,15 +1327,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 +1353,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" @@ -1410,15 +1431,23 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc } mediaType = "AudioMessage" case "document": + // For PDF documents, rasterize page 1 into a JPEG preview thumbnail. + // A missing pdftoppm or a failure yields nil and the document is + // sent without a preview instead of failing the request. + var jpegThumb []byte + if mimeType == "application/pdf" { + jpegThumb = makePDFThumbnail(fileData, 200) + } if isNewsletter { media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ - URL: &uploaded.URL, - FileName: &data.Filename, - Caption: proto.String(data.Caption), - DirectPath: &uploaded.DirectPath, - Mimetype: proto.String(mimeType), - FileSHA256: uploaded.FileSHA256, - FileLength: &uploaded.FileLength, + URL: &uploaded.URL, + FileName: &data.Filename, + Caption: proto.String(data.Caption), + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, }} } else { media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ @@ -1431,6 +1460,7 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, }} } @@ -1875,6 +1905,92 @@ 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() +} + +// makePDFThumbnail rasterizes the first page of a PDF into a JPEG thumbnail +// using the external "pdftoppm" tool (poppler-utils). It returns nil when +// pdftoppm is not installed or rasterization fails, so callers can gracefully +// send the document without a preview instead of failing the request. +func makePDFThumbnail(fileData []byte, maxWidth int) []byte { + if _, err := exec.LookPath("pdftoppm"); err != nil { + return nil + } + + scaleWidth := maxWidth + if scaleWidth < 1 { + scaleWidth = 72 + } + + // Render only the first page to a PNG on stdout, scaled to scaleWidth. + // "-scale-to-y -1" keeps the original aspect ratio. + cmd := exec.Command("pdftoppm", + "-png", + "-f", "1", + "-l", "1", + "-singlefile", + "-scale-to-x", strconv.Itoa(scaleWidth), + "-scale-to-y", "-1", + ) + cmd.Stdin = bytes.NewReader(fileData) + + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return nil + } + if out.Len() == 0 { + return nil + } + + // Re-encode the rendered PNG as a JPEG thumbnail for consistency with images. + return makeJPEGThumbnail(out.Bytes(), maxWidth) +} + func sectionsToString(data *ListStruct) (string, error) { type row struct { Header string `json:"header"` @@ -2515,29 +2631,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..87460383 --- /dev/null +++ b/pkg/sendMessage/service/thumbnail_test.go @@ -0,0 +1,142 @@ +package send_service + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "os/exec" + "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) + } +} + +func TestMakePDFThumbnail_InvalidInputReturnsNil(t *testing.T) { + // With or without pdftoppm installed, garbage input must never panic and + // must yield nil so the caller falls back to sending without a preview. + if thumb := makePDFThumbnail([]byte("%PDF-not-really"), 200); thumb != nil { + t.Fatalf("expected nil for invalid PDF input, got %d bytes", len(thumb)) + } + if thumb := makePDFThumbnail(nil, 200); thumb != nil { + t.Fatalf("expected nil for nil input, got %d bytes", len(thumb)) + } +} + +func TestMakePDFThumbnail_RendersFirstPageWhenPopplerAvailable(t *testing.T) { + if _, err := exec.LookPath("pdftoppm"); err != nil { + t.Skip("pdftoppm not installed; skipping PDF rasterization test") + } + + pdf := minimalPDF(t) + thumb := makePDFThumbnail(pdf, 200) + if thumb == nil { + t.Fatal("expected a thumbnail from a valid PDF, got nil") + } + + cfg, format, err := image.DecodeConfig(bytes.NewReader(thumb)) + if err != nil { + t.Fatalf("PDF thumbnail is not a decodable image: %v", err) + } + if format != "jpeg" { + t.Fatalf("expected jpeg thumbnail, got %q", format) + } + if cfg.Width < 1 || cfg.Width > 200 { + t.Fatalf("expected width within (0, 200], got %d", cfg.Width) + } +} + +// minimalPDF returns the bytes of a tiny, valid single-page PDF. +func minimalPDF(t *testing.T) []byte { + t.Helper() + const doc = "%PDF-1.1\n" + + "1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n" + + "2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n" + + "3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>endobj\n" + + "trailer<< /Root 1 0 R >>\n" + + "%%EOF\n" + return []byte(doc) +} + +// ensure the jpeg encoder import stays referenced even if the helper changes. +var _ = jpeg.DefaultQuality