20 "github.com/stretchr/testify/require"
22 "go.mau.fi/whatsmeow/proto/waE2E"
23 "go.mau.fi/whatsmeow/types"
24 "go.mau.fi/whatsmeow/types/events"
27 func TestWhatsAppImagesAreDurableBeforeAcknowledgement(t *testing.T) {
28 testCases := []struct {
33 name: "a contact image keeps its caption and pixels",
34 sender: whatsappTestAna,
37 name: "an own image keeps its caption and pixels",
38 sender: whatsappTestOwn,
42 for _, tc := range testCases {
43 t.Run(tc.name, func(t *testing.T) {
45 whatsappTestSession(t, dir, true)
47 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
48 whatsapp.client = fake
49 data := imageTestPNG(t, 3, 2)
50 fake.downloadData = data
51 event := whatsappImageTestEvent(tc.sender, "Read this label.")
52 require.True(t, whatsapp.handle(event))
53 require.Len(t, fake.Downloads(), 1)
54 require.Same(t, event.Message.ImageMessage, fake.Downloads()[0])
56 restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
57 require.NoError(t, restored.CloseAll())
58 require.Len(t, restored.Closed(), 1)
60 batch := restored.Closed()[0]
61 names := imageFiles(batch.Messages[0].Text)
62 require.Len(t, names, 1)
63 require.Equal(t, tc.sender == whatsappTestOwn, batch.Messages[0].Mine)
64 require.Equal(t, imageText(names, "Read this label."), batch.Messages[0].Text)
65 require.Equal(t, names, imageFiles(batch.Text(time.UTC)))
67 store := &ImageStore{dir: filepath.Join(dir, imageDirName)}
68 saved, err := store.Read(names[0])
69 require.NoError(t, err)
70 require.Equal(t, data, saved)
72 info, err := os.Stat(filepath.Join(store.dir, names[0]))
73 require.NoError(t, err)
74 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
75 require.NotContains(t, logs.String(), "Read this label")
76 require.NotContains(t, logs.String(), "base64")
81 func TestWhatsAppImageDownloadCanOutlastNameLookup(t *testing.T) {
82 whatsapp, fake := whatsappStartTestLinked(t)
83 whatsapp.client = fake
84 whatsapp.inbox.lookupTime = 10 * time.Millisecond
85 whatsapp.inbox.imageTime = time.Second
86 fake.downloadWait = 50 * time.Millisecond
87 data := imageTestPNG(t, 3, 2)
88 fake.downloadData = data
90 require.True(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Slow photo.")))
92 restored := whatsappTestState(t, whatsapp.state.path)
93 require.NoError(t, restored.CloseAll())
94 require.Len(t, restored.Closed(), 1)
96 text := restored.Closed()[0].Messages[0].Text
97 names := imageFiles(text)
98 require.Len(t, names, 1)
99 require.Equal(t, imageText(names, "Slow photo."), text)
101 saved, err := whatsapp.inbox.images.Read(names[0])
102 require.NoError(t, err)
103 require.Equal(t, data, saved)
106 func TestWhatsAppImageFailureKeepsTheBatchMoving(t *testing.T) {
108 whatsappTestSession(t, dir, true)
110 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
111 whatsapp.client = fake
112 fake.downloadErr = errors.New("download failed for " + whatsappTestNumber + " and " + whatsappTestAna.User)
113 event := whatsappImageTestEvent(whatsappTestAna, "Keep this caption.")
114 require.True(t, whatsapp.handle(event))
115 require.True(t, whatsapp.handle(whatsappTestMessage(whatsappTestAna, whatsappTestAna, "next", "Next text.", event.Info.Timestamp)))
117 restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
118 require.NoError(t, restored.CloseAll())
119 require.Len(t, restored.Closed(), 1)
121 messages := restored.Closed()[0].Messages
122 require.Len(t, messages, 2)
123 require.Equal(t, "(image not downloaded)\nKeep this caption.", messages[0].Text)
124 require.Equal(t, "Next text.", messages[1].Text)
125 require.Empty(t, imageFiles(restored.Closed()[0].Text(time.UTC)))
126 require.Contains(t, logs.String(), "<3>image receive failed chat=")
127 require.Contains(t, logs.String(), ": download image: media download failed")
128 require.NotContains(t, logs.String(), whatsappTestNumber)
129 require.NotContains(t, logs.String(), whatsappTestAna.User)
130 require.NotContains(t, logs.String(), "Keep this caption")
131 require.NotContains(t, logs.String(), "base64")
134 func TestWhatsAppImageDownloadErrorsKeepOnlySafeCauses(t *testing.T) {
135 mediaURL := "https://example.com/media/secret-path?auth=secret-token&hash=secret-hash"
136 directPath := "media/secret-path?auth=secret-token&hash=secret-hash"
137 testCases := []struct {
143 name: "signed request URLs do not reach the log",
147 Err: errors.New("connection failed"),
149 cause: "media download failed",
152 name: "invalid direct paths do not reach the log",
153 err: fmt.Errorf("media download path does not start with slash: %s", directPath),
154 cause: "media download failed",
157 name: "request preparation errors do not expose media paths",
158 err: fmt.Errorf("failed to prepare request: parse %q: invalid control character", mediaURL),
159 cause: "media download failed",
162 name: "cancellation keeps its safe cause without the URL",
163 err: fmt.Errorf("request %s: %w", mediaURL, context.Canceled),
164 cause: "context canceled",
167 name: "a deadline keeps its safe cause without the path",
168 err: fmt.Errorf("request %s: %w", directPath, context.DeadlineExceeded),
169 cause: "context deadline exceeded",
172 name: "HTTP failures keep only the status code",
173 err: whatsmeow.DownloadHTTPError{Response: &http.Response{
174 StatusCode: http.StatusForbidden,
175 Status: "403 " + mediaURL,
177 cause: "media server returned HTTP 403",
181 for _, tc := range testCases {
182 t.Run(tc.name, func(t *testing.T) {
184 whatsappTestSession(t, dir, true)
186 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
187 whatsapp.client = fake
188 fake.downloadErr = tc.err
190 require.True(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Keep this caption.")))
191 require.NoError(t, whatsapp.state.CloseAll())
192 require.Equal(t, "(image not downloaded)\nKeep this caption.", whatsapp.state.Closed()[0].Messages[0].Text)
193 require.Contains(t, logs.String(), "<3>image receive failed chat=")
194 require.Contains(t, logs.String(), ": download image: "+tc.cause)
196 for _, secret := range []string{mediaURL, directPath, "example.com", "secret-path", "secret-token", "secret-hash", whatsappTestAna.User, "Keep this caption."} {
197 require.NotContains(t, logs.String(), secret)
203 func TestWhatsAppImageLimitsKeepMarkersWithoutStoredPixels(t *testing.T) {
204 var gifBytes bytes.Buffer
206 require.NoError(t, gif.Encode(&gifBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
208 testCases := []struct {
216 name: "declared large images are not downloaded",
218 size: imageMaxBytes + 1,
221 name: "large downloaded images become markers",
223 data: bytes.Repeat([]byte{0}, imageMaxBytes+1),
227 name: "unsupported image types are not downloaded",
231 name: "GIF bytes cannot claim to be PNG",
233 data: gifBytes.Bytes(),
237 name: "invalid image bytes become markers",
239 data: []byte("not an image"),
244 for _, tc := range testCases {
245 t.Run(tc.name, func(t *testing.T) {
246 whatsapp, fake := whatsappStartTestLinked(t)
247 whatsapp.client = fake
248 fake.downloadData = tc.data
249 event := whatsappImageTestEvent(whatsappTestAna, "")
250 event.Message.ImageMessage.Mimetype = new(tc.mime)
251 event.Message.ImageMessage.FileLength = new(tc.size)
252 require.True(t, whatsapp.handle(event))
253 require.Len(t, fake.Downloads(), tc.downloads)
254 require.NoError(t, whatsapp.state.CloseAll())
255 require.Len(t, whatsapp.state.Closed(), 1)
256 require.Equal(t, "(image not downloaded)", whatsapp.state.Closed()[0].Messages[0].Text)
258 _, err := os.Stat(whatsapp.inbox.images.dir)
259 require.ErrorIs(t, err, os.ErrNotExist)
264 func TestWhatsAppSupportsJPEGPNGAndWebPBytes(t *testing.T) {
265 var jpegBytes bytes.Buffer
267 require.NoError(t, jpeg.Encode(&jpegBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
269 testCases := []struct {
276 name: "PNG bytes keep the PNG extension",
278 data: imageTestPNG(t, 2, 2),
282 name: "JPEG bytes set the JPEG extension even with a PNG label",
284 data: jpegBytes.Bytes(),
288 name: "WebP bytes keep the WebP extension",
290 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},
295 for _, tc := range testCases {
296 t.Run(tc.name, func(t *testing.T) {
297 whatsapp, fake := whatsappStartTestLinked(t)
298 whatsapp.client = fake
299 fake.downloadData = tc.data
300 event := whatsappImageTestEvent(whatsappTestAna, "")
301 event.Message.ImageMessage.Mimetype = new(tc.mime)
302 require.True(t, whatsapp.handle(event))
303 require.NoError(t, whatsapp.state.CloseAll())
305 names := imageFiles(whatsapp.state.Closed()[0].Text(time.UTC))
306 require.Len(t, names, 1)
307 require.True(t, strings.HasSuffix(names[0], tc.suffix))
309 data, err := whatsapp.inbox.images.Read(names[0])
310 require.NoError(t, err)
311 require.Equal(t, tc.data, data)
316 func TestWhatsAppMediaMarkersNeverDownload(t *testing.T) {
317 testCases := []struct {
319 message *waE2E.Message
323 name: "stickers keep a text marker",
324 message: &waE2E.Message{
325 StickerMessage: &waE2E.StickerMessage{},
330 name: "videos keep a text marker and caption",
331 message: &waE2E.Message{
332 VideoMessage: &waE2E.VideoMessage{Caption: new("Watch this.")},
334 text: "(video)\nWatch this.",
337 name: "GIF playback keeps a text marker",
338 message: &waE2E.Message{
339 VideoMessage: &waE2E.VideoMessage{GifPlayback: new(true)},
345 for _, tc := range testCases {
346 t.Run(tc.name, func(t *testing.T) {
347 whatsapp, fake := whatsappStartTestLinked(t)
348 whatsapp.client = fake
349 event := whatsappImageTestEvent(whatsappTestAna, "")
350 event.Message = tc.message
351 require.True(t, whatsapp.handle(event))
352 require.NoError(t, whatsapp.state.CloseAll())
353 require.Len(t, whatsapp.state.Closed(), 1)
354 require.Equal(t, tc.text, whatsapp.state.Closed()[0].Messages[0].Text)
355 require.Empty(t, fake.Downloads())
360 func TestWhatsAppPrivateImagesAreNotDownloaded(t *testing.T) {
361 testCases := []struct {
363 change func(event *events.Message)
366 name: "view-once events are not read",
367 change: func(event *events.Message) {
368 event.IsViewOnce = true
372 name: "view-once image fields are not read",
373 change: func(event *events.Message) {
374 event.Message.ImageMessage.ViewOnce = new(true)
378 name: "disappearing images are not read",
379 change: func(event *events.Message) {
380 event.IsEphemeral = true
384 name: "self-chat images are not read",
385 change: func(event *events.Message) {
386 event.Info.Chat = whatsappTestOwn
391 for _, tc := range testCases {
392 t.Run(tc.name, func(t *testing.T) {
393 whatsapp, fake := whatsappStartTestLinked(t)
394 whatsapp.client = fake
395 event := whatsappImageTestEvent(whatsappTestAna, "Secret caption.")
397 require.True(t, whatsapp.handle(event))
398 require.NoError(t, whatsapp.state.CloseAll())
399 require.Empty(t, whatsapp.state.Closed())
400 require.Empty(t, fake.Downloads())
405 func TestWhatsAppImageStateFailureDoesNotAcknowledge(t *testing.T) {
406 whatsapp, fake := whatsappStartTestLinked(t)
407 whatsapp.client = fake
408 fake.downloadData = imageTestPNG(t, 2, 2)
409 require.NoError(t, os.Mkdir(whatsapp.state.path, 0o700))
410 require.False(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Keep this.")))
411 require.ErrorContains(t, <-whatsapp.inbox.failures, "save WhatsApp state")
414 func TestWhatsAppImagesReachTheirRootOrRoutedChild(t *testing.T) {
415 testCases := []struct {
421 name: "private images wake the root with pixels",
424 name: "routed group images wake only their child with pixels",
430 for _, tc := range testCases {
431 t.Run(tc.name, func(t *testing.T) {
433 server := newModelFake(t)
434 server.reply = whatsappImageTestReply
435 test := newDaemonMessageTest(t, dir, server)
436 test.agents[0].agent.config.Model = "gpt-test-root"
438 whatsappTestSession(t, dir, true)
440 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
441 whatsapp.client = fake
442 data := imageTestPNG(t, 3, 2)
443 fake.downloadData = data
444 event := whatsappImageTestEvent(whatsappTestAna, "What do you see?")
446 event.Info.Chat = whatsappTestFamily
447 event.Info.IsGroup = true
449 whatsapp.groups.set(whatsappTestFamily.String(), "Garden plans")
450 require.NoError(t, test.messages.routes.Set("whatsapp:"+whatsappTestFamily.String(), "Garden"))
453 test.Start(t, tc.target)
454 require.True(t, whatsapp.handle(event))
455 require.NoError(t, whatsapp.inbox.deliver(t.Context(), test.loops[0], whatsapp.link(), true))
456 test.events[tc.target].WaitFor(t, "idle", 1)
458 text := test.events[tc.target].Last("work")
459 names := imageFiles(text)
460 require.Len(t, names, 1)
461 require.Contains(t, text, "[image "+names[0]+"]\nWhat do you see?")
462 require.Equal(t, 1, test.agents[tc.target].memory.Len())
463 require.Empty(t, whatsapp.state.Closed())
466 require.Zero(t, test.agents[0].memory.Len())
467 require.False(t, test.loops[0].waiting())
470 model := test.agents[tc.target].agent.config.Model
472 for index := range server.Calls() {
473 call := server.Call(index)
474 if call.body["model"] == model {
477 require.Contains(t, string(call.raw), "data:image/png;base64,"+base64.StdEncoding.EncodeToString(data))
481 require.Equal(t, 1, turnCalls)
482 require.NotContains(t, logs.String()+test.logs.String(), "What do you see?")
483 require.NotContains(t, logs.String()+test.logs.String(), "base64")
488 func TestWhatsAppImageRoutingSurvivesRestartWithoutDuplicateEntries(t *testing.T) {
490 server := newModelFake(t)
491 server.reply = whatsappImageTestReply
492 first := newDaemonMessageTest(t, dir, server)
493 whatsappTestSession(t, dir, true)
495 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
496 whatsapp.client = fake
497 fake.downloadData = imageTestPNG(t, 3, 2)
498 event := whatsappImageTestEvent(whatsappTestAna, "Keep this image.")
499 require.NoError(t, first.messages.routes.Set("whatsapp:"+whatsappTestAna.String(), "Garden"))
500 require.True(t, whatsapp.handle(event))
501 require.NoError(t, whatsapp.state.CloseAll())
503 batch := whatsapp.state.Closed()[0]
504 text := batch.Text(time.UTC)
505 routed, err := first.messages.RouteWhatsApp(batch, text)
506 require.NoError(t, err)
507 require.True(t, routed)
510 second := newDaemonMessageTest(t, dir, server)
511 restored := whatsappRouteTestInbox(t, dir)
512 require.NoError(t, restored.deliver(t.Context(), second.loops[0], WhatsAppLink{}, true))
513 require.Empty(t, restored.state.Closed())
514 require.Zero(t, second.agents[0].memory.Len())
515 require.Equal(t, 1, second.agents[1].memory.Len())
516 require.True(t, second.loops[1].waiting())
518 message, err := second.agents[1].memory.Message(0)
519 require.NoError(t, err)
520 require.Equal(t, text, message.Text)
521 require.Len(t, imageFiles(message.Text), 1)
523 second.events[1].WaitFor(t, "idle", 1)
524 require.Empty(t, second.Queue(t).Pending)
527 for index := range server.Calls() {
528 call := server.Call(index)
529 if call.body["model"] == "gpt-test-garden" {
532 require.Contains(t, string(call.raw), "input_image")
536 require.Equal(t, 1, turnCalls)
539 third := newDaemonMessageTest(t, dir, server)
540 require.Equal(t, 1, third.agents[1].memory.Len())
541 require.False(t, third.loops[1].waiting())
544 func TestWhatsAppImageMentionsAndRepliesUseImageContext(t *testing.T) {
545 whatsapp, fake := whatsappStartTestLinked(t)
546 whatsapp.client = fake
547 fake.downloadData = imageTestPNG(t, 2, 2)
548 event := whatsappImageTestEvent(whatsappTestAna, "@"+whatsappTestLID+" check "+whatsappTestNumber)
549 event.Info.Chat = whatsappTestFamily
550 event.Message.ImageMessage.ContextInfo = &waE2E.ContextInfo{
551 MentionedJID: []string{whatsappTestLID + "@lid"},
552 Participant: new(whatsappTestOwn.String()),
555 require.True(t, whatsapp.handle(event))
556 require.NoError(t, whatsapp.state.CloseAll())
558 batch := whatsapp.state.Closed()[0]
559 require.True(t, batch.Turn())
560 require.True(t, batch.Messages[0].Mention)
561 require.True(t, batch.Messages[0].Reply)
562 require.Contains(t, batch.Text(time.UTC), "@you check OWN")
563 require.NotContains(t, batch.Text(time.UTC), whatsappTestNumber)
564 require.NotContains(t, batch.Text(time.UTC), whatsappTestLID)
567 func whatsappImageTestEvent(sender types.JID, caption string) *events.Message {
568 event := whatsappTestMessage(whatsappTestAna, sender, "image", "", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))
569 event.Message = &waE2E.Message{
570 ImageMessage: &waE2E.ImageMessage{
571 Mimetype: new("image/png"),
572 Caption: new(caption),
579 func whatsappImageTestReply(w http.ResponseWriter, call ModelFakeCall) {
580 text := "A red test image."
581 if call.body["model"] == "gpt-test-root" || call.body["model"] == "gpt-test-garden" {
585 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))