21 "github.com/stretchr/testify/require"
23 "go.mau.fi/whatsmeow/proto/waE2E"
24 "go.mau.fi/whatsmeow/types"
25 "go.mau.fi/whatsmeow/types/events"
28 func TestWhatsAppImagesAreDurableBeforeAcknowledgement(t *testing.T) {
29 testCases := []struct {
34 name: "a contact image keeps its caption and pixels",
35 sender: whatsappTestAna,
38 name: "an own image keeps its caption and pixels",
39 sender: whatsappTestOwn,
43 for _, tc := range testCases {
44 t.Run(tc.name, func(t *testing.T) {
46 whatsappTestSession(t, dir, true)
48 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
49 whatsapp.client = fake
50 data := imageTestPNG(t, 3, 2)
51 fake.downloadData = data
52 event := whatsappImageTestEvent(tc.sender, "Read this label.")
53 require.True(t, whatsapp.handle(event))
54 require.Len(t, fake.Downloads(), 1)
55 require.Same(t, event.Message.ImageMessage, fake.Downloads()[0])
57 restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
58 require.NoError(t, restored.CloseAll())
59 require.Len(t, restored.Closed(), 1)
61 batch := restored.Closed()[0]
62 names := imageFiles(batch.Messages[0].Text)
63 require.Len(t, names, 1)
64 require.Equal(t, tc.sender == whatsappTestOwn, batch.Messages[0].Mine)
65 require.Equal(t, imageText(names, "Read this label."), batch.Messages[0].Text)
66 require.Equal(t, names, imageFiles(batch.Text(time.UTC)))
68 store := &ImageStore{dir: filepath.Join(dir, imageDirName)}
69 saved, err := store.Read(names[0])
70 require.NoError(t, err)
71 require.Equal(t, data, saved)
73 info, err := os.Stat(filepath.Join(store.dir, names[0]))
74 require.NoError(t, err)
75 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
76 require.NotContains(t, logs.String(), "Read this label")
77 require.NotContains(t, logs.String(), "base64")
82 func TestWhatsAppImageDownloadCanOutlastNameLookup(t *testing.T) {
83 whatsapp, fake := whatsappStartTestLinked(t)
84 whatsapp.client = fake
85 whatsapp.inbox.lookupTime = 10 * time.Millisecond
86 whatsapp.inbox.imageTime = time.Second
87 fake.downloadWait = 50 * time.Millisecond
88 data := imageTestPNG(t, 3, 2)
89 fake.downloadData = data
91 require.True(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Slow photo.")))
93 restored := whatsappTestState(t, whatsapp.state.path)
94 require.NoError(t, restored.CloseAll())
95 require.Len(t, restored.Closed(), 1)
97 text := restored.Closed()[0].Messages[0].Text
98 names := imageFiles(text)
99 require.Len(t, names, 1)
100 require.Equal(t, imageText(names, "Slow photo."), text)
102 saved, err := whatsapp.inbox.images.Read(names[0])
103 require.NoError(t, err)
104 require.Equal(t, data, saved)
107 func TestWhatsAppRunCancellationEndsAnImageDownload(t *testing.T) {
108 synctest.Test(t, func(t *testing.T) {
109 test := whatsappRetryTest(t)
110 test.fake.downloadWait = time.Hour
114 acknowledged := make(chan bool, 1)
116 acknowledged <- test.fake.Emit(whatsappImageTestEvent(whatsappTestAna, "Keep this caption."))
123 t.Fatal("download ended before daemon cancellation")
128 require.True(t, <-acknowledged)
129 require.NoError(t, <-test.done)
130 require.Empty(t, test.fake.Downloads())
131 require.Contains(t, test.logs.String(), "context canceled")
132 require.NoError(t, test.whatsapp.state.CloseAll())
133 require.Equal(t, "(image not downloaded)\nKeep this caption.", test.whatsapp.state.Closed()[0].Messages[0].Text)
137 func TestWhatsAppImageFailureKeepsTheBatchMoving(t *testing.T) {
139 whatsappTestSession(t, dir, true)
141 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
142 whatsapp.client = fake
143 fake.downloadErr = errors.New("download failed for " + whatsappTestNumber + " and " + whatsappTestAna.User)
144 event := whatsappImageTestEvent(whatsappTestAna, "Keep this caption.")
145 require.True(t, whatsapp.handle(event))
146 require.True(t, whatsapp.handle(whatsappTestMessage(whatsappTestAna, whatsappTestAna, "next", "Next text.", event.Info.Timestamp)))
148 restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
149 require.NoError(t, restored.CloseAll())
150 require.Len(t, restored.Closed(), 1)
152 messages := restored.Closed()[0].Messages
153 require.Len(t, messages, 2)
154 require.Equal(t, "(image not downloaded)\nKeep this caption.", messages[0].Text)
155 require.Equal(t, "Next text.", messages[1].Text)
156 require.Empty(t, imageFiles(restored.Closed()[0].Text(time.UTC)))
157 require.Contains(t, logs.String(), "<3>image receive failed chat=")
158 require.Contains(t, logs.String(), ": download image: media download failed")
159 require.NotContains(t, logs.String(), whatsappTestNumber)
160 require.NotContains(t, logs.String(), whatsappTestAna.User)
161 require.NotContains(t, logs.String(), "Keep this caption")
162 require.NotContains(t, logs.String(), "base64")
165 func TestWhatsAppImageDownloadErrorsKeepOnlySafeCauses(t *testing.T) {
166 mediaURL := "https://example.com/media/secret-path?auth=secret-token&hash=secret-hash"
167 directPath := "media/secret-path?auth=secret-token&hash=secret-hash"
168 testCases := []struct {
174 name: "signed request URLs do not reach the log",
178 Err: errors.New("connection failed"),
180 cause: "media download failed",
183 name: "invalid direct paths do not reach the log",
184 err: fmt.Errorf("media download path does not start with slash: %s", directPath),
185 cause: "media download failed",
188 name: "request preparation errors do not expose media paths",
189 err: fmt.Errorf("failed to prepare request: parse %q: invalid control character", mediaURL),
190 cause: "media download failed",
193 name: "cancellation keeps its safe cause without the URL",
194 err: fmt.Errorf("request %s: %w", mediaURL, context.Canceled),
195 cause: "context canceled",
198 name: "a deadline keeps its safe cause without the path",
199 err: fmt.Errorf("request %s: %w", directPath, context.DeadlineExceeded),
200 cause: "context deadline exceeded",
203 name: "HTTP failures keep only the status code",
204 err: whatsmeow.DownloadHTTPError{Response: &http.Response{
205 StatusCode: http.StatusForbidden,
206 Status: "403 " + mediaURL,
208 cause: "media server returned HTTP 403",
212 for _, tc := range testCases {
213 t.Run(tc.name, func(t *testing.T) {
215 whatsappTestSession(t, dir, true)
217 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
218 whatsapp.client = fake
219 fake.downloadErr = tc.err
221 require.True(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Keep this caption.")))
222 require.NoError(t, whatsapp.state.CloseAll())
223 require.Equal(t, "(image not downloaded)\nKeep this caption.", whatsapp.state.Closed()[0].Messages[0].Text)
224 require.Contains(t, logs.String(), "<3>image receive failed chat=")
225 require.Contains(t, logs.String(), ": download image: "+tc.cause)
227 for _, secret := range []string{mediaURL, directPath, "example.com", "secret-path", "secret-token", "secret-hash", whatsappTestAna.User, "Keep this caption."} {
228 require.NotContains(t, logs.String(), secret)
234 func TestWhatsAppImageLimitsKeepMarkersWithoutStoredPixels(t *testing.T) {
235 var gifBytes bytes.Buffer
237 require.NoError(t, gif.Encode(&gifBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
239 testCases := []struct {
247 name: "declared large images are not downloaded",
249 size: imageMaxBytes + 1,
252 name: "large downloaded images become markers",
254 data: bytes.Repeat([]byte{0}, imageMaxBytes+1),
258 name: "unsupported image types are not downloaded",
262 name: "GIF bytes cannot claim to be PNG",
264 data: gifBytes.Bytes(),
268 name: "invalid image bytes become markers",
270 data: []byte("not an image"),
275 for _, tc := range testCases {
276 t.Run(tc.name, func(t *testing.T) {
277 whatsapp, fake := whatsappStartTestLinked(t)
278 whatsapp.client = fake
279 fake.downloadData = tc.data
280 event := whatsappImageTestEvent(whatsappTestAna, "")
281 event.Message.ImageMessage.Mimetype = new(tc.mime)
282 event.Message.ImageMessage.FileLength = new(tc.size)
283 require.True(t, whatsapp.handle(event))
284 require.Len(t, fake.Downloads(), tc.downloads)
285 require.NoError(t, whatsapp.state.CloseAll())
286 require.Len(t, whatsapp.state.Closed(), 1)
287 require.Equal(t, "(image not downloaded)", whatsapp.state.Closed()[0].Messages[0].Text)
289 _, err := os.Stat(whatsapp.inbox.images.dir)
290 require.ErrorIs(t, err, os.ErrNotExist)
295 func TestWhatsAppSupportsJPEGPNGAndWebPBytes(t *testing.T) {
296 var jpegBytes bytes.Buffer
298 require.NoError(t, jpeg.Encode(&jpegBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
300 testCases := []struct {
307 name: "PNG bytes keep the PNG extension",
309 data: imageTestPNG(t, 2, 2),
313 name: "JPEG bytes set the JPEG extension even with a PNG label",
315 data: jpegBytes.Bytes(),
319 name: "WebP bytes keep the WebP extension",
321 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},
326 for _, tc := range testCases {
327 t.Run(tc.name, func(t *testing.T) {
328 whatsapp, fake := whatsappStartTestLinked(t)
329 whatsapp.client = fake
330 fake.downloadData = tc.data
331 event := whatsappImageTestEvent(whatsappTestAna, "")
332 event.Message.ImageMessage.Mimetype = new(tc.mime)
333 require.True(t, whatsapp.handle(event))
334 require.NoError(t, whatsapp.state.CloseAll())
336 names := imageFiles(whatsapp.state.Closed()[0].Text(time.UTC))
337 require.Len(t, names, 1)
338 require.True(t, strings.HasSuffix(names[0], tc.suffix))
340 data, err := whatsapp.inbox.images.Read(names[0])
341 require.NoError(t, err)
342 require.Equal(t, tc.data, data)
347 func TestWhatsAppMediaMarkersNeverDownload(t *testing.T) {
348 testCases := []struct {
350 message *waE2E.Message
354 name: "stickers keep a text marker",
355 message: &waE2E.Message{
356 StickerMessage: &waE2E.StickerMessage{},
361 name: "videos keep a text marker and caption",
362 message: &waE2E.Message{
363 VideoMessage: &waE2E.VideoMessage{Caption: new("Watch this.")},
365 text: "(video)\nWatch this.",
368 name: "GIF playback keeps a text marker",
369 message: &waE2E.Message{
370 VideoMessage: &waE2E.VideoMessage{GifPlayback: new(true)},
376 for _, tc := range testCases {
377 t.Run(tc.name, func(t *testing.T) {
378 whatsapp, fake := whatsappStartTestLinked(t)
379 whatsapp.client = fake
380 event := whatsappImageTestEvent(whatsappTestAna, "")
381 event.Message = tc.message
382 require.True(t, whatsapp.handle(event))
383 require.NoError(t, whatsapp.state.CloseAll())
384 require.Len(t, whatsapp.state.Closed(), 1)
385 require.Equal(t, tc.text, whatsapp.state.Closed()[0].Messages[0].Text)
386 require.Empty(t, fake.Downloads())
391 func TestWhatsAppPrivateImagesAreNotDownloaded(t *testing.T) {
392 testCases := []struct {
394 change func(event *events.Message)
397 name: "view-once events are not read",
398 change: func(event *events.Message) {
399 event.IsViewOnce = true
403 name: "view-once image fields are not read",
404 change: func(event *events.Message) {
405 event.Message.ImageMessage.ViewOnce = new(true)
409 name: "disappearing images are not read",
410 change: func(event *events.Message) {
411 event.IsEphemeral = true
415 name: "self-chat images are not read",
416 change: func(event *events.Message) {
417 event.Info.Chat = whatsappTestOwn
422 for _, tc := range testCases {
423 t.Run(tc.name, func(t *testing.T) {
424 whatsapp, fake := whatsappStartTestLinked(t)
425 whatsapp.client = fake
426 event := whatsappImageTestEvent(whatsappTestAna, "Secret caption.")
428 require.True(t, whatsapp.handle(event))
429 require.NoError(t, whatsapp.state.CloseAll())
430 require.Empty(t, whatsapp.state.Closed())
431 require.Empty(t, fake.Downloads())
436 func TestWhatsAppImageStateFailureDoesNotAcknowledge(t *testing.T) {
437 whatsapp, fake := whatsappStartTestLinked(t)
438 whatsapp.client = fake
439 fake.downloadData = imageTestPNG(t, 2, 2)
440 require.NoError(t, os.Mkdir(whatsapp.state.path, 0o700))
441 require.False(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Keep this.")))
442 require.ErrorContains(t, <-whatsapp.inbox.failures, "save WhatsApp state")
445 func TestWhatsAppImagesReachTheirRootOrRoutedChild(t *testing.T) {
446 testCases := []struct {
452 name: "private images wake the root with pixels",
455 name: "routed group images wake only their child with pixels",
461 for _, tc := range testCases {
462 t.Run(tc.name, func(t *testing.T) {
464 server := newModelFake(t)
465 server.reply = whatsappImageTestReply
466 test := newDaemonMessageTest(t, dir, server)
467 test.agents[0].agent.config.Model = "gpt-test-root"
469 whatsappTestSession(t, dir, true)
471 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
472 whatsapp.client = fake
473 data := imageTestPNG(t, 3, 2)
474 fake.downloadData = data
475 event := whatsappImageTestEvent(whatsappTestAna, "What do you see?")
477 event.Info.Chat = whatsappTestFamily
478 event.Info.IsGroup = true
480 whatsapp.groups.set(whatsappTestFamily.String(), "Garden plans")
481 require.NoError(t, test.messages.routes.Set("whatsapp:"+whatsappTestFamily.String(), "Garden"))
484 test.Start(t, tc.target)
485 require.True(t, whatsapp.handle(event))
486 require.NoError(t, whatsapp.inbox.deliver(t.Context(), test.loops[0], whatsapp.link(), true))
487 test.events[tc.target].WaitFor(t, "idle", 1)
489 text := test.events[tc.target].Last("work")
490 names := imageFiles(text)
491 require.Len(t, names, 1)
492 require.Contains(t, text, "[image "+names[0]+"]\nWhat do you see?")
493 require.Equal(t, 1, test.agents[tc.target].memory.Len())
494 require.Empty(t, whatsapp.state.Closed())
497 require.Zero(t, test.agents[0].memory.Len())
498 require.False(t, test.loops[0].waiting())
501 model := test.agents[tc.target].agent.config.Model
503 for index := range server.Calls() {
504 call := server.Call(index)
505 if call.body["model"] == model {
508 require.Contains(t, string(call.raw), "data:image/png;base64,"+base64.StdEncoding.EncodeToString(data))
512 require.Equal(t, 1, turnCalls)
513 require.NotContains(t, logs.String()+test.logs.String(), "What do you see?")
514 require.NotContains(t, logs.String()+test.logs.String(), "base64")
519 func TestWhatsAppImageRoutingSurvivesRestartWithoutDuplicateEntries(t *testing.T) {
521 server := newModelFake(t)
522 server.reply = whatsappImageTestReply
523 first := newDaemonMessageTest(t, dir, server)
524 whatsappTestSession(t, dir, true)
526 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
527 whatsapp.client = fake
528 fake.downloadData = imageTestPNG(t, 3, 2)
529 event := whatsappImageTestEvent(whatsappTestAna, "Keep this image.")
530 require.NoError(t, first.messages.routes.Set("whatsapp:"+whatsappTestAna.String(), "Garden"))
531 require.True(t, whatsapp.handle(event))
532 require.NoError(t, whatsapp.state.CloseAll())
534 batch := whatsapp.state.Closed()[0]
535 text := batch.Text(time.UTC)
536 routed, err := first.messages.RouteWhatsApp(batch, text)
537 require.NoError(t, err)
538 require.True(t, routed)
541 second := newDaemonMessageTest(t, dir, server)
542 restored := whatsappRouteTestInbox(t, dir)
543 require.NoError(t, restored.deliver(t.Context(), second.loops[0], WhatsAppLink{}, true))
544 require.Empty(t, restored.state.Closed())
545 require.Zero(t, second.agents[0].memory.Len())
546 require.Equal(t, 1, second.agents[1].memory.Len())
547 require.True(t, second.loops[1].waiting())
549 message, err := second.agents[1].memory.Message(0)
550 require.NoError(t, err)
551 require.Equal(t, text, message.Text)
552 require.Len(t, imageFiles(message.Text), 1)
554 second.events[1].WaitFor(t, "idle", 1)
555 require.Empty(t, second.Queue(t).Pending)
558 for index := range server.Calls() {
559 call := server.Call(index)
560 if call.body["model"] == "gpt-test-garden" {
563 require.Contains(t, string(call.raw), "input_image")
567 require.Equal(t, 1, turnCalls)
570 third := newDaemonMessageTest(t, dir, server)
571 require.Equal(t, 1, third.agents[1].memory.Len())
572 require.False(t, third.loops[1].waiting())
575 func TestWhatsAppImageMentionsAndRepliesUseImageContext(t *testing.T) {
576 whatsapp, fake := whatsappStartTestLinked(t)
577 whatsapp.client = fake
578 fake.downloadData = imageTestPNG(t, 2, 2)
579 event := whatsappImageTestEvent(whatsappTestAna, "@"+whatsappTestLID+" check "+whatsappTestNumber)
580 event.Info.Chat = whatsappTestFamily
581 event.Message.ImageMessage.ContextInfo = &waE2E.ContextInfo{
582 MentionedJID: []string{whatsappTestLID + "@lid"},
583 Participant: new(whatsappTestOwn.String()),
586 require.True(t, whatsapp.handle(event))
587 require.NoError(t, whatsapp.state.CloseAll())
589 batch := whatsapp.state.Closed()[0]
590 require.True(t, batch.Turn())
591 require.True(t, batch.Messages[0].Mention)
592 require.True(t, batch.Messages[0].Reply)
593 require.Contains(t, batch.Text(time.UTC), "@you check OWN")
594 require.NotContains(t, batch.Text(time.UTC), whatsappTestNumber)
595 require.NotContains(t, batch.Text(time.UTC), whatsappTestLID)
598 func whatsappImageTestEvent(sender types.JID, caption string) *events.Message {
599 event := whatsappTestMessage(whatsappTestAna, sender, "image", "", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))
600 event.Message = &waE2E.Message{
601 ImageMessage: &waE2E.ImageMessage{
602 Mimetype: new("image/png"),
603 Caption: new(caption),
610 func whatsappImageTestReply(w http.ResponseWriter, call ModelFakeCall) {
611 text := "A red test image."
612 if call.body["model"] == "gpt-test-root" || call.body["model"] == "gpt-test-garden" {
616 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))