+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "image"
+ "image/gif"
+ "image/jpeg"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/proto/waE2E"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+func TestWhatsAppImagesAreDurableBeforeAcknowledgement(t *testing.T) {
+ testCases := []struct {
+ name string
+ sender types.JID
+ }{
+ {
+ name: "a contact image keeps its caption and pixels",
+ sender: whatsappTestAna,
+ },
+ {
+ name: "an own image keeps its caption and pixels",
+ sender: whatsappTestOwn,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
+ whatsapp.client = fake
+ data := imageTestPNG(t, 3, 2)
+ fake.downloadData = data
+ event := whatsappImageTestEvent(tc.sender, "Read this label.")
+ require.True(t, whatsapp.handle(event))
+ require.Len(t, fake.Downloads(), 1)
+ require.Same(t, event.Message.ImageMessage, fake.Downloads()[0])
+
+ restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
+ require.NoError(t, restored.CloseAll())
+ require.Len(t, restored.Closed(), 1)
+
+ batch := restored.Closed()[0]
+ names := imageFiles(batch.Messages[0].Text)
+ require.Len(t, names, 1)
+ require.Equal(t, tc.sender == whatsappTestOwn, batch.Messages[0].Mine)
+ require.Equal(t, imageText(names, "Read this label."), batch.Messages[0].Text)
+ require.Equal(t, names, imageFiles(batch.Text(time.UTC)))
+
+ store := &ImageStore{dir: filepath.Join(dir, imageDirName)}
+ saved, err := store.Read(names[0])
+ require.NoError(t, err)
+ require.Equal(t, data, saved)
+
+ info, err := os.Stat(filepath.Join(store.dir, names[0]))
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+ require.NotContains(t, logs.String(), "Read this label")
+ require.NotContains(t, logs.String(), "base64")
+ })
+ }
+}
+
+func TestWhatsAppImageDownloadCanOutlastNameLookup(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ whatsapp.inbox.lookupTime = 10 * time.Millisecond
+ whatsapp.inbox.imageTime = time.Second
+ fake.downloadWait = 50 * time.Millisecond
+ data := imageTestPNG(t, 3, 2)
+ fake.downloadData = data
+
+ require.True(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Slow photo.")))
+
+ restored := whatsappTestState(t, whatsapp.state.path)
+ require.NoError(t, restored.CloseAll())
+ require.Len(t, restored.Closed(), 1)
+
+ text := restored.Closed()[0].Messages[0].Text
+ names := imageFiles(text)
+ require.Len(t, names, 1)
+ require.Equal(t, imageText(names, "Slow photo."), text)
+
+ saved, err := whatsapp.inbox.images.Read(names[0])
+ require.NoError(t, err)
+ require.Equal(t, data, saved)
+}
+
+func TestWhatsAppImageFailureKeepsTheBatchMoving(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
+ whatsapp.client = fake
+ fake.downloadErr = errors.New("download failed for " + whatsappTestNumber + " and " + whatsappTestAna.User)
+ event := whatsappImageTestEvent(whatsappTestAna, "Keep this caption.")
+ require.True(t, whatsapp.handle(event))
+ require.True(t, whatsapp.handle(whatsappTestMessage(whatsappTestAna, whatsappTestAna, "next", "Next text.", event.Info.Timestamp)))
+
+ restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
+ require.NoError(t, restored.CloseAll())
+ require.Len(t, restored.Closed(), 1)
+
+ messages := restored.Closed()[0].Messages
+ require.Len(t, messages, 2)
+ require.Equal(t, "(image not downloaded)\nKeep this caption.", messages[0].Text)
+ require.Equal(t, "Next text.", messages[1].Text)
+ require.Empty(t, imageFiles(restored.Closed()[0].Text(time.UTC)))
+ require.Contains(t, logs.String(), "<3>image receive failed chat=")
+ require.Contains(t, logs.String(), ": download image: media download failed")
+ require.NotContains(t, logs.String(), whatsappTestNumber)
+ require.NotContains(t, logs.String(), whatsappTestAna.User)
+ require.NotContains(t, logs.String(), "Keep this caption")
+ require.NotContains(t, logs.String(), "base64")
+}
+
+func TestWhatsAppImageDownloadErrorsKeepOnlySafeCauses(t *testing.T) {
+ mediaURL := "https://example.com/media/secret-path?auth=secret-token&hash=secret-hash"
+ directPath := "media/secret-path?auth=secret-token&hash=secret-hash"
+ testCases := []struct {
+ name string
+ err error
+ cause string
+ }{
+ {
+ name: "signed request URLs do not reach the log",
+ err: &url.Error{
+ Op: "Get",
+ URL: mediaURL,
+ Err: errors.New("connection failed"),
+ },
+ cause: "media download failed",
+ },
+ {
+ name: "invalid direct paths do not reach the log",
+ err: fmt.Errorf("media download path does not start with slash: %s", directPath),
+ cause: "media download failed",
+ },
+ {
+ name: "request preparation errors do not expose media paths",
+ err: fmt.Errorf("failed to prepare request: parse %q: invalid control character", mediaURL),
+ cause: "media download failed",
+ },
+ {
+ name: "cancellation keeps its safe cause without the URL",
+ err: fmt.Errorf("request %s: %w", mediaURL, context.Canceled),
+ cause: "context canceled",
+ },
+ {
+ name: "a deadline keeps its safe cause without the path",
+ err: fmt.Errorf("request %s: %w", directPath, context.DeadlineExceeded),
+ cause: "context deadline exceeded",
+ },
+ {
+ name: "HTTP failures keep only the status code",
+ err: whatsmeow.DownloadHTTPError{Response: &http.Response{
+ StatusCode: http.StatusForbidden,
+ Status: "403 " + mediaURL,
+ }},
+ cause: "media server returned HTTP 403",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
+ whatsapp.client = fake
+ fake.downloadErr = tc.err
+
+ require.True(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Keep this caption.")))
+ require.NoError(t, whatsapp.state.CloseAll())
+ require.Equal(t, "(image not downloaded)\nKeep this caption.", whatsapp.state.Closed()[0].Messages[0].Text)
+ require.Contains(t, logs.String(), "<3>image receive failed chat=")
+ require.Contains(t, logs.String(), ": download image: "+tc.cause)
+
+ for _, secret := range []string{mediaURL, directPath, "example.com", "secret-path", "secret-token", "secret-hash", whatsappTestAna.User, "Keep this caption."} {
+ require.NotContains(t, logs.String(), secret)
+ }
+ })
+ }
+}
+
+func TestWhatsAppImageLimitsKeepMarkersWithoutStoredPixels(t *testing.T) {
+ var gifBytes bytes.Buffer
+
+ require.NoError(t, gif.Encode(&gifBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
+
+ testCases := []struct {
+ name string
+ mime string
+ size uint64
+ data []byte
+ downloads int
+ }{
+ {
+ name: "declared large images are not downloaded",
+ mime: "image/png",
+ size: imageMaxBytes + 1,
+ },
+ {
+ name: "large downloaded images become markers",
+ mime: "image/png",
+ data: bytes.Repeat([]byte{0}, imageMaxBytes+1),
+ downloads: 1,
+ },
+ {
+ name: "unsupported image types are not downloaded",
+ mime: "image/gif",
+ },
+ {
+ name: "GIF bytes cannot claim to be PNG",
+ mime: "image/png",
+ data: gifBytes.Bytes(),
+ downloads: 1,
+ },
+ {
+ name: "invalid image bytes become markers",
+ mime: "image/png",
+ data: []byte("not an image"),
+ downloads: 1,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ fake.downloadData = tc.data
+ event := whatsappImageTestEvent(whatsappTestAna, "")
+ event.Message.ImageMessage.Mimetype = new(tc.mime)
+ event.Message.ImageMessage.FileLength = new(tc.size)
+ require.True(t, whatsapp.handle(event))
+ require.Len(t, fake.Downloads(), tc.downloads)
+ require.NoError(t, whatsapp.state.CloseAll())
+ require.Len(t, whatsapp.state.Closed(), 1)
+ require.Equal(t, "(image not downloaded)", whatsapp.state.Closed()[0].Messages[0].Text)
+
+ _, err := os.Stat(whatsapp.inbox.images.dir)
+ require.ErrorIs(t, err, os.ErrNotExist)
+ })
+ }
+}
+
+func TestWhatsAppSupportsJPEGPNGAndWebPBytes(t *testing.T) {
+ var jpegBytes bytes.Buffer
+
+ require.NoError(t, jpeg.Encode(&jpegBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
+
+ testCases := []struct {
+ name string
+ mime string
+ data []byte
+ suffix string
+ }{
+ {
+ name: "PNG bytes keep the PNG extension",
+ mime: "image/png",
+ data: imageTestPNG(t, 2, 2),
+ suffix: ".png",
+ },
+ {
+ name: "JPEG bytes set the JPEG extension even with a PNG label",
+ mime: "image/png",
+ data: jpegBytes.Bytes(),
+ suffix: ".jpg",
+ },
+ {
+ name: "WebP bytes keep the WebP extension",
+ mime: "image/webp",
+ data: []byte{'R', 'I', 'F', 'F', 22, 0, 0, 0, 'W', 'E', 'B', 'P', 'V', 'P', '8', 'L', 10, 0, 0, 0, 0x2f, 0, 0, 0, 0, 0x07, 0x10, 0x11, 0x11, 0x88},
+ suffix: ".webp",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ fake.downloadData = tc.data
+ event := whatsappImageTestEvent(whatsappTestAna, "")
+ event.Message.ImageMessage.Mimetype = new(tc.mime)
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.state.CloseAll())
+
+ names := imageFiles(whatsapp.state.Closed()[0].Text(time.UTC))
+ require.Len(t, names, 1)
+ require.True(t, strings.HasSuffix(names[0], tc.suffix))
+
+ data, err := whatsapp.inbox.images.Read(names[0])
+ require.NoError(t, err)
+ require.Equal(t, tc.data, data)
+ })
+ }
+}
+
+func TestWhatsAppMediaMarkersNeverDownload(t *testing.T) {
+ testCases := []struct {
+ name string
+ message *waE2E.Message
+ text string
+ }{
+ {
+ name: "stickers keep a text marker",
+ message: &waE2E.Message{
+ StickerMessage: &waE2E.StickerMessage{},
+ },
+ text: "(sticker)",
+ },
+ {
+ name: "videos keep a text marker and caption",
+ message: &waE2E.Message{
+ VideoMessage: &waE2E.VideoMessage{Caption: new("Watch this.")},
+ },
+ text: "(video)\nWatch this.",
+ },
+ {
+ name: "GIF playback keeps a text marker",
+ message: &waE2E.Message{
+ VideoMessage: &waE2E.VideoMessage{GifPlayback: new(true)},
+ },
+ text: "(GIF)",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ event := whatsappImageTestEvent(whatsappTestAna, "")
+ event.Message = tc.message
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.state.CloseAll())
+ require.Len(t, whatsapp.state.Closed(), 1)
+ require.Equal(t, tc.text, whatsapp.state.Closed()[0].Messages[0].Text)
+ require.Empty(t, fake.Downloads())
+ })
+ }
+}
+
+func TestWhatsAppPrivateImagesAreNotDownloaded(t *testing.T) {
+ testCases := []struct {
+ name string
+ change func(event *events.Message)
+ }{
+ {
+ name: "view-once events are not read",
+ change: func(event *events.Message) {
+ event.IsViewOnce = true
+ },
+ },
+ {
+ name: "view-once image fields are not read",
+ change: func(event *events.Message) {
+ event.Message.ImageMessage.ViewOnce = new(true)
+ },
+ },
+ {
+ name: "disappearing images are not read",
+ change: func(event *events.Message) {
+ event.IsEphemeral = true
+ },
+ },
+ {
+ name: "self-chat images are not read",
+ change: func(event *events.Message) {
+ event.Info.Chat = whatsappTestOwn
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ event := whatsappImageTestEvent(whatsappTestAna, "Secret caption.")
+ tc.change(event)
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.state.CloseAll())
+ require.Empty(t, whatsapp.state.Closed())
+ require.Empty(t, fake.Downloads())
+ })
+ }
+}
+
+func TestWhatsAppImageStateFailureDoesNotAcknowledge(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ fake.downloadData = imageTestPNG(t, 2, 2)
+ require.NoError(t, os.Mkdir(whatsapp.state.path, 0o700))
+ require.False(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Keep this.")))
+ require.ErrorContains(t, <-whatsapp.inbox.failures, "save WhatsApp state")
+}
+
+func TestWhatsAppImagesReachTheirRootOrRoutedChild(t *testing.T) {
+ testCases := []struct {
+ name string
+ target int
+ group bool
+ }{
+ {
+ name: "private images wake the root with pixels",
+ },
+ {
+ name: "routed group images wake only their child with pixels",
+ target: 1,
+ group: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ server := newModelFake(t)
+ server.reply = whatsappImageTestReply
+ test := newDaemonMessageTest(t, dir, server)
+ test.agents[0].agent.config.Model = "gpt-test-root"
+
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
+ whatsapp.client = fake
+ data := imageTestPNG(t, 3, 2)
+ fake.downloadData = data
+ event := whatsappImageTestEvent(whatsappTestAna, "What do you see?")
+ if tc.group {
+ event.Info.Chat = whatsappTestFamily
+ event.Info.IsGroup = true
+
+ whatsapp.groups.set(whatsappTestFamily.String(), "Garden plans")
+ require.NoError(t, test.messages.routes.Set("whatsapp:"+whatsappTestFamily.String(), "Garden"))
+ }
+
+ test.Start(t, tc.target)
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.inbox.deliver(t.Context(), test.loops[0], whatsapp.link(), true))
+ test.events[tc.target].WaitFor(t, "idle", 1)
+
+ text := test.events[tc.target].Last("work")
+ names := imageFiles(text)
+ require.Len(t, names, 1)
+ require.Contains(t, text, "[image "+names[0]+"]\nWhat do you see?")
+ require.Equal(t, 1, test.agents[tc.target].memory.Len())
+ require.Empty(t, whatsapp.state.Closed())
+
+ if tc.target != 0 {
+ require.Zero(t, test.agents[0].memory.Len())
+ require.False(t, test.loops[0].waiting())
+ }
+
+ model := test.agents[tc.target].agent.config.Model
+ turnCalls := 0
+ for index := range server.Calls() {
+ call := server.Call(index)
+ if call.body["model"] == model {
+ turnCalls++
+
+ require.Contains(t, string(call.raw), "data:image/png;base64,"+base64.StdEncoding.EncodeToString(data))
+ }
+ }
+
+ require.Equal(t, 1, turnCalls)
+ require.NotContains(t, logs.String()+test.logs.String(), "What do you see?")
+ require.NotContains(t, logs.String()+test.logs.String(), "base64")
+ })
+ }
+}
+
+func TestWhatsAppImageRoutingSurvivesRestartWithoutDuplicateEntries(t *testing.T) {
+ dir := t.TempDir()
+ server := newModelFake(t)
+ server.reply = whatsappImageTestReply
+ first := newDaemonMessageTest(t, dir, server)
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
+ whatsapp.client = fake
+ fake.downloadData = imageTestPNG(t, 3, 2)
+ event := whatsappImageTestEvent(whatsappTestAna, "Keep this image.")
+ require.NoError(t, first.messages.routes.Set("whatsapp:"+whatsappTestAna.String(), "Garden"))
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.state.CloseAll())
+
+ batch := whatsapp.state.Closed()[0]
+ text := batch.Text(time.UTC)
+ routed, err := first.messages.RouteWhatsApp(batch, text)
+ require.NoError(t, err)
+ require.True(t, routed)
+ first.Close(t)
+
+ second := newDaemonMessageTest(t, dir, server)
+ restored := whatsappRouteTestInbox(t, dir)
+ require.NoError(t, restored.deliver(t.Context(), second.loops[0], WhatsAppLink{}, true))
+ require.Empty(t, restored.state.Closed())
+ require.Zero(t, second.agents[0].memory.Len())
+ require.Equal(t, 1, second.agents[1].memory.Len())
+ require.True(t, second.loops[1].waiting())
+
+ message, err := second.agents[1].memory.Message(0)
+ require.NoError(t, err)
+ require.Equal(t, text, message.Text)
+ require.Len(t, imageFiles(message.Text), 1)
+ second.Start(t, 1)
+ second.events[1].WaitFor(t, "idle", 1)
+ require.Empty(t, second.Queue(t).Pending)
+
+ turnCalls := 0
+ for index := range server.Calls() {
+ call := server.Call(index)
+ if call.body["model"] == "gpt-test-garden" {
+ turnCalls++
+
+ require.Contains(t, string(call.raw), "input_image")
+ }
+ }
+
+ require.Equal(t, 1, turnCalls)
+ second.Close(t)
+
+ third := newDaemonMessageTest(t, dir, server)
+ require.Equal(t, 1, third.agents[1].memory.Len())
+ require.False(t, third.loops[1].waiting())
+}
+
+func TestWhatsAppImageMentionsAndRepliesUseImageContext(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ fake.downloadData = imageTestPNG(t, 2, 2)
+ event := whatsappImageTestEvent(whatsappTestAna, "@"+whatsappTestLID+" check "+whatsappTestNumber)
+ event.Info.Chat = whatsappTestFamily
+ event.Message.ImageMessage.ContextInfo = &waE2E.ContextInfo{
+ MentionedJID: []string{whatsappTestLID + "@lid"},
+ Participant: new(whatsappTestOwn.String()),
+ }
+
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.state.CloseAll())
+
+ batch := whatsapp.state.Closed()[0]
+ require.True(t, batch.Turn())
+ require.True(t, batch.Messages[0].Mention)
+ require.True(t, batch.Messages[0].Reply)
+ require.Contains(t, batch.Text(time.UTC), "@you check OWN")
+ require.NotContains(t, batch.Text(time.UTC), whatsappTestNumber)
+ require.NotContains(t, batch.Text(time.UTC), whatsappTestLID)
+}
+
+func whatsappImageTestEvent(sender types.JID, caption string) *events.Message {
+ event := whatsappTestMessage(whatsappTestAna, sender, "image", "", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))
+ event.Message = &waE2E.Message{
+ ImageMessage: &waE2E.ImageMessage{
+ Mimetype: new("image/png"),
+ Caption: new(caption),
+ },
+ }
+
+ return event
+}
+
+func whatsappImageTestReply(w http.ResponseWriter, call ModelFakeCall) {
+ text := "A red test image."
+ if call.body["model"] == "gpt-test-root" || call.body["model"] == "gpt-test-garden" {
+ text = ""
+ }
+
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
+}