package main import ( "bytes" "context" "encoding/base64" "errors" "fmt" "image" "image/gif" "image/jpeg" "net/http" "net/url" "os" "path/filepath" "strings" "testing" "testing/synctest" "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 TestWhatsAppRunCancellationEndsAnImageDownload(t *testing.T) { synctest.Test(t, func(t *testing.T) { test := whatsappRetryTest(t) test.fake.downloadWait = time.Hour test.start(t) synctest.Wait() acknowledged := make(chan bool, 1) go func() { acknowledged <- test.fake.Emit(whatsappImageTestEvent(whatsappTestAna, "Keep this caption.")) }() synctest.Wait() select { case <-acknowledged: t.Fatal("download ended before daemon cancellation") default: } test.cancel() require.True(t, <-acknowledged) require.NoError(t, <-test.done) require.Empty(t, test.fake.Downloads()) require.Contains(t, test.logs.String(), "context canceled") require.NoError(t, test.whatsapp.state.CloseAll()) require.Equal(t, "(image not downloaded)\nKeep this caption.", test.whatsapp.state.Closed()[0].Messages[0].Text) }) } 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)) }