]> git.giacomolli.uk - onechat/blob - whatsapp_image_test.go
Reflow comments over the reflow width
[onechat] / whatsapp_image_test.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "errors"
8 "fmt"
9 "image"
10 "image/gif"
11 "image/jpeg"
12 "net/http"
13 "net/url"
14 "os"
15 "path/filepath"
16 "strings"
17 "testing"
18 "testing/synctest"
19 "time"
20
21 "github.com/stretchr/testify/require"
22 "go.mau.fi/whatsmeow"
23 "go.mau.fi/whatsmeow/proto/waE2E"
24 "go.mau.fi/whatsmeow/types"
25 "go.mau.fi/whatsmeow/types/events"
26 )
27
28 func TestWhatsAppImagesAreDurableBeforeAcknowledgement(t *testing.T) {
29 testCases := []struct {
30 name string
31 sender types.JID
32 }{
33 {
34 name: "a contact image keeps its caption and pixels",
35 sender: whatsappTestAna,
36 },
37 {
38 name: "an own image keeps its caption and pixels",
39 sender: whatsappTestOwn,
40 },
41 }
42
43 for _, tc := range testCases {
44 t.Run(tc.name, func(t *testing.T) {
45 dir := t.TempDir()
46 whatsappTestSession(t, dir, true)
47
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])
56
57 restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
58 require.NoError(t, restored.CloseAll())
59 require.Len(t, restored.Closed(), 1)
60
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)))
67
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)
72
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")
78 })
79 }
80 }
81
82 func TestWhatsAppImageDownloadCanOutlastNameLookup(t *testing.T) {
83 synctest.Test(t, func(t *testing.T) {
84 whatsapp, fake := whatsappStartTestLinked(t)
85 whatsapp.client = fake
86 fake.downloadWait = whatsappContactLookupTimeout + time.Second
87 data := imageTestPNG(t, 3, 2)
88 fake.downloadData = data
89
90 require.True(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Slow photo.")))
91
92 restored := whatsappTestState(t, whatsapp.state.path)
93 require.NoError(t, restored.CloseAll())
94 require.Len(t, restored.Closed(), 1)
95
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)
100
101 saved, err := whatsapp.inbox.images.Read(names[0])
102 require.NoError(t, err)
103 require.Equal(t, data, saved)
104 })
105 }
106
107 func TestWhatsAppRunCancellationEndsAnImageDownload(t *testing.T) {
108 synctest.Test(t, func(t *testing.T) {
109 test := whatsappRetryTest(t)
110 test.fake.downloadWait = time.Hour
111 test.start(t)
112 synctest.Wait()
113
114 acknowledged := make(chan bool, 1)
115 go func() {
116 acknowledged <- test.fake.Emit(whatsappImageTestEvent(whatsappTestAna, "Keep this caption."))
117 }()
118
119 synctest.Wait()
120
121 select {
122 case <-acknowledged:
123 t.Fatal("download ended before daemon cancellation")
124 default:
125 }
126
127 test.cancel()
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)
134 })
135 }
136
137 func TestWhatsAppImageFailureKeepsTheBatchMoving(t *testing.T) {
138 dir := t.TempDir()
139 whatsappTestSession(t, dir, true)
140
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)))
147
148 restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
149 require.NoError(t, restored.CloseAll())
150 require.Len(t, restored.Closed(), 1)
151
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")
163 }
164
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 {
169 name string
170 err error
171 cause string
172 }{
173 {
174 name: "signed request URLs do not reach the log",
175 err: &url.Error{
176 Op: "Get",
177 URL: mediaURL,
178 Err: errors.New("connection failed"),
179 },
180 cause: "media download failed",
181 },
182 {
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",
186 },
187 {
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",
191 },
192 {
193 name: "cancellation keeps its safe cause without the URL",
194 err: fmt.Errorf("request %s: %w", mediaURL, context.Canceled),
195 cause: "context canceled",
196 },
197 {
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",
201 },
202 {
203 name: "HTTP failures keep only the status code",
204 err: whatsmeow.DownloadHTTPError{Response: &http.Response{
205 StatusCode: http.StatusForbidden,
206 Status: "403 " + mediaURL,
207 }},
208 cause: "media server returned HTTP 403",
209 },
210 }
211
212 for _, tc := range testCases {
213 t.Run(tc.name, func(t *testing.T) {
214 dir := t.TempDir()
215 whatsappTestSession(t, dir, true)
216
217 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
218 whatsapp.client = fake
219 fake.downloadErr = tc.err
220
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)
226
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)
229 }
230 })
231 }
232 }
233
234 func TestWhatsAppImageLimitsKeepMarkersWithoutStoredPixels(t *testing.T) {
235 var gifBytes bytes.Buffer
236
237 require.NoError(t, gif.Encode(&gifBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
238
239 testCases := []struct {
240 name string
241 mime string
242 size uint64
243 data []byte
244 downloads int
245 }{
246 {
247 name: "declared large images are not downloaded",
248 mime: "image/png",
249 size: imageMaxBytes + 1,
250 },
251 {
252 name: "large downloaded images become markers",
253 mime: "image/png",
254 data: bytes.Repeat([]byte{0}, imageMaxBytes+1),
255 downloads: 1,
256 },
257 {
258 name: "unsupported image types are not downloaded",
259 mime: "image/gif",
260 },
261 {
262 name: "GIF bytes cannot claim to be PNG",
263 mime: "image/png",
264 data: gifBytes.Bytes(),
265 downloads: 1,
266 },
267 {
268 name: "invalid image bytes become markers",
269 mime: "image/png",
270 data: []byte("not an image"),
271 downloads: 1,
272 },
273 }
274
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)
288
289 _, err := os.Stat(whatsapp.inbox.images.dir)
290 require.ErrorIs(t, err, os.ErrNotExist)
291 })
292 }
293 }
294
295 func TestWhatsAppSupportsJPEGPNGAndWebPBytes(t *testing.T) {
296 var jpegBytes bytes.Buffer
297
298 require.NoError(t, jpeg.Encode(&jpegBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
299
300 testCases := []struct {
301 name string
302 mime string
303 data []byte
304 suffix string
305 }{
306 {
307 name: "PNG bytes keep the PNG extension",
308 mime: "image/png",
309 data: imageTestPNG(t, 2, 2),
310 suffix: ".png",
311 },
312 {
313 name: "JPEG bytes set the JPEG extension even with a PNG label",
314 mime: "image/png",
315 data: jpegBytes.Bytes(),
316 suffix: ".jpg",
317 },
318 {
319 name: "WebP bytes keep the WebP extension",
320 mime: "image/webp",
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},
322 suffix: ".webp",
323 },
324 }
325
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())
335
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))
339
340 data, err := whatsapp.inbox.images.Read(names[0])
341 require.NoError(t, err)
342 require.Equal(t, tc.data, data)
343 })
344 }
345 }
346
347 func TestWhatsAppMediaMarkersNeverDownload(t *testing.T) {
348 testCases := []struct {
349 name string
350 message *waE2E.Message
351 text string
352 }{
353 {
354 name: "stickers keep a text marker",
355 message: &waE2E.Message{
356 StickerMessage: &waE2E.StickerMessage{},
357 },
358 text: "(sticker)",
359 },
360 {
361 name: "videos keep a text marker and caption",
362 message: &waE2E.Message{
363 VideoMessage: &waE2E.VideoMessage{Caption: new("Watch this.")},
364 },
365 text: "(video)\nWatch this.",
366 },
367 {
368 name: "GIF playback keeps a text marker",
369 message: &waE2E.Message{
370 VideoMessage: &waE2E.VideoMessage{GifPlayback: new(true)},
371 },
372 text: "(GIF)",
373 },
374 }
375
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())
387 })
388 }
389 }
390
391 func TestWhatsAppPrivateImagesAreNotDownloaded(t *testing.T) {
392 testCases := []struct {
393 name string
394 change func(event *events.Message)
395 }{
396 {
397 name: "view-once events are not read",
398 change: func(event *events.Message) {
399 event.IsViewOnce = true
400 },
401 },
402 {
403 name: "view-once image fields are not read",
404 change: func(event *events.Message) {
405 event.Message.ImageMessage.ViewOnce = new(true)
406 },
407 },
408 {
409 name: "disappearing images are not read",
410 change: func(event *events.Message) {
411 event.IsEphemeral = true
412 },
413 },
414 {
415 name: "self-chat images are not read",
416 change: func(event *events.Message) {
417 event.Info.Chat = whatsappTestOwn
418 },
419 },
420 }
421
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.")
427 tc.change(event)
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())
432 })
433 }
434 }
435
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")
443 }
444
445 func TestWhatsAppImagesReachTheirRootOrRoutedChild(t *testing.T) {
446 testCases := []struct {
447 name string
448 target int
449 group bool
450 }{
451 {
452 name: "private images wake the root with pixels",
453 },
454 {
455 name: "routed group images wake only their child with pixels",
456 target: 1,
457 group: true,
458 },
459 }
460
461 for _, tc := range testCases {
462 t.Run(tc.name, func(t *testing.T) {
463 dir := t.TempDir()
464 server := newModelFake(t)
465 server.reply = whatsappImageTestReply
466 test := newDaemonMessageTest(t, dir, server)
467 test.agents[0].agent.config.Model = "gpt-test-root"
468
469 whatsappTestSession(t, dir, true)
470
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?")
476 if tc.group {
477 event.Info.Chat = whatsappTestFamily
478 event.Info.IsGroup = true
479
480 whatsapp.groups.set(whatsappTestFamily.String(), "Garden plans")
481 require.NoError(t, test.messages.routes.Set("whatsapp:"+whatsappTestFamily.String(), "Garden"))
482 }
483
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)
488
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())
495
496 if tc.target != 0 {
497 require.Zero(t, test.agents[0].memory.Len())
498 require.False(t, test.loops[0].waiting())
499 }
500
501 model := test.agents[tc.target].agent.config.Model
502 turnCalls := 0
503 for index := range server.Calls() {
504 call := server.Call(index)
505 if call.body["model"] == model {
506 turnCalls++
507
508 require.Contains(t, string(call.raw), "data:image/png;base64,"+base64.StdEncoding.EncodeToString(data))
509 }
510 }
511
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")
515 })
516 }
517 }
518
519 func TestWhatsAppImageRoutingSurvivesRestartWithoutDuplicateEntries(t *testing.T) {
520 dir := t.TempDir()
521 server := newModelFake(t)
522 server.reply = whatsappImageTestReply
523 first := newDaemonMessageTest(t, dir, server)
524 whatsappTestSession(t, dir, true)
525
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())
533
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)
539 first.Close(t)
540
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())
548
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)
553 second.Start(t, 1)
554 second.events[1].WaitFor(t, "idle", 1)
555 require.Empty(t, second.Queue(t).Pending)
556
557 turnCalls := 0
558 for index := range server.Calls() {
559 call := server.Call(index)
560 if call.body["model"] == "gpt-test-garden" {
561 turnCalls++
562
563 require.Contains(t, string(call.raw), "input_image")
564 }
565 }
566
567 require.Equal(t, 1, turnCalls)
568 second.Close(t)
569
570 third := newDaemonMessageTest(t, dir, server)
571 require.Equal(t, 1, third.agents[1].memory.Len())
572 require.False(t, third.loops[1].waiting())
573 }
574
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()),
584 }
585
586 require.True(t, whatsapp.handle(event))
587 require.NoError(t, whatsapp.state.CloseAll())
588
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)
596 }
597
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),
604 },
605 }
606
607 return event
608 }
609
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" {
613 text = ""
614 }
615
616 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
617 }