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
40 changes: 40 additions & 0 deletions Dockerfile.custom
Original file line number Diff line number Diff line change
@@ -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"]
192 changes: 143 additions & 49 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 @@ -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{
Expand All @@ -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,
}}
}

Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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{
Expand All @@ -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,
}}
}

Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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{
Expand Down
Loading