]> git.giacomolli.uk - onechat/blob - whatsapp_image_test.go
Derive contexts from the caller and name timeouts
[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 "time"
19
20 "github.com/stretchr/testify/require"
21 "go.mau.fi/whatsmeow"
22 "go.mau.fi/whatsmeow/proto/waE2E"
23 "go.mau.fi/whatsmeow/types"
24 "go.mau.fi/whatsmeow/types/events"
25 )
26
27 func TestWhatsAppImagesAreDurableBeforeAcknowledgement(t *testing.T) {
28 testCases := []struct {
29 name string
30 sender types.JID
31 }{
32 {
33 name: "a contact image keeps its caption and pixels",
34 sender: whatsappTestAna,
35 },
36 {
37 name: "an own image keeps its caption and pixels",
38 sender: whatsappTestOwn,
39 },
40 }
41
42 for _, tc := range testCases {
43 t.Run(tc.name, func(t *testing.T) {
44 dir := t.TempDir()
45 whatsappTestSession(t, dir, true)
46
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])
55
56 restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
57 require.NoError(t, restored.CloseAll())
58 require.Len(t, restored.Closed(), 1)
59
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)))
66
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)
71
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")
77 })
78 }
79 }
80
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
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 func TestWhatsAppImageFailureKeepsTheBatchMoving(t *testing.T) {
107 dir := t.TempDir()
108 whatsappTestSession(t, dir, true)
109
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)))
116
117 restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
118 require.NoError(t, restored.CloseAll())
119 require.Len(t, restored.Closed(), 1)
120
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")
132 }
133
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 {
138 name string
139 err error
140 cause string
141 }{
142 {
143 name: "signed request URLs do not reach the log",
144 err: &url.Error{
145 Op: "Get",
146 URL: mediaURL,
147 Err: errors.New("connection failed"),
148 },
149 cause: "media download failed",
150 },
151 {
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",
155 },
156 {
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",
160 },
161 {
162 name: "cancellation keeps its safe cause without the URL",
163 err: fmt.Errorf("request %s: %w", mediaURL, context.Canceled),
164 cause: "context canceled",
165 },
166 {
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",
170 },
171 {
172 name: "HTTP failures keep only the status code",
173 err: whatsmeow.DownloadHTTPError{Response: &http.Response{
174 StatusCode: http.StatusForbidden,
175 Status: "403 " + mediaURL,
176 }},
177 cause: "media server returned HTTP 403",
178 },
179 }
180
181 for _, tc := range testCases {
182 t.Run(tc.name, func(t *testing.T) {
183 dir := t.TempDir()
184 whatsappTestSession(t, dir, true)
185
186 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
187 whatsapp.client = fake
188 fake.downloadErr = tc.err
189
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)
195
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)
198 }
199 })
200 }
201 }
202
203 func TestWhatsAppImageLimitsKeepMarkersWithoutStoredPixels(t *testing.T) {
204 var gifBytes bytes.Buffer
205
206 require.NoError(t, gif.Encode(&gifBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
207
208 testCases := []struct {
209 name string
210 mime string
211 size uint64
212 data []byte
213 downloads int
214 }{
215 {
216 name: "declared large images are not downloaded",
217 mime: "image/png",
218 size: imageMaxBytes + 1,
219 },
220 {
221 name: "large downloaded images become markers",
222 mime: "image/png",
223 data: bytes.Repeat([]byte{0}, imageMaxBytes+1),
224 downloads: 1,
225 },
226 {
227 name: "unsupported image types are not downloaded",
228 mime: "image/gif",
229 },
230 {
231 name: "GIF bytes cannot claim to be PNG",
232 mime: "image/png",
233 data: gifBytes.Bytes(),
234 downloads: 1,
235 },
236 {
237 name: "invalid image bytes become markers",
238 mime: "image/png",
239 data: []byte("not an image"),
240 downloads: 1,
241 },
242 }
243
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)
257
258 _, err := os.Stat(whatsapp.inbox.images.dir)
259 require.ErrorIs(t, err, os.ErrNotExist)
260 })
261 }
262 }
263
264 func TestWhatsAppSupportsJPEGPNGAndWebPBytes(t *testing.T) {
265 var jpegBytes bytes.Buffer
266
267 require.NoError(t, jpeg.Encode(&jpegBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
268
269 testCases := []struct {
270 name string
271 mime string
272 data []byte
273 suffix string
274 }{
275 {
276 name: "PNG bytes keep the PNG extension",
277 mime: "image/png",
278 data: imageTestPNG(t, 2, 2),
279 suffix: ".png",
280 },
281 {
282 name: "JPEG bytes set the JPEG extension even with a PNG label",
283 mime: "image/png",
284 data: jpegBytes.Bytes(),
285 suffix: ".jpg",
286 },
287 {
288 name: "WebP bytes keep the WebP extension",
289 mime: "image/webp",
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},
291 suffix: ".webp",
292 },
293 }
294
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())
304
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))
308
309 data, err := whatsapp.inbox.images.Read(names[0])
310 require.NoError(t, err)
311 require.Equal(t, tc.data, data)
312 })
313 }
314 }
315
316 func TestWhatsAppMediaMarkersNeverDownload(t *testing.T) {
317 testCases := []struct {
318 name string
319 message *waE2E.Message
320 text string
321 }{
322 {
323 name: "stickers keep a text marker",
324 message: &waE2E.Message{
325 StickerMessage: &waE2E.StickerMessage{},
326 },
327 text: "(sticker)",
328 },
329 {
330 name: "videos keep a text marker and caption",
331 message: &waE2E.Message{
332 VideoMessage: &waE2E.VideoMessage{Caption: new("Watch this.")},
333 },
334 text: "(video)\nWatch this.",
335 },
336 {
337 name: "GIF playback keeps a text marker",
338 message: &waE2E.Message{
339 VideoMessage: &waE2E.VideoMessage{GifPlayback: new(true)},
340 },
341 text: "(GIF)",
342 },
343 }
344
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())
356 })
357 }
358 }
359
360 func TestWhatsAppPrivateImagesAreNotDownloaded(t *testing.T) {
361 testCases := []struct {
362 name string
363 change func(event *events.Message)
364 }{
365 {
366 name: "view-once events are not read",
367 change: func(event *events.Message) {
368 event.IsViewOnce = true
369 },
370 },
371 {
372 name: "view-once image fields are not read",
373 change: func(event *events.Message) {
374 event.Message.ImageMessage.ViewOnce = new(true)
375 },
376 },
377 {
378 name: "disappearing images are not read",
379 change: func(event *events.Message) {
380 event.IsEphemeral = true
381 },
382 },
383 {
384 name: "self-chat images are not read",
385 change: func(event *events.Message) {
386 event.Info.Chat = whatsappTestOwn
387 },
388 },
389 }
390
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.")
396 tc.change(event)
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())
401 })
402 }
403 }
404
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")
412 }
413
414 func TestWhatsAppImagesReachTheirRootOrRoutedChild(t *testing.T) {
415 testCases := []struct {
416 name string
417 target int
418 group bool
419 }{
420 {
421 name: "private images wake the root with pixels",
422 },
423 {
424 name: "routed group images wake only their child with pixels",
425 target: 1,
426 group: true,
427 },
428 }
429
430 for _, tc := range testCases {
431 t.Run(tc.name, func(t *testing.T) {
432 dir := t.TempDir()
433 server := newModelFake(t)
434 server.reply = whatsappImageTestReply
435 test := newDaemonMessageTest(t, dir, server)
436 test.agents[0].agent.config.Model = "gpt-test-root"
437
438 whatsappTestSession(t, dir, true)
439
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?")
445 if tc.group {
446 event.Info.Chat = whatsappTestFamily
447 event.Info.IsGroup = true
448
449 whatsapp.groups.set(whatsappTestFamily.String(), "Garden plans")
450 require.NoError(t, test.messages.routes.Set("whatsapp:"+whatsappTestFamily.String(), "Garden"))
451 }
452
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)
457
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())
464
465 if tc.target != 0 {
466 require.Zero(t, test.agents[0].memory.Len())
467 require.False(t, test.loops[0].waiting())
468 }
469
470 model := test.agents[tc.target].agent.config.Model
471 turnCalls := 0
472 for index := range server.Calls() {
473 call := server.Call(index)
474 if call.body["model"] == model {
475 turnCalls++
476
477 require.Contains(t, string(call.raw), "data:image/png;base64,"+base64.StdEncoding.EncodeToString(data))
478 }
479 }
480
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")
484 })
485 }
486 }
487
488 func TestWhatsAppImageRoutingSurvivesRestartWithoutDuplicateEntries(t *testing.T) {
489 dir := t.TempDir()
490 server := newModelFake(t)
491 server.reply = whatsappImageTestReply
492 first := newDaemonMessageTest(t, dir, server)
493 whatsappTestSession(t, dir, true)
494
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())
502
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)
508 first.Close(t)
509
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())
517
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)
522 second.Start(t, 1)
523 second.events[1].WaitFor(t, "idle", 1)
524 require.Empty(t, second.Queue(t).Pending)
525
526 turnCalls := 0
527 for index := range server.Calls() {
528 call := server.Call(index)
529 if call.body["model"] == "gpt-test-garden" {
530 turnCalls++
531
532 require.Contains(t, string(call.raw), "input_image")
533 }
534 }
535
536 require.Equal(t, 1, turnCalls)
537 second.Close(t)
538
539 third := newDaemonMessageTest(t, dir, server)
540 require.Equal(t, 1, third.agents[1].memory.Len())
541 require.False(t, third.loops[1].waiting())
542 }
543
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()),
553 }
554
555 require.True(t, whatsapp.handle(event))
556 require.NoError(t, whatsapp.state.CloseAll())
557
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)
565 }
566
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),
573 },
574 }
575
576 return event
577 }
578
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" {
582 text = ""
583 }
584
585 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
586 }