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