]> git.giacomolli.uk - onechat/blob - whatsapp_test.go
Find WhatsApp contacts and groups
[onechat] / whatsapp_test.go
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "net/http"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14 "go.mau.fi/whatsmeow"
15 "go.mau.fi/whatsmeow/proto/waE2E"
16 "go.mau.fi/whatsmeow/proto/waWeb"
17 "go.mau.fi/whatsmeow/types"
18 "go.mau.fi/whatsmeow/types/events"
19 )
20
21 func TestWhatsAppStatusFollowsTheConnection(t *testing.T) {
22 dir := t.TempDir()
23 whatsappTestSession(t, dir, true)
24 clock := newTestClock()
25 whatsapp, fake, _ := whatsappStartTest(t, dir, clock)
26 daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
27
28 <-fake.connects
29 require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status())
30
31 require.True(t, fake.Emit(&events.Connected{}))
32 require.Equal(t, "WhatsApp is linked and connected since 2026-01-01 12:00.", whatsapp.Status())
33
34 clock.now = clock.now.Add(20 * time.Minute)
35 require.True(t, fake.Emit(&events.Disconnected{}))
36 clock.now = clock.now.Add(time.Minute)
37 require.True(t, fake.Emit(&events.Disconnected{}))
38 require.Equal(t, "WhatsApp is linked and disconnected since 2026-01-01 12:20. The last connection started at 2026-01-01 12:00.", whatsapp.Status())
39 }
40
41 func TestWhatsAppStatusOfAFirstConnectionThatFailed(t *testing.T) {
42 dir := t.TempDir()
43 whatsappTestSession(t, dir, true)
44 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
45 daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
46
47 <-fake.connects
48 require.True(t, fake.Emit(&events.Disconnected{}))
49 require.Equal(t, "WhatsApp is linked and disconnected since 2026-01-01 12:00.", whatsapp.Status())
50 }
51
52 func TestWhatsAppIsNotLinkedWithoutALinkedDevice(t *testing.T) {
53 testCases := []struct {
54 name string
55 session func(t *testing.T, dir string)
56 }{
57 {
58 name: "no session file",
59 session: func(t *testing.T, dir string) {
60 },
61 },
62 {
63 name: "a session of a pairing that did not end",
64 session: func(t *testing.T, dir string) {
65 whatsappTestSession(t, dir, false)
66 },
67 },
68 }
69
70 for _, tc := range testCases {
71 t.Run(tc.name, func(t *testing.T) {
72 dir := t.TempDir()
73 tc.session(t, dir)
74 whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
75 require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
76 })
77 }
78 }
79
80 func TestWhatsAppRefusesASessionThatOthersCanRead(t *testing.T) {
81 dir := t.TempDir()
82 whatsappTestSession(t, dir, true)
83 require.NoError(t, os.Chmod(filepath.Join(dir, whatsappDirName, whatsappSessionFileName), 0o644))
84
85 _, err := openWhatsApp(t.Context(), dir, time.Now, whatsappDiscardLog(), whatsappNewClient)
86 require.EqualError(t, err, "whatsapp/session.db holds the WhatsApp session, so its mode must be 0600")
87 }
88
89 func TestWhatsAppCommandsAnswerOnlyTheClientThatAsked(t *testing.T) {
90 server := newModelFake(t)
91 telegram := newTelegramFake(t)
92 bot, _ := telegramStartTestBot(t, telegram)
93 socket := daemonStartTestWithBot(t, server, bot)
94 asking := terminalStartTestClient(t, socket)
95 other := terminalStartTestClient(t, socket)
96 asking.WaitFor(t, "[connected to OneChat]")
97 other.WaitFor(t, "[connected to OneChat]")
98
99 asking.Type("/whatsapp status")
100 asking.WaitFor(t, "WhatsApp is not linked.")
101 asking.Type(" /whatsapp unknown ")
102 asking.WaitFor(t, whatsappUsage)
103 asking.Type("/whatsapp pair 0123")
104 asking.WaitFor(t, "PHONE must be the number of your WhatsApp account")
105
106 telegram.Add(TelegramUpdate{
107 UpdateID: 1,
108 Message: telegramOwnerMessage("/whatsapp status"),
109 })
110
111 telegramWaitForSent(t, telegram, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.")
112 require.Len(t, telegram.Sent(), 1)
113 require.Equal(t, "[connected to OneChat]\n", other.Output())
114 require.Zero(t, server.Calls())
115 }
116
117 func TestWhatsAppPairsWithACode(t *testing.T) {
118 dir := t.TempDir()
119 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
120 server := newModelFake(t)
121 socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
122 asking := terminalStartTestClient(t, socket)
123 other := terminalStartTestClient(t, socket)
124 asking.WaitFor(t, "[connected to OneChat]")
125 other.WaitFor(t, "[connected to OneChat]")
126
127 asking.Type("/whatsapp pair +1 555-000-1111")
128 items := <-fake.pairings
129 items <- whatsmeow.QRChannelItem{
130 Event: whatsmeow.QRChannelEventCode,
131 }
132
133 asking.WaitFor(t, whatsappPairSteps+"\n"+whatsappTestCode+"\n")
134 require.True(t, fake.Emit(&events.PairSuccess{
135 ID: types.NewADJID(whatsappTestNumber, 0, 1),
136 LID: types.NewADJID(whatsappTestLID, 0, 1),
137 }))
138 items <- whatsmeow.QRChannelSuccess
139 asking.WaitFor(t, "WhatsApp is linked to +15550001111.")
140
141 require.Equal(t, []string{whatsappTestNumber}, fake.Phones())
142 require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status())
143 require.Equal(t, "[connected to OneChat]\n", other.Output())
144 require.Zero(t, server.Calls())
145 require.Equal(t, []string{"WhatsApp is linked."}, whatsappTestLogTexts(t, memory))
146
147 info, err := os.Stat(filepath.Join(dir, whatsappDirName))
148 require.NoError(t, err)
149 require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
150
151 info, err = os.Stat(filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
152 require.NoError(t, err)
153 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
154 }
155
156 func TestWhatsAppPairingCodeIsATelegramMessageAlone(t *testing.T) {
157 whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
158 telegram := newTelegramFake(t)
159 bot, _ := telegramStartTestBot(t, telegram)
160 daemonStartTestWithWhatsApp(t, newModelFake(t), bot, whatsapp)
161
162 telegram.Add(TelegramUpdate{
163 UpdateID: 1,
164 Message: telegramOwnerMessage("/whatsapp pair 15550001111"),
165 })
166
167 items := <-fake.pairings
168 items <- whatsmeow.QRChannelItem{
169 Event: whatsmeow.QRChannelEventCode,
170 }
171
172 telegramWaitForSent(t, telegram, whatsappPairSteps, whatsappTestCode)
173 }
174
175 func TestWhatsAppPairingEnds(t *testing.T) {
176 testCases := []struct {
177 name string
178 end func(items chan whatsmeow.QRChannelItem)
179 answer string
180 }{
181 {
182 name: "the code expires",
183 end: func(items chan whatsmeow.QRChannelItem) {
184 items <- whatsmeow.QRChannelTimeout
185 },
186 answer: whatsappPairExpired,
187 },
188 {
189 name: "the library closes the pairing",
190 end: func(items chan whatsmeow.QRChannelItem) {
191 close(items)
192 },
193 answer: whatsappPairExpired,
194 },
195 {
196 name: "the pairing fails",
197 end: func(items chan whatsmeow.QRChannelItem) {
198 items <- whatsmeow.QRChannelItem{
199 Event: whatsmeow.QRChannelEventError,
200 Error: errors.New("bad handshake"),
201 }
202 },
203 answer: "The pairing failed: bad handshake. Run /whatsapp pair PHONE again.",
204 },
205 }
206
207 for _, tc := range testCases {
208 t.Run(tc.name, func(t *testing.T) {
209 whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
210 socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
211 client := terminalStartTestClient(t, socket)
212 client.Type("/whatsapp pair 15550001111")
213
214 items := <-fake.pairings
215 items <- whatsmeow.QRChannelItem{
216 Event: whatsmeow.QRChannelEventCode,
217 }
218
219 client.WaitFor(t, whatsappTestCode)
220 tc.end(items)
221 client.WaitFor(t, tc.answer)
222 require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
223 })
224 }
225 }
226
227 func TestWhatsAppNewPairingEndsTheOneBefore(t *testing.T) {
228 whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
229 socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
230 client := terminalStartTestClient(t, socket)
231
232 client.Type("/whatsapp pair 15550001111")
233 <-fake.pairings
234 client.Type("/whatsapp pair 15550002222")
235 items := <-fake.pairings
236 items <- whatsmeow.QRChannelItem{
237 Event: whatsmeow.QRChannelEventCode,
238 }
239
240 client.WaitFor(t, whatsappTestCode)
241 require.Equal(t, []string{"15550002222"}, fake.Phones())
242 require.NotContains(t, client.Output(), "did not start the pairing")
243 }
244
245 func TestWhatsAppPairWhenLinked(t *testing.T) {
246 dir := t.TempDir()
247 whatsappTestSession(t, dir, true)
248 whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
249 socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
250 client := terminalStartTestClient(t, socket)
251
252 client.Type("/whatsapp pair 15550001111")
253 client.WaitFor(t, "WhatsApp is linked already. Run /whatsapp unlink first.")
254 }
255
256 func TestWhatsAppUnlink(t *testing.T) {
257 testCases := []struct {
258 name string
259 logoutErr error
260 answer string
261 }{
262 {
263 name: "with a logout",
264 answer: "WhatsApp is unlinked.",
265 },
266 {
267 name: "with no logout",
268 logoutErr: errors.New("not connected"),
269 answer: "WhatsApp is unlinked here, but the phone did not get the logout. In WhatsApp on your phone, open Linked devices and remove the device.",
270 },
271 }
272
273 for _, tc := range testCases {
274 t.Run(tc.name, func(t *testing.T) {
275 dir := t.TempDir()
276 whatsappTestSession(t, dir, true)
277 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
278 fake.logoutErr = tc.logoutErr
279 socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
280 client := terminalStartTestClient(t, socket)
281 <-fake.connects
282
283 client.Type("/whatsapp unlink")
284 client.WaitFor(t, tc.answer)
285 require.Equal(t, 1, fake.Logouts())
286 require.NoFileExists(t, filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
287 require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
288 require.Equal(t, []string{"WhatsApp is unlinked."}, whatsappTestLogTexts(t, memory))
289
290 client.Type("/whatsapp unlink")
291 client.WaitFor(t, "WhatsApp is not linked.\n")
292 })
293 }
294 }
295
296 func TestWhatsAppLogoutFromThePhone(t *testing.T) {
297 dir := t.TempDir()
298 whatsappTestSession(t, dir, true)
299 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
300 telegram := newTelegramFake(t)
301 bot, _ := telegramStartTestBot(t, telegram)
302 socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), bot, whatsapp)
303 client := terminalStartTestClient(t, socket)
304 client.WaitFor(t, "[connected to OneChat]")
305 <-fake.connects
306
307 require.True(t, fake.Emit(&events.LoggedOut{}))
308 notice := "WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again."
309 client.WaitFor(t, notice)
310 telegramWaitForSent(t, telegram, notice)
311 require.NoFileExists(t, filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
312 require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
313 require.Equal(t, []string{"WhatsApp was unlinked from the phone."}, whatsappTestLogTexts(t, memory))
314 }
315
316 func TestWhatsAppLongDisconnectionGivesOneNotice(t *testing.T) {
317 dir := t.TempDir()
318 whatsappTestSession(t, dir, true)
319 clock := newTestClock()
320 whatsapp, fake, _ := whatsappStartTest(t, dir, clock)
321 whatsapp.noticeDelay = 50 * time.Millisecond
322 socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
323 client := terminalStartTestClient(t, socket)
324 client.WaitFor(t, "[connected to OneChat]")
325 <-fake.connects
326
327 require.True(t, fake.Emit(&events.Connected{}))
328 require.True(t, fake.Emit(&events.Disconnected{}))
329 require.True(t, fake.Emit(&events.Connected{}))
330 time.Sleep(100 * time.Millisecond)
331 require.Equal(t, "[connected to OneChat]\n", client.Output())
332
333 clock.now = clock.now.Add(time.Hour)
334 require.True(t, fake.Emit(&events.Disconnected{}))
335 require.True(t, fake.Emit(&events.Disconnected{}))
336 client.WaitFor(t, "WhatsApp is disconnected since 2026-01-01 13:00. OneChat tries again.\n")
337 require.True(t, fake.Emit(&events.Disconnected{}))
338 time.Sleep(100 * time.Millisecond)
339
340 require.True(t, fake.Emit(&events.Connected{}))
341 client.WaitFor(t, "WhatsApp is connected again.\n")
342 require.Equal(t, 1, strings.Count(client.Output(), "WhatsApp is disconnected since"))
343 }
344
345 func TestWhatsAppDisconnectionWithNoRetryGivesANoticeAtOnce(t *testing.T) {
346 dir := t.TempDir()
347 whatsappTestSession(t, dir, true)
348 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
349 socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
350 client := terminalStartTestClient(t, socket)
351 client.WaitFor(t, "[connected to OneChat]")
352 <-fake.connects
353
354 require.True(t, fake.Emit(&events.StreamReplaced{}))
355 client.WaitFor(t, "WhatsApp is disconnected and does not connect again by itself: stream replaced. Restart the daemon to try again.")
356 }
357
358 func TestWhatsAppLogsNoOwnNumber(t *testing.T) {
359 dir := t.TempDir()
360 whatsappTestSession(t, dir, true)
361 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
362 fake.connectErr = errors.New("no route for " + whatsappTestNumber + "@s.whatsapp.net and " + whatsappTestLID + "@lid")
363 daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
364 <-fake.connects
365
366 require.Eventually(t, func() bool {
367 return logs.String() != ""
368 }, 10*time.Second, time.Millisecond)
369 require.Equal(t, "connect: no route for OWN@s.whatsapp.net and OWN@lid\n", logs.String())
370 }
371
372 func TestWhatsAppPhone(t *testing.T) {
373 testCases := []struct {
374 name string
375 phone string
376 valid bool
377 }{
378 {
379 name: "a number with a plus and dashes",
380 phone: "+1-555-000-1111",
381 valid: true,
382 },
383 {
384 name: "a number with no country code",
385 phone: "0115550001111",
386 valid: false,
387 },
388 {
389 name: "a short number",
390 phone: "123456",
391 valid: false,
392 },
393 {
394 name: "a long number",
395 phone: "1234567890123456",
396 valid: false,
397 },
398 {
399 name: "a number with letters",
400 phone: "1555ABC1111",
401 valid: false,
402 },
403 }
404
405 for _, tc := range testCases {
406 t.Run(tc.name, func(t *testing.T) {
407 _, valid := whatsappPhone(tc.phone)
408 require.Equal(t, tc.valid, valid)
409 })
410 }
411 }
412
413 func TestWhatsAppCommandArgs(t *testing.T) {
414 testCases := []struct {
415 name string
416 line string
417 args string
418 found bool
419 }{
420 {
421 name: "a command with its arguments",
422 line: " /whatsapp pair +1 555 ",
423 args: "pair +1 555",
424 found: true,
425 },
426 {
427 name: "the command alone",
428 line: "/whatsapp",
429 args: "",
430 found: true,
431 },
432 {
433 name: "a word that starts with the command",
434 line: "/whatsappstatus",
435 args: "",
436 found: false,
437 },
438 {
439 name: "a message that names the command",
440 line: "what does /whatsapp status do?",
441 args: "",
442 found: false,
443 },
444 }
445
446 for _, tc := range testCases {
447 t.Run(tc.name, func(t *testing.T) {
448 args, found := whatsappCommandArgs(tc.line)
449 require.Equal(t, tc.args, args)
450 require.Equal(t, tc.found, found)
451 })
452 }
453 }
454
455 // whatsappTestLogTexts returns the texts of the messages in the log of memory,
456 // after the log holds at least one message.
457 func whatsappTestLogTexts(t *testing.T, memory *Memory) []string {
458 t.Helper()
459
460 require.Eventually(t, func() bool {
461 return memory.Len() > 0
462 }, 10*time.Second, time.Millisecond)
463
464 var texts []string
465 for id := range memory.Len() {
466 message, err := memory.Message(id)
467 require.NoError(t, err)
468
469 texts = append(texts, message.Text)
470 }
471
472 return texts
473 }
474
475 func TestWhatsAppPrivateBatchStartsATurnWithAnEmptyReply(t *testing.T) {
476 whatsapp, fake := whatsappStartTestLinked(t)
477 require.NoError(t, whatsapp.device.Contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza"))
478
479 server := newModelFake(t)
480 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
481 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
482 }
483
484 telegram := newTelegramFake(t)
485 bot, _ := telegramStartTestBot(t, telegram)
486 socket, memory := daemonStartTestWithWhatsApp(t, server, bot, whatsapp)
487 client := terminalStartTestClient(t, socket)
488 client.WaitFor(t, "[connected to OneChat]")
489 whatsappTestConnect(t, fake)
490
491 at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
492 require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "Are you at home?", at)))
493 require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestOwn, "m2", "Yes", at)))
494
495 text := "[WhatsApp] Ana Souza, private chat 15550002222@s.whatsapp.net, Thursday 2026-01-01\n12:00 Ana Souza: Are you at home?\n12:00 You: Yes\n" + whatsappBatchEmptyReply
496 client.WaitFor(t, text+"\n[working]\n[done]\n")
497 require.Equal(t, 1, server.Calls())
498 require.Contains(t, string(server.Call(0).raw), "Are you at home?")
499 require.Equal(t, []string{text}, whatsappTestLogTexts(t, memory))
500 require.Empty(t, telegram.Sent())
501 require.Eventually(t, func() bool {
502 return len(whatsapp.batches.Closed()) == 0
503 }, 10*time.Second, time.Millisecond)
504 }
505
506 func TestWhatsAppGroupTurns(t *testing.T) {
507 testCases := []struct {
508 name string
509 message func(event *events.Message)
510 line string
511 turn bool
512 }{
513 {
514 name: "plain",
515 message: func(event *events.Message) {
516 },
517 line: "12:00 Bruno Lima: Who brings the cake?",
518 },
519 {
520 name: "mention",
521 message: func(event *events.Message) {
522 event.Message = &waE2E.Message{
523 ExtendedTextMessage: &waE2E.ExtendedTextMessage{
524 Text: new("@" + whatsappTestLID + " can you bring the cake? Call " + whatsappTestNumber + "."),
525 ContextInfo: &waE2E.ContextInfo{
526 MentionedJID: []string{whatsappTestLID + "@lid"},
527 },
528 },
529 }
530 },
531 line: "12:00 Bruno Lima (mentions you): @you can you bring the cake? Call OWN.",
532 turn: true,
533 },
534 {
535 name: "reply",
536 message: func(event *events.Message) {
537 event.Message = &waE2E.Message{
538 ExtendedTextMessage: &waE2E.ExtendedTextMessage{
539 Text: new("Who brings the cake?"),
540 ContextInfo: &waE2E.ContextInfo{
541 Participant: new(whatsappTestOwn.String()),
542 },
543 },
544 }
545 },
546 line: "12:00 Bruno Lima (replies to you): Who brings the cake?",
547 turn: true,
548 },
549 }
550
551 for _, tc := range testCases {
552 t.Run(tc.name, func(t *testing.T) {
553 whatsapp, fake := whatsappStartTestLinked(t)
554 fake.groups = []*types.GroupInfo{
555 {
556 JID: whatsappTestFamily,
557 GroupName: types.GroupName{
558 Name: "Family",
559 },
560 },
561 }
562
563 server := newModelFake(t)
564 socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
565 client := terminalStartTestClient(t, socket)
566 client.WaitFor(t, "[connected to OneChat]")
567 whatsappTestConnect(t, fake)
568
569 event := whatsappTestMessage(whatsappTestFamily, whatsappTestBruno, "m1", "Who brings the cake?", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))
570 event.Info.PushName = "Bruno Lima"
571 tc.message(event)
572 require.True(t, fake.Emit(event))
573
574 text := "[WhatsApp] Family, group 120363000000000001@g.us, Thursday 2026-01-01\n" + tc.line
575 if tc.turn {
576 text += "\n" + whatsappBatchEmptyReply
577 client.WaitFor(t, "[done]")
578 require.Equal(t, 1, server.Calls())
579 } else {
580 client.WaitFor(t, text)
581 time.Sleep(100 * time.Millisecond)
582 require.Zero(t, server.Calls())
583 }
584
585 require.Equal(t, text, whatsappTestLogTexts(t, memory)[0])
586 })
587 }
588 }
589
590 func TestWhatsAppTakesOnlyText(t *testing.T) {
591 at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
592 testCases := []struct {
593 name string
594 change func(event *events.Message)
595 }{
596 {
597 name: "an edit",
598 change: func(event *events.Message) {
599 event.IsEdit = true
600 },
601 },
602 {
603 name: "old history",
604 change: func(event *events.Message) {
605 event.SourceWebMsg = &waWeb.WebMessageInfo{}
606 },
607 },
608 {
609 name: "a view-once message",
610 change: func(event *events.Message) {
611 event.IsViewOnce = true
612 },
613 },
614 {
615 name: "a disappearing message",
616 change: func(event *events.Message) {
617 event.IsEphemeral = true
618 },
619 },
620 {
621 name: "the chat of the user with itself",
622 change: func(event *events.Message) {
623 event.Info.Chat = whatsappTestOwn
624 },
625 },
626 {
627 name: "a status broadcast",
628 change: func(event *events.Message) {
629 event.Info.Chat = types.StatusBroadcastJID
630 },
631 },
632 {
633 name: "a photo with a caption",
634 change: func(event *events.Message) {
635 event.Message = &waE2E.Message{
636 ImageMessage: &waE2E.ImageMessage{
637 Caption: new("look"),
638 },
639 }
640 },
641 },
642 {
643 name: "a text of spaces",
644 change: func(event *events.Message) {
645 event.Message.Conversation = new(" ")
646 },
647 },
648 }
649
650 for _, tc := range testCases {
651 t.Run(tc.name, func(t *testing.T) {
652 whatsapp, _ := whatsappStartTestLinked(t)
653 event := whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "hi", at)
654 tc.change(event)
655 require.True(t, whatsapp.handle(event))
656 require.NoError(t, whatsapp.batches.CloseAll())
657 require.Empty(t, whatsapp.batches.Closed())
658 })
659 }
660 }
661
662 // whatsappStartTestLinked opens a WhatsApp with a linked device in a temporary
663 // directory, with a quiet time of 50 ms for its batches.
664 func whatsappStartTestLinked(t *testing.T) (*WhatsApp, *WhatsAppFake) {
665 t.Helper()
666
667 dir := t.TempDir()
668 whatsappTestSession(t, dir, true)
669 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
670 whatsapp.batches.quiet = 50 * time.Millisecond
671
672 return whatsapp, fake
673 }
674
675 func TestWhatsAppCatchupGivesOneTurn(t *testing.T) {
676 whatsapp, fake := whatsappStartTestLinked(t)
677 server := newModelFake(t)
678 socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
679 client := terminalStartTestClient(t, socket)
680 client.WaitFor(t, "[connected to OneChat]")
681 <-fake.connects
682
683 require.True(t, fake.Emit(&events.Connected{}))
684 at := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC)
685 for index, event := range []*events.Message{
686 whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "Good morning", at),
687 whatsappTestMessage(whatsappTestBruno, whatsappTestBruno, "m2", "Call me", at.Add(time.Hour)),
688 whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m3", "Are you there?", at.Add(2*time.Hour)),
689 } {
690 event.Info.PushName = []string{"Ana", "Bruno", "Ana"}[index]
691 require.True(t, fake.Emit(event))
692 }
693
694 time.Sleep(100 * time.Millisecond)
695 require.Zero(t, memory.Len())
696
697 require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
698 client.WaitFor(t, "[done]")
699 require.Equal(t, []string{
700 "[WhatsApp] Ana, private chat 15550002222@s.whatsapp.net, Thursday 2026-01-01\n08:00 Ana: Good morning\n" + whatsappBatchEmptyReply,
701 "[WhatsApp] Bruno, private chat 15550003333@s.whatsapp.net, Thursday 2026-01-01\n09:00 Bruno: Call me\n" + whatsappBatchEmptyReply,
702 "[WhatsApp] Ana, private chat 15550002222@s.whatsapp.net, Thursday 2026-01-01\n10:00 Ana: Are you there?\n" + whatsappBatchEmptyReply,
703 }, whatsappTestLogTexts(t, memory)[:3])
704
705 time.Sleep(100 * time.Millisecond)
706 require.Equal(t, 1, server.Calls())
707 for _, text := range []string{"Good morning", "Call me", "Are you there?"} {
708 require.Contains(t, string(server.Call(0).raw), text)
709 }
710 }
711
712 func TestWhatsAppCatchupEndsAfterItsLimit(t *testing.T) {
713 whatsapp, fake := whatsappStartTestLinked(t)
714 whatsapp.catchupLimit = 100 * time.Millisecond
715 socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
716 client := terminalStartTestClient(t, socket)
717 client.WaitFor(t, "[connected to OneChat]")
718 <-fake.connects
719
720 require.True(t, fake.Emit(&events.Connected{}))
721 require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestOwn, "m1", "On my way.", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))))
722 client.WaitFor(t, "12:00 You: On my way.")
723 require.Len(t, whatsappTestLogTexts(t, memory), 1)
724 }
725
726 func TestWhatsAppBatchesOfTheStateFileWaitForTheCatchup(t *testing.T) {
727 dir := t.TempDir()
728 whatsappTestSession(t, dir, true)
729 before, _, _ := whatsappStartTest(t, dir, newTestClock())
730 _, err := before.batches.Add(whatsappTestChat, WhatsAppMessage{
731 ID: "m1",
732 Sender: "Ana Souza",
733 Time: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
734 Text: "Did you see my message?",
735 }, time.Now().Add(-time.Hour))
736 require.NoError(t, err)
737
738 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
739 socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
740 client := terminalStartTestClient(t, socket)
741 client.WaitFor(t, "[connected to OneChat]")
742 <-fake.connects
743
744 time.Sleep(100 * time.Millisecond)
745 require.Zero(t, memory.Len())
746
747 require.True(t, fake.Emit(&events.Connected{}))
748 require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
749 client.WaitFor(t, "12:00 Ana Souza: Did you see my message?")
750 client.WaitFor(t, "[done]")
751 }
752
753 // whatsappTestConnect waits for the connection of the daemon and gives it the
754 // events of a connection with an empty catch-up.
755 func whatsappTestConnect(t *testing.T, fake *WhatsAppFake) {
756 t.Helper()
757
758 <-fake.connects
759 require.True(t, fake.Emit(&events.Connected{}))
760 require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
761 }
762
763 func TestWhatsAppFindMatchesNamesWithNoRegardToCaseAndAccents(t *testing.T) {
764 whatsapp, fake := whatsappStartTestLinked(t)
765 fake.groups = []*types.GroupInfo{
766 {
767 JID: whatsappTestFamily,
768 GroupName: types.GroupName{
769 Name: "Família Souza",
770 },
771 },
772 }
773
774 whatsappTestContacts(t, whatsapp)
775 daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
776 whatsappTestConnect(t, fake)
777
778 testCases := []struct {
779 name string
780 query string
781 answer string
782 }{
783 {
784 name: "no accent finds an accent",
785 query: "joao",
786 answer: "15550004444@s.whatsapp.net | João Silva | contact",
787 },
788 {
789 name: "a part of a name finds contacts and groups",
790 query: "SOUZA",
791 answer: "15550002222@s.whatsapp.net | Ana Souza | contact\n120363000000000001@g.us | Família Souza | group",
792 },
793 {
794 name: "a business name",
795 query: "bakery",
796 answer: "15550005555@s.whatsapp.net | Corner Bakery | contact",
797 },
798 {
799 name: "no match",
800 query: "Zed",
801 answer: "No contact or group has a name with \"Zed\".",
802 },
803 }
804
805 for _, tc := range testCases {
806 t.Run(tc.name, func(t *testing.T) {
807 answer, err := whatsapp.Find(t.Context(), tc.query)
808 require.NoError(t, err)
809 require.Equal(t, tc.answer, answer)
810 })
811 }
812 }
813
814 func TestWhatsAppFindGivesAtMostTwentyMatches(t *testing.T) {
815 whatsapp, _ := whatsappStartTestLinked(t)
816 for index := range 25 {
817 jid := types.NewJID(fmt.Sprintf("1555001%04d", index), types.DefaultUserServer)
818 require.NoError(t, whatsapp.device.Contacts.PutContactName(t.Context(), jid, "", fmt.Sprintf("Member %02d", index)))
819 }
820
821 answer, err := whatsapp.Find(t.Context(), "member")
822 require.NoError(t, err)
823
824 lines := strings.Split(answer, "\n")
825 require.Len(t, lines, 22)
826 require.Equal(t, "15550010000@s.whatsapp.net | Member 00 | contact", lines[0])
827 require.Equal(t, "5 more: give a longer part of the name.", lines[20])
828 require.Equal(t, "The groups are not in this answer, because WhatsApp is disconnected.", lines[21])
829 }
830
831 func TestWhatsAppFindBeforeTheNamesArrive(t *testing.T) {
832 whatsapp, fake := whatsappStartTestLinked(t)
833 daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
834 whatsappTestConnect(t, fake)
835
836 answer, err := whatsapp.Find(t.Context(), "ana")
837 require.NoError(t, err)
838 require.Equal(t, whatsappNamesNotReady, answer)
839 }
840
841 func TestWhatsAppFindWithoutALink(t *testing.T) {
842 whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
843
844 answer, err := whatsapp.Find(t.Context(), "ana")
845 require.NoError(t, err)
846 require.Equal(t, whatsappNotLinked, answer)
847 }
848
849 func TestAgentFindsAWhatsAppContact(t *testing.T) {
850 whatsapp, fake := whatsappStartTestLinked(t)
851 whatsappTestContacts(t, whatsapp)
852 server := newModelFake(t)
853 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
854 if call.number == 0 {
855 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "whatsapp_find", `{"name":"ana"}`)}, 100, 0, 5))
856 return
857 }
858
859 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Found Ana.")}, 100, 0, 5))
860 }
861
862 socket, _ := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
863 client := terminalStartTestClient(t, socket)
864 whatsappTestConnect(t, fake)
865
866 client.Type("Find Ana on WhatsApp.")
867 client.WaitFor(t, "[result] 15550002222@s.whatsapp.net | Ana Souza | contact\nFound Ana.\n[done]")
868 }
869
870 func TestSubagentHasNoWhatsAppTools(t *testing.T) {
871 box := &Toolbox{}
872 require.Equal(t, `error: unknown tool "whatsapp_find"`, box.run(t.Context(), "whatsapp_find", `{"name":"ana"}`))
873 }
874
875 // whatsappTestContacts puts the contacts of the tests into the session of
876 // whatsapp: Ana Souza, Joao Silva with an accent, a business, and the user.
877 func whatsappTestContacts(t *testing.T, whatsapp *WhatsApp) {
878 t.Helper()
879
880 contacts := whatsapp.device.Contacts
881 require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza"))
882 require.NoError(t, contacts.PutContactName(t.Context(), types.NewJID("15550004444", types.DefaultUserServer), "João", "João Silva"))
883 _, _, err := contacts.PutBusinessName(t.Context(), types.NewJID("15550005555", types.DefaultUserServer), "Corner Bakery")
884 require.NoError(t, err)
885 require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestOwn, "Me", "Me Souza"))
886 }