fix: include image metadata for sends

This commit is contained in:
Peter Steinberger 2026-05-05 06:17:30 +01:00
parent 3031a34ff2
commit b4ca2e35b0
No known key found for this signature in database
3 changed files with 157 additions and 9 deletions

View File

@ -56,6 +56,7 @@
- Send: strip a leading `+` from phone-number recipients before building WhatsApp JIDs. (#74 — thanks @FrederickStempfle)
- Search: keep FTS5 enabled after reopening existing databases with already-applied migrations. (#185 — thanks @iamhitarth)
- Send: add `send text --reply-to` for quoted replies, with sender inference for synced group messages. (#154 — thanks @draix)
- Send: validate image uploads and include image dimensions plus a JPEG thumbnail for better client rendering.
- Send: keep the connection alive briefly after successful sends so retry receipts can repair first-send session gaps. (#89 — thanks @alexph-dev)
- Send: bound send attempts and reconnect once for stale-session/time-out failures instead of hanging indefinitely. (#115 — thanks @0xatrilla)
- Send: include the Opus codec parameter when sending OGG audio so WhatsApp delivers it as audio. (#41 — thanks @emre6943)

View File

@ -1,9 +1,16 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"math"
"mime"
"net/http"
@ -24,6 +31,7 @@ import (
)
const maxSendFileSize = 100 * 1024 * 1024
const imageThumbnailMaxDimension = 96
const voiceWaveformSamples = 64
const voiceWaveformMax = 100
@ -91,16 +99,11 @@ func sendFile(ctx context.Context, a interface {
switch mediaType {
case "image":
msg.ImageMessage = &waProto.ImageMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
Caption: proto.String(opts.caption),
imageMsg, err := newImageMessage(up, mimeType, opts.caption, data)
if err != nil {
return "", nil, err
}
msg.ImageMessage = imageMsg
case "video":
msg.VideoMessage = &waProto.VideoMessage{
URL: proto.String(up.URL),
@ -166,6 +169,83 @@ func sendFile(ctx context.Context, a interface {
}, nil
}
func newImageMessage(up whatsmeow.UploadResponse, mimeType, caption string, data []byte) (*waProto.ImageMessage, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("invalid image data: %w", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil, fmt.Errorf("invalid image dimensions: %dx%d", cfg.Width, cfg.Height)
}
msg := &waProto.ImageMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
Caption: proto.String(caption),
Height: proto.Uint32(uint32(cfg.Height)),
Width: proto.Uint32(uint32(cfg.Width)),
}
if thumbnail, err := imageJPEGThumbnail(data); err == nil && len(thumbnail) > 0 {
msg.JPEGThumbnail = thumbnail
}
return msg, nil
}
func imageJPEGThumbnail(data []byte) ([]byte, error) {
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
bounds := src.Bounds()
srcW, srcH := bounds.Dx(), bounds.Dy()
if srcW <= 0 || srcH <= 0 {
return nil, fmt.Errorf("invalid image dimensions: %dx%d", srcW, srcH)
}
dstW, dstH := scaledDimensions(srcW, srcH, imageThumbnailMaxDimension)
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
for y := 0; y < dstH; y++ {
for x := 0; x < dstW; x++ {
srcX := bounds.Min.X + x*srcW/dstW
srcY := bounds.Min.Y + y*srcH/dstH
dst.Set(x, y, src.At(srcX, srcY))
}
}
var out bytes.Buffer
if err := jpeg.Encode(&out, dst, &jpeg.Options{Quality: 75}); err != nil {
return nil, err
}
return out.Bytes(), nil
}
func scaledDimensions(width, height, maxDimension int) (int, int) {
if width <= 0 || height <= 0 {
return 0, 0
}
if maxDimension <= 0 || (width <= maxDimension && height <= maxDimension) {
return width, height
}
if width >= height {
scaledHeight := height * maxDimension / width
if scaledHeight < 1 {
scaledHeight = 1
}
return maxDimension, scaledHeight
}
scaledWidth := width * maxDimension / height
if scaledWidth < 1 {
scaledWidth = 1
}
return scaledWidth, maxDimension
}
func newAudioMessage(up whatsmeow.UploadResponse, mimeType string, ptt bool, meta voiceNoteMetadata) *waProto.AudioMessage {
msg := &waProto.AudioMessage{
URL: proto.String(up.URL),

View File

@ -1,8 +1,13 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"image"
"image/color"
"image/jpeg"
"image/png"
"os"
"os/exec"
"path/filepath"
@ -110,6 +115,68 @@ func TestNewAudioMessageAttachesPTTMetadata(t *testing.T) {
}
}
func TestNewImageMessageAttachesDimensionsAndThumbnail(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 120, 60))
for y := 0; y < 60; y++ {
for x := 0; x < 120; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 120, A: 255})
}
}
var data bytes.Buffer
if err := png.Encode(&data, img); err != nil {
t.Fatalf("png.Encode: %v", err)
}
up := whatsmeow.UploadResponse{
URL: "https://upload",
DirectPath: "/path",
MediaKey: []byte("key"),
FileEncSHA256: []byte("enc"),
FileSHA256: []byte("plain"),
FileLength: uint64(data.Len()),
}
msg, err := newImageMessage(up, "image/png", "caption", data.Bytes())
if err != nil {
t.Fatalf("newImageMessage: %v", err)
}
if msg.GetWidth() != 120 || msg.GetHeight() != 60 {
t.Fatalf("dimensions = %dx%d, want 120x60", msg.GetWidth(), msg.GetHeight())
}
if msg.GetCaption() != "caption" {
t.Fatalf("caption = %q", msg.GetCaption())
}
if len(msg.GetJPEGThumbnail()) == 0 {
t.Fatalf("missing JPEG thumbnail")
}
if _, err := jpeg.Decode(bytes.NewReader(msg.GetJPEGThumbnail())); err != nil {
t.Fatalf("thumbnail is not JPEG: %v", err)
}
}
func TestNewImageMessageRejectsInvalidImageData(t *testing.T) {
_, err := newImageMessage(whatsmeow.UploadResponse{}, "image/png", "", []byte("not an image"))
if err == nil || !strings.Contains(err.Error(), "invalid image data") {
t.Fatalf("expected invalid image error, got %v", err)
}
}
func TestScaledDimensions(t *testing.T) {
for _, tc := range []struct {
width, height int
wantW, wantH int
}{
{width: 120, height: 60, wantW: 96, wantH: 48},
{width: 60, height: 120, wantW: 48, wantH: 96},
{width: 40, height: 30, wantW: 40, wantH: 30},
{width: 1, height: 1000, wantW: 1, wantH: 96},
} {
gotW, gotH := scaledDimensions(tc.width, tc.height, imageThumbnailMaxDimension)
if gotW != tc.wantW || gotH != tc.wantH {
t.Fatalf("scaledDimensions(%d,%d) = %dx%d, want %dx%d", tc.width, tc.height, gotW, gotH, tc.wantW, tc.wantH)
}
}
}
func TestWaveformFromPCM16LE(t *testing.T) {
data := make([]byte, voiceWaveformSamples*4)
for i := 0; i < voiceWaveformSamples*2; i++ {