From: Anderson A. Giacomolli Date: Sun, 8 Nov 2026 21:38:18 +0000 (-0300) Subject: Give the WhatsApp batches their own type X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/a6a82d96b4906e9e1f09f45f69f48124f11ae1bb?hp=1804474c7a03cca8bf780dd870a985e7badb4331 Give the WhatsApp batches their own type WhatsAppInbox in whatsapp_inbox.go puts the incoming text messages into batches, logs the closed batches, and holds the catch-up after each connection. It owns the wake and failures channels of Run, the limit of the catch-up, and, under its own mutex, the state of the catch-up and its timer. The conversion of an event to a batch message goes with it. WhatsApp keeps the session and the connection. Its event handler gives each message to the inbox with a WhatsAppLink, and starts and ends the catch-up there. The state file stays a field of WhatsApp too, because it also holds the send records, and it has a mutex of its own. The tests of the batches and the catch-up move to whatsapp_inbox_test.go, and the helpers that more test files use move to whatsapp_fake_helper_test.go. The behavior does not change. Issue: 6f69ca1 --- diff --git a/whatsapp.go b/whatsapp.go index 929ae5d..c5af946 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -76,17 +76,10 @@ const ( // wait for Run. whatsappRequests = 8 - // whatsappSource is the source of the work events of the batches. - whatsappSource = "whatsapp" - // whatsappLookupTime is the longest time of a read of the names in the // session database or of the groups from WhatsApp. whatsappLookupTime = 10 * time.Second - // whatsappCatchupLimit is the longest wait for the end of the catch-up - // after a connection. - whatsappCatchupLimit = time.Minute - // whatsappFindMatches is the most matches of whatsapp_find. whatsappFindMatches = 20 @@ -133,17 +126,14 @@ type WhatsAppClient interface { // the client of a device with its event handler. func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *log.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) { w := &WhatsApp{ - dir: filepath.Join(dir, whatsappDirName), - clock: clock, - logger: logger, - newClient: newClient, - noticeDelay: whatsappNoticeDelay, - catchupLimit: whatsappCatchupLimit, - requests: make(chan WhatsAppRequest, whatsappRequests), - logouts: make(chan struct{}, 1), - wake: make(chan struct{}, 1), - failures: make(chan error, 1), - groups: newWhatsAppGroups(logger), + dir: filepath.Join(dir, whatsappDirName), + clock: clock, + logger: logger, + newClient: newClient, + noticeDelay: whatsappNoticeDelay, + requests: make(chan WhatsAppRequest, whatsappRequests), + logouts: make(chan struct{}, 1), + groups: newWhatsAppGroups(logger), } var err error @@ -152,6 +142,8 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge return nil, err } + w.inbox = newWhatsAppInbox(w.state, clock, w.groups) + info, err := os.Stat(w.sessionPath()) if errors.Is(err, os.ErrNotExist) { return w, nil @@ -172,7 +164,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge if w.device.ID != nil { w.linked = true - w.catchingUp = true + w.inbox.waitForCatchup() w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User) } @@ -192,20 +184,9 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge // client, and the state of the connection, which the event handler of the // library changes from its own goroutines. // -// The text messages go into batches, one chat at a time, as WhatsAppState says. -// Run logs each closed batch as a work message that starts with [WhatsApp], and -// a batch that needs the user wakes the turn loop. -// -// After each connection, the servers send the messages that came while the -// daemon was away, and then OfflineSyncCompleted. Until then, or until -// catchupLimit passes, the closed batches wait in the state file. At the end of -// the catch-up, Run closes all batches, logs them, and wakes the turn loop -// once, so the whole catch-up gives one turn and not one turn for each chat. -// The batches of the state file at the start wait for the first catch-up in the -// same way. The event handler acknowledges a message only after the state file -// holds it, so the servers send again a message that a crash lost. A batch -// leaves the state file only after its work message is durable in the log. A -// crash between the two makes the batch repeat once. +// The inbox puts the text messages into batches and logs them, and groups +// holds the names of the groups. Both get a WhatsAppLink from link for each +// call, so they use the current device and client. // // The library logs nothing, because its log lines hold the ID of the own // account, which is the phone number of the user. The own number and the own @@ -215,18 +196,16 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge // log, and those of private chats hold phone numbers, because the agent needs // them to send. type WhatsApp struct { - dir string - clock func() time.Time - logger *log.Logger - newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient - noticeDelay time.Duration - catchupLimit time.Duration - requests chan WhatsAppRequest - logouts chan struct{} - wake chan struct{} - failures chan error - state *WhatsAppState - groups *WhatsAppGroups + dir string + clock func() time.Time + logger *log.Logger + newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient + noticeDelay time.Duration + requests chan WhatsAppRequest + logouts chan struct{} + state *WhatsAppState + groups *WhatsAppGroups + inbox *WhatsAppInbox mutex sync.Mutex container *sqlstore.Container @@ -242,10 +221,6 @@ type WhatsApp struct { noticed bool timer *time.Timer closed bool - catchingUp bool - caughtUp bool - catchups int - catchupTimer *time.Timer } // Run connects a linked device, logs the closed batches in l, runs the pair and @@ -274,24 +249,18 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err }() for { - err := w.deliver(ctx, l, false) + err := w.inbox.deliver(ctx, l, w.link(), false) if err != nil { return err } - var due <-chan time.Time - wait, open := w.state.Wait(time.Now()) - if open { - due = time.After(wait) - } - select { case <-ctx.Done(): return nil - case err = <-w.failures: + case err = <-w.inbox.failures: return err - case <-w.wake: - case <-due: + case <-w.inbox.wake: + case <-w.inbox.due(): case <-w.logouts: err = w.loggedOut(ctx, l) if err != nil { @@ -465,14 +434,12 @@ func (w *WhatsApp) close() { w.timer.Stop() } - if w.catchupTimer != nil { - w.catchupTimer.Stop() - } - client := w.client w.client = nil w.mutex.Unlock() + w.inbox.close() + if client != nil { client.Disconnect() } @@ -485,7 +452,7 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error { return nil } - err := w.deliver(ctx, l, true) + err := w.inbox.deliver(ctx, l, w.link(), true) if err != nil { return err } @@ -521,7 +488,7 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str return nil } - err := w.deliver(ctx, l, true) + err := w.inbox.deliver(ctx, l, w.link(), true) if err != nil { return err } @@ -555,71 +522,6 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str return nil } -// deliver logs the closed batches in l in order, each as a work message of -// whatsappSource, and wakes the loop once with the batches that start a turn. A -// batch leaves the state file after its message is durable in the log. A group -// with no name gets its name from WhatsApp first. During a catch-up it logs -// nothing, and at its end it closes all batches first. With all, it closes and -// logs all batches also during a catch-up. -func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error { - w.mutex.Lock() - catchingUp := w.catchingUp - caughtUp := w.caughtUp - w.caughtUp = false - w.mutex.Unlock() - - var err error - switch { - case all || caughtUp: - err = w.state.CloseAll() - case catchingUp: - return nil - default: - _, err = w.state.CloseDue(time.Now()) - } - - if err != nil { - return err - } - - var turns []Message - for _, batch := range w.state.Closed() { - if batch.Chat.Group && batch.Chat.Name == "" { - batch.Chat.Name = w.groups.name(ctx, w.link(), batch.Chat.ID) - } - - message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "") - if err != nil { - return fmt.Errorf("log a WhatsApp batch: %w", err) - } - - // A stop of the daemon during the flush is no failure: the - // batch stays in the state file and repeats once at the next - // start. - err = l.Flush(ctx) - if ctx.Err() != nil { - return nil - } - - if err != nil { - return fmt.Errorf("log a WhatsApp batch: %w", err) - } - - err = w.state.Remove(batch.Number) - if err != nil { - return err - } - - if batch.Turn() { - turns = append(turns, message) - } - } - - l.Wake(turns...) - - return nil -} - // Find returns the contacts and the groups whose name contains name, with no // regard to case and accents, as lines "ID | name | contact" or "ID | name | // group", sorted by name. It gives at most whatsappFindMatches lines and the @@ -703,7 +605,6 @@ func (w *WhatsApp) removeSession() error { w.linked = false w.own = nil w.connected = false - w.catchingUp = false if w.timer != nil { w.timer.Stop() } @@ -714,6 +615,8 @@ func (w *WhatsApp) removeSession() error { w.device = nil w.mutex.Unlock() + w.inbox.stopCatchup() + if client != nil { client.Disconnect() } @@ -874,16 +777,16 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) { } // handle takes one event of the library and tells if it was handled. A message -// goes to receive. The names of the groups follow the events of the groups. A -// connection starts a catch-up, which ends at OfflineSyncCompleted or after -// catchupLimit. A disconnection keeps the time of its start while the library -// tries again. After noticeDelay of a disconnection, all clients get one -// notice, and they get one more when the connection comes back. A disconnection -// that the library does not try again gives a notice at once. +// goes to the inbox. The names of the groups follow the events of the groups. A +// connection starts a catch-up of the inbox, which ends at OfflineSyncCompleted +// or after its catchupLimit. A disconnection keeps the time of its start while +// the library tries again. After noticeDelay of a disconnection, all clients +// get one notice, and they get one more when the connection comes back. A +// disconnection that the library does not try again gives a notice at once. func (w *WhatsApp) handle(event any) bool { switch event := event.(type) { case *events.Message: - return w.receive(event) + return w.inbox.receive(w.link(), event) case *events.GroupInfo: if event.Name != nil { w.groups.set(event.JID.String(), event.Name.Name) @@ -893,6 +796,11 @@ func (w *WhatsApp) handle(event any) bool { case *events.JoinedGroup: w.groups.set(event.JID.String(), event.Name) return true + case *events.OfflineSyncCompleted: + w.inbox.endCatchup() + return true + case *events.Connected: + w.inbox.startCatchup() } var notice string @@ -903,16 +811,6 @@ func (w *WhatsApp) handle(event any) bool { case *events.Connected: w.connected = true w.connectedAt = w.clock() - w.catchingUp = true - w.catchups++ - catchups := w.catchups - if w.catchupTimer != nil { - w.catchupTimer.Stop() - } - - w.catchupTimer = time.AfterFunc(w.catchupLimit, func() { - w.caughtUpBy(catchups) - }) if w.timer != nil { w.timer.Stop() } @@ -932,8 +830,6 @@ func (w *WhatsApp) handle(event any) bool { } w.connected = false - case *events.OfflineSyncCompleted: - w.endCatchup() case *events.LoggedOut: select { case w.logouts <- struct{}{}: @@ -955,72 +851,6 @@ func (w *WhatsApp) handle(event any) bool { return true } -// receive puts the text of event into its batch and wakes Run. It returns false -// when the state file cannot hold the message, so that the library does not -// acknowledge it, and Run stops the daemon. A message that is not text, or that -// the batches do not take, is handled with nothing to do. -func (w *WhatsApp) receive(event *events.Message) bool { - ctx, cancel := context.WithTimeout(context.Background(), whatsappLookupTime) - defer cancel() - - w.mutex.Lock() - device := w.device - own := slices.Clone(w.own) - w.mutex.Unlock() - - chat, message, found := whatsappMessage(ctx, device, own, event) - if !found { - return true - } - - _, err := w.state.Add(chat, message, time.Now()) - if err != nil { - select { - case w.failures <- err: - default: - } - - return false - } - - select { - case w.wake <- struct{}{}: - default: - } - - return true -} - -// caughtUpBy ends the catch-up of the connection with the number catchups, when -// no later connection started a new one. -func (w *WhatsApp) caughtUpBy(catchups int) { - w.mutex.Lock() - defer w.mutex.Unlock() - - if w.catchups == catchups { - w.endCatchup() - } -} - -// endCatchup ends a running catch-up and wakes Run, which logs all batches. The -// caller holds the mutex. -func (w *WhatsApp) endCatchup() { - if !w.catchingUp { - return - } - - w.catchingUp = false - w.caughtUp = true - if w.catchupTimer != nil { - w.catchupTimer.Stop() - } - - select { - case w.wake <- struct{}{}: - default: - } -} - // disconnectedLong gives the notice of a long disconnection, unless the // disconnection with the number disconnections ended. func (w *WhatsApp) disconnectedLong(disconnections int) { @@ -1183,90 +1013,6 @@ func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsA return client } -// whatsappMessage returns the chat and the batch message of event, or false -// when the batches do not take it. device is the linked device, and own holds -// the own IDs. The batches take the text of a private chat or a group, from -// another person or from the user. They do not take media, reactions, edits, -// deletions, view-once and disappearing messages, old history, status -// broadcasts, channels, and the chat of the user with itself. -// -// A mention of the user becomes @you, another mention @NAME when the name is -// known, and the own number and LID become OWN, so that they never go to the -// log. -func whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) { - info := event.Info - chatJID := info.Chat.ToNonAD() - switch { - case device == nil: - case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil: - case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer: - case slices.Contains(own, chatJID.User): - default: - return whatsappText(ctx, device, own, event) - } - - return WhatsAppChat{}, WhatsAppMessage{}, false -} - -// whatsappText returns the chat and the batch message of event, a message that -// the batches take when it has text. -func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) { - info := event.Info - text := event.Message.GetConversation() - extended := event.Message.GetExtendedTextMessage() - if text == "" { - text = extended.GetText() - } - - if strings.TrimSpace(text) == "" { - return WhatsAppChat{}, WhatsAppMessage{}, false - } - - contextInfo := extended.GetContextInfo() - message := WhatsAppMessage{ - ID: info.ID, - Time: info.Timestamp, - Mine: info.IsFromMe, - } - - if !message.Mine { - message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName) - } - - for _, mentioned := range contextInfo.GetMentionedJID() { - jid, err := types.ParseJID(mentioned) - switch { - case err != nil: - case slices.Contains(own, jid.User): - message.Mention = true - text = strings.ReplaceAll(text, "@"+jid.User, "@you") - default: - text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, "")) - } - } - - participant, err := types.ParseJID(contextInfo.GetParticipant()) - message.Reply = err == nil && slices.Contains(own, participant.User) - - message.Text = whatsappRedact(own, text) - chatJID := info.Chat.ToNonAD() - chat := WhatsAppChat{ - ID: chatJID.String(), - Group: chatJID.Server == types.GroupServer, - } - - if !chat.Group { - push := info.PushName - if message.Mine { - push = "" - } - - chat.Name = whatsappContactName(ctx, device, chatJID, push) - } - - return chat, message, true -} - // whatsappContactName returns the name of the person jid: the full name, the // first name, the business name, or the push name of the contact in the session // of device, then push, the push name of a message, then the phone number. A diff --git a/whatsapp_fake_helper_test.go b/whatsapp_fake_helper_test.go index c9bc835..3d7d117 100644 --- a/whatsapp_fake_helper_test.go +++ b/whatsapp_fake_helper_test.go @@ -309,3 +309,46 @@ func whatsappTestMessage(chat, sender types.JID, id, text string, at time.Time) }, } } + +// whatsappTestLogTexts returns the texts of the messages in the log of memory, +// after the log holds at least one message. +func whatsappTestLogTexts(t *testing.T, memory *Memory) []string { + t.Helper() + + require.Eventually(t, func() bool { + return memory.Len() > 0 + }, 10*time.Second, time.Millisecond) + + var texts []string + for id := range memory.Len() { + message, err := memory.Message(id) + require.NoError(t, err) + + texts = append(texts, message.Text) + } + + return texts +} + +// whatsappStartTestLinked opens a WhatsApp with a linked device in a temporary +// directory, with a quiet time of 50 ms for its batches. +func whatsappStartTestLinked(t *testing.T) (*WhatsApp, *WhatsAppFake) { + t.Helper() + + dir := t.TempDir() + whatsappTestSession(t, dir, true) + whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock()) + whatsapp.state.quiet = 50 * time.Millisecond + + return whatsapp, fake +} + +// whatsappTestConnect waits for the connection of the daemon and gives it the +// events of a connection with an empty catch-up. +func whatsappTestConnect(t *testing.T, fake *WhatsAppFake) { + t.Helper() + + <-fake.connects + require.True(t, fake.Emit(&events.Connected{})) + require.True(t, fake.Emit(&events.OfflineSyncCompleted{})) +} diff --git a/whatsapp_inbox.go b/whatsapp_inbox.go new file mode 100644 index 0000000..d76e43c --- /dev/null +++ b/whatsapp_inbox.go @@ -0,0 +1,347 @@ +package main + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" + "time" + + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +const ( + // whatsappSource is the source of the work events of the batches. + whatsappSource = "whatsapp" + + // whatsappCatchupLimit is the longest wait for the end of the catch-up + // after a connection. + whatsappCatchupLimit = time.Minute +) + +// newWhatsAppInbox returns the inbox of the batches in state, with clock for +// the times of the batch texts and groups for the names of the groups. +func newWhatsAppInbox(state *WhatsAppState, clock func() time.Time, groups *WhatsAppGroups) *WhatsAppInbox { + return &WhatsAppInbox{ + state: state, + clock: clock, + groups: groups, + catchupLimit: whatsappCatchupLimit, + wake: make(chan struct{}, 1), + failures: make(chan error, 1), + } +} + +// WhatsAppInbox puts the text messages into batches, one chat at a time, as +// WhatsAppState says. deliver logs each closed batch as a work message that +// starts with [WhatsApp], and a batch that needs the user wakes the turn loop. +// +// After each connection, the servers send the messages that came while the +// daemon was away, and then OfflineSyncCompleted. Until then, or until +// catchupLimit passes, the closed batches wait in the state file. At the end of +// the catch-up, deliver closes all batches, logs them, and wakes the turn loop +// once, so the whole catch-up gives one turn and not one turn for each chat. +// The batches of the state file at the start wait for the first catch-up in the +// same way. The event handler acknowledges a message only after the state file +// holds it, so the servers send again a message that a crash lost. A batch +// leaves the state file only after its work message is durable in the log. A +// crash between the two makes the batch repeat once. +// +// The state file is the one of WhatsApp, which keeps the send records in it +// too. groups gives the names of the groups. The mutex protects the state of +// the catch-up, which the event handler of the library changes from its own +// goroutines. wake tells Run that a batch can be due or that a catch-up ended, +// and failures gives Run the error of a write of the state file. +type WhatsAppInbox struct { + state *WhatsAppState + clock func() time.Time + groups *WhatsAppGroups + catchupLimit time.Duration + wake chan struct{} + failures chan error + + mutex sync.Mutex + catchingUp bool + caughtUp bool + catchups int + catchupTimer *time.Timer +} + +// due returns a channel that gets a value when the next open batch is due, or +// nil when no batch is open. +func (i *WhatsAppInbox) due() <-chan time.Time { + wait, open := i.state.Wait(time.Now()) + if !open { + return nil + } + + return time.After(wait) +} + +// deliver logs the closed batches in l in order, each as a work message of +// whatsappSource, and wakes the loop once with the batches that start a turn. A +// batch leaves the state file after its message is durable in the log. A group +// with no name gets its name from WhatsApp first, with the client of link. +// During a catch-up it logs nothing, and at its end it closes all batches +// first. With all, it closes and logs all batches also during a catch-up. +func (i *WhatsAppInbox) deliver(ctx context.Context, l *TurnLoop, link WhatsAppLink, all bool) error { + i.mutex.Lock() + catchingUp := i.catchingUp + caughtUp := i.caughtUp + i.caughtUp = false + i.mutex.Unlock() + + var err error + switch { + case all || caughtUp: + err = i.state.CloseAll() + case catchingUp: + return nil + default: + _, err = i.state.CloseDue(time.Now()) + } + + if err != nil { + return err + } + + var turns []Message + for _, batch := range i.state.Closed() { + if batch.Chat.Group && batch.Chat.Name == "" { + batch.Chat.Name = i.groups.name(ctx, link, batch.Chat.ID) + } + + message, err := l.Work(whatsappSource, batch.Text(i.clock().Location()), "") + if err != nil { + return fmt.Errorf("log a WhatsApp batch: %w", err) + } + + // A stop of the daemon during the flush is no failure: the + // batch stays in the state file and repeats once at the next + // start. + err = l.Flush(ctx) + if ctx.Err() != nil { + return nil + } + + if err != nil { + return fmt.Errorf("log a WhatsApp batch: %w", err) + } + + err = i.state.Remove(batch.Number) + if err != nil { + return err + } + + if batch.Turn() { + turns = append(turns, message) + } + } + + l.Wake(turns...) + + return nil +} + +// waitForCatchup makes the batches wait for a catch-up, as for the batches of +// the state file at the start, before the first connection. +func (i *WhatsAppInbox) waitForCatchup() { + i.mutex.Lock() + defer i.mutex.Unlock() + + i.catchingUp = true +} + +// startCatchup starts the catch-up of a new connection, which ends at +// endCatchup or after catchupLimit. +func (i *WhatsAppInbox) startCatchup() { + i.mutex.Lock() + defer i.mutex.Unlock() + + i.catchingUp = true + i.catchups++ + catchups := i.catchups + if i.catchupTimer != nil { + i.catchupTimer.Stop() + } + + i.catchupTimer = time.AfterFunc(i.catchupLimit, func() { + i.caughtUpBy(catchups) + }) +} + +// receive puts the text of event into its batch and wakes Run. link gives the +// device and the own IDs. It returns false when the state file cannot hold the +// message, so that the library does not acknowledge it, and Run stops the +// daemon. A message that is not text, or that the batches do not take, is +// handled with nothing to do. +func (i *WhatsAppInbox) receive(link WhatsAppLink, event *events.Message) bool { + ctx, cancel := context.WithTimeout(context.Background(), whatsappLookupTime) + defer cancel() + + chat, message, found := whatsappMessage(ctx, link.device, link.own, event) + if !found { + return true + } + + _, err := i.state.Add(chat, message, time.Now()) + if err != nil { + select { + case i.failures <- err: + default: + } + + return false + } + + select { + case i.wake <- struct{}{}: + default: + } + + return true +} + +// caughtUpBy ends the catch-up of the connection with the number catchups, when +// no later connection started a new one. +func (i *WhatsAppInbox) caughtUpBy(catchups int) { + i.mutex.Lock() + defer i.mutex.Unlock() + + if i.catchups == catchups { + i.finishCatchup() + } +} + +// finishCatchup ends a running catch-up and wakes Run, which logs all batches. +// The caller holds the mutex. +func (i *WhatsAppInbox) finishCatchup() { + if !i.catchingUp { + return + } + + i.catchingUp = false + i.caughtUp = true + if i.catchupTimer != nil { + i.catchupTimer.Stop() + } + + select { + case i.wake <- struct{}{}: + default: + } +} + +// endCatchup ends a running catch-up at OfflineSyncCompleted. +func (i *WhatsAppInbox) endCatchup() { + i.mutex.Lock() + defer i.mutex.Unlock() + + i.finishCatchup() +} + +// stopCatchup ends a running catch-up with no wake, after the session is +// removed. +func (i *WhatsAppInbox) stopCatchup() { + i.mutex.Lock() + defer i.mutex.Unlock() + + i.catchingUp = false +} + +// close stops the timer of the catch-up. +func (i *WhatsAppInbox) close() { + i.mutex.Lock() + defer i.mutex.Unlock() + + if i.catchupTimer != nil { + i.catchupTimer.Stop() + } +} + +// whatsappMessage returns the chat and the batch message of event, or false +// when the batches do not take it. device is the linked device, and own holds +// the own IDs. The batches take the text of a private chat or a group, from +// another person or from the user. They do not take media, reactions, edits, +// deletions, view-once and disappearing messages, old history, status +// broadcasts, channels, and the chat of the user with itself. +// +// A mention of the user becomes @you, another mention @NAME when the name is +// known, and the own number and LID become OWN, so that they never go to the +// log. +func whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) { + info := event.Info + chatJID := info.Chat.ToNonAD() + switch { + case device == nil: + case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil: + case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer: + case slices.Contains(own, chatJID.User): + default: + return whatsappText(ctx, device, own, event) + } + + return WhatsAppChat{}, WhatsAppMessage{}, false +} + +// whatsappText returns the chat and the batch message of event, a message that +// the batches take when it has text. +func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) { + info := event.Info + text := event.Message.GetConversation() + extended := event.Message.GetExtendedTextMessage() + if text == "" { + text = extended.GetText() + } + + if strings.TrimSpace(text) == "" { + return WhatsAppChat{}, WhatsAppMessage{}, false + } + + contextInfo := extended.GetContextInfo() + message := WhatsAppMessage{ + ID: info.ID, + Time: info.Timestamp, + Mine: info.IsFromMe, + } + + if !message.Mine { + message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName) + } + + for _, mentioned := range contextInfo.GetMentionedJID() { + jid, err := types.ParseJID(mentioned) + switch { + case err != nil: + case slices.Contains(own, jid.User): + message.Mention = true + text = strings.ReplaceAll(text, "@"+jid.User, "@you") + default: + text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, "")) + } + } + + participant, err := types.ParseJID(contextInfo.GetParticipant()) + message.Reply = err == nil && slices.Contains(own, participant.User) + + message.Text = whatsappRedact(own, text) + chatJID := info.Chat.ToNonAD() + chat := WhatsAppChat{ + ID: chatJID.String(), + Group: chatJID.Server == types.GroupServer, + } + + if !chat.Group { + push := info.PushName + if message.Mine { + push = "" + } + + chat.Name = whatsappContactName(ctx, device, chatJID, push) + } + + return chat, message, true +} diff --git a/whatsapp_inbox_test.go b/whatsapp_inbox_test.go new file mode 100644 index 0000000..7e9d3a3 --- /dev/null +++ b/whatsapp_inbox_test.go @@ -0,0 +1,282 @@ +package main + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/proto/waWeb" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +func TestWhatsAppPrivateBatchStartsATurnWithAnEmptyReply(t *testing.T) { + whatsapp, fake := whatsappStartTestLinked(t) + require.NoError(t, whatsapp.device.Contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza")) + + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) + } + + telegram := newTelegramFake(t) + bot, _ := telegramStartTestBot(t, telegram) + socket, memory := daemonStartTestWithWhatsApp(t, server, bot, whatsapp) + client := terminalStartTestClient(t, socket) + client.WaitFor(t, "[connected to OneChat]") + <-fake.connects + + // The catch-up holds the batch open until its end, so the two messages + // form one batch also on a slow machine. + require.True(t, fake.Emit(&events.Connected{})) + at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "Are you at home?", at))) + require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestOwn, "m2", "Yes", at))) + require.True(t, fake.Emit(&events.OfflineSyncCompleted{})) + + 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 + client.WaitFor(t, text+"\n[working]\n[done]\n") + require.Equal(t, 1, server.Calls()) + require.Contains(t, string(server.Call(0).raw), "Are you at home?") + require.Equal(t, []string{text}, whatsappTestLogTexts(t, memory)) + require.Empty(t, telegram.Sent()) + require.Eventually(t, func() bool { + return len(whatsapp.state.Closed()) == 0 + }, 10*time.Second, time.Millisecond) +} + +func TestWhatsAppGroupTurns(t *testing.T) { + testCases := []struct { + name string + message func(event *events.Message) + line string + turn bool + }{ + { + name: "plain", + message: func(event *events.Message) { + }, + line: "12:00 Bruno Lima: Who brings the cake?", + }, + { + name: "mention", + message: func(event *events.Message) { + event.Message = &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: new("@" + whatsappTestLID + " can you bring the cake? Call " + whatsappTestNumber + "."), + ContextInfo: &waE2E.ContextInfo{ + MentionedJID: []string{whatsappTestLID + "@lid"}, + }, + }, + } + }, + line: "12:00 Bruno Lima (mentions you): @you can you bring the cake? Call OWN.", + turn: true, + }, + { + name: "reply", + message: func(event *events.Message) { + event.Message = &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: new("Who brings the cake?"), + ContextInfo: &waE2E.ContextInfo{ + Participant: new(whatsappTestOwn.String()), + }, + }, + } + }, + line: "12:00 Bruno Lima (replies to you): Who brings the cake?", + turn: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + whatsapp, fake := whatsappStartTestLinked(t) + fake.groups = []*types.GroupInfo{ + { + JID: whatsappTestFamily, + GroupName: types.GroupName{ + Name: "Family", + }, + }, + } + + server := newModelFake(t) + socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp) + client := terminalStartTestClient(t, socket) + client.WaitFor(t, "[connected to OneChat]") + whatsappTestConnect(t, fake) + + event := whatsappTestMessage(whatsappTestFamily, whatsappTestBruno, "m1", "Who brings the cake?", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)) + event.Info.PushName = "Bruno Lima" + tc.message(event) + require.True(t, fake.Emit(event)) + + text := "[WhatsApp] Family, group 120363000000000001@g.us, Thursday 2026-01-01\n" + tc.line + if tc.turn { + text += "\n" + whatsappBatchEmptyReply + client.WaitFor(t, "[done]") + require.Equal(t, 1, server.Calls()) + } else { + client.WaitFor(t, text) + time.Sleep(100 * time.Millisecond) + require.Zero(t, server.Calls()) + } + + require.Equal(t, text, whatsappTestLogTexts(t, memory)[0]) + }) + } +} + +func TestWhatsAppTakesOnlyText(t *testing.T) { + at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + testCases := []struct { + name string + change func(event *events.Message) + }{ + { + name: "an edit", + change: func(event *events.Message) { + event.IsEdit = true + }, + }, + { + name: "old history", + change: func(event *events.Message) { + event.SourceWebMsg = &waWeb.WebMessageInfo{} + }, + }, + { + name: "a view-once message", + change: func(event *events.Message) { + event.IsViewOnce = true + }, + }, + { + name: "a disappearing message", + change: func(event *events.Message) { + event.IsEphemeral = true + }, + }, + { + name: "the chat of the user with itself", + change: func(event *events.Message) { + event.Info.Chat = whatsappTestOwn + }, + }, + { + name: "a status broadcast", + change: func(event *events.Message) { + event.Info.Chat = types.StatusBroadcastJID + }, + }, + { + name: "a photo with a caption", + change: func(event *events.Message) { + event.Message = &waE2E.Message{ + ImageMessage: &waE2E.ImageMessage{ + Caption: new("look"), + }, + } + }, + }, + { + name: "a text of spaces", + change: func(event *events.Message) { + event.Message.Conversation = new(" ") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + whatsapp, _ := whatsappStartTestLinked(t) + event := whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "hi", at) + tc.change(event) + require.True(t, whatsapp.handle(event)) + require.NoError(t, whatsapp.state.CloseAll()) + require.Empty(t, whatsapp.state.Closed()) + }) + } +} + +func TestWhatsAppCatchupGivesOneTurn(t *testing.T) { + whatsapp, fake := whatsappStartTestLinked(t) + server := newModelFake(t) + socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp) + client := terminalStartTestClient(t, socket) + client.WaitFor(t, "[connected to OneChat]") + <-fake.connects + + require.True(t, fake.Emit(&events.Connected{})) + at := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC) + for index, event := range []*events.Message{ + whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "Good morning", at), + whatsappTestMessage(whatsappTestBruno, whatsappTestBruno, "m2", "Call me", at.Add(time.Hour)), + whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m3", "Are you there?", at.Add(2*time.Hour)), + } { + event.Info.PushName = []string{"Ana", "Bruno", "Ana"}[index] + require.True(t, fake.Emit(event)) + } + + time.Sleep(100 * time.Millisecond) + require.Zero(t, memory.Len()) + + require.True(t, fake.Emit(&events.OfflineSyncCompleted{})) + client.WaitFor(t, "[done]") + require.Equal(t, []string{ + "[WhatsApp] Ana, private chat 15550002222@s.whatsapp.net, Thursday 2026-01-01\n08:00 Ana: Good morning\n" + whatsappBatchEmptyReply, + "[WhatsApp] Bruno, private chat 15550003333@s.whatsapp.net, Thursday 2026-01-01\n09:00 Bruno: Call me\n" + whatsappBatchEmptyReply, + "[WhatsApp] Ana, private chat 15550002222@s.whatsapp.net, Thursday 2026-01-01\n10:00 Ana: Are you there?\n" + whatsappBatchEmptyReply, + }, whatsappTestLogTexts(t, memory)[:3]) + + time.Sleep(100 * time.Millisecond) + require.Equal(t, 1, server.Calls()) + for _, text := range []string{"Good morning", "Call me", "Are you there?"} { + require.Contains(t, string(server.Call(0).raw), text) + } +} + +func TestWhatsAppCatchupEndsAfterItsLimit(t *testing.T) { + whatsapp, fake := whatsappStartTestLinked(t) + whatsapp.inbox.catchupLimit = 100 * time.Millisecond + socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) + client := terminalStartTestClient(t, socket) + client.WaitFor(t, "[connected to OneChat]") + <-fake.connects + + require.True(t, fake.Emit(&events.Connected{})) + require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestOwn, "m1", "On my way.", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)))) + client.WaitFor(t, "12:00 You: On my way.") + require.Len(t, whatsappTestLogTexts(t, memory), 1) +} + +func TestWhatsAppStateOfTheStateFileWaitForTheCatchup(t *testing.T) { + dir := t.TempDir() + whatsappTestSession(t, dir, true) + before, _, _ := whatsappStartTest(t, dir, newTestClock()) + _, err := before.state.Add(whatsappTestChat, WhatsAppMessage{ + ID: "m1", + Sender: "Ana Souza", + Time: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), + Text: "Did you see my message?", + }, time.Now().Add(-time.Hour)) + require.NoError(t, err) + + whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock()) + socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) + client := terminalStartTestClient(t, socket) + client.WaitFor(t, "[connected to OneChat]") + <-fake.connects + + time.Sleep(100 * time.Millisecond) + require.Zero(t, memory.Len()) + + require.True(t, fake.Emit(&events.Connected{})) + require.True(t, fake.Emit(&events.OfflineSyncCompleted{})) + client.WaitFor(t, "12:00 Ana Souza: Did you see my message?") + client.WaitFor(t, "[done]") +} diff --git a/whatsapp_test.go b/whatsapp_test.go index 791e3ca..3d79def 100644 --- a/whatsapp_test.go +++ b/whatsapp_test.go @@ -14,8 +14,6 @@ import ( "github.com/stretchr/testify/require" "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/proto/waWeb" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" ) @@ -462,318 +460,6 @@ func TestWhatsAppCommandArgs(t *testing.T) { } } -// whatsappTestLogTexts returns the texts of the messages in the log of memory, -// after the log holds at least one message. -func whatsappTestLogTexts(t *testing.T, memory *Memory) []string { - t.Helper() - - require.Eventually(t, func() bool { - return memory.Len() > 0 - }, 10*time.Second, time.Millisecond) - - var texts []string - for id := range memory.Len() { - message, err := memory.Message(id) - require.NoError(t, err) - - texts = append(texts, message.Text) - } - - return texts -} - -func TestWhatsAppPrivateBatchStartsATurnWithAnEmptyReply(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - require.NoError(t, whatsapp.device.Contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza")) - - server := newModelFake(t) - server.reply = func(w http.ResponseWriter, call ModelFakeCall) { - modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) - } - - telegram := newTelegramFake(t) - bot, _ := telegramStartTestBot(t, telegram) - socket, memory := daemonStartTestWithWhatsApp(t, server, bot, whatsapp) - client := terminalStartTestClient(t, socket) - client.WaitFor(t, "[connected to OneChat]") - <-fake.connects - - // The catch-up holds the batch open until its end, so the two messages - // form one batch also on a slow machine. - require.True(t, fake.Emit(&events.Connected{})) - at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "Are you at home?", at))) - require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestOwn, "m2", "Yes", at))) - require.True(t, fake.Emit(&events.OfflineSyncCompleted{})) - - 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 - client.WaitFor(t, text+"\n[working]\n[done]\n") - require.Equal(t, 1, server.Calls()) - require.Contains(t, string(server.Call(0).raw), "Are you at home?") - require.Equal(t, []string{text}, whatsappTestLogTexts(t, memory)) - require.Empty(t, telegram.Sent()) - require.Eventually(t, func() bool { - return len(whatsapp.state.Closed()) == 0 - }, 10*time.Second, time.Millisecond) -} - -func TestWhatsAppGroupTurns(t *testing.T) { - testCases := []struct { - name string - message func(event *events.Message) - line string - turn bool - }{ - { - name: "plain", - message: func(event *events.Message) { - }, - line: "12:00 Bruno Lima: Who brings the cake?", - }, - { - name: "mention", - message: func(event *events.Message) { - event.Message = &waE2E.Message{ - ExtendedTextMessage: &waE2E.ExtendedTextMessage{ - Text: new("@" + whatsappTestLID + " can you bring the cake? Call " + whatsappTestNumber + "."), - ContextInfo: &waE2E.ContextInfo{ - MentionedJID: []string{whatsappTestLID + "@lid"}, - }, - }, - } - }, - line: "12:00 Bruno Lima (mentions you): @you can you bring the cake? Call OWN.", - turn: true, - }, - { - name: "reply", - message: func(event *events.Message) { - event.Message = &waE2E.Message{ - ExtendedTextMessage: &waE2E.ExtendedTextMessage{ - Text: new("Who brings the cake?"), - ContextInfo: &waE2E.ContextInfo{ - Participant: new(whatsappTestOwn.String()), - }, - }, - } - }, - line: "12:00 Bruno Lima (replies to you): Who brings the cake?", - turn: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - fake.groups = []*types.GroupInfo{ - { - JID: whatsappTestFamily, - GroupName: types.GroupName{ - Name: "Family", - }, - }, - } - - server := newModelFake(t) - socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp) - client := terminalStartTestClient(t, socket) - client.WaitFor(t, "[connected to OneChat]") - whatsappTestConnect(t, fake) - - event := whatsappTestMessage(whatsappTestFamily, whatsappTestBruno, "m1", "Who brings the cake?", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)) - event.Info.PushName = "Bruno Lima" - tc.message(event) - require.True(t, fake.Emit(event)) - - text := "[WhatsApp] Family, group 120363000000000001@g.us, Thursday 2026-01-01\n" + tc.line - if tc.turn { - text += "\n" + whatsappBatchEmptyReply - client.WaitFor(t, "[done]") - require.Equal(t, 1, server.Calls()) - } else { - client.WaitFor(t, text) - time.Sleep(100 * time.Millisecond) - require.Zero(t, server.Calls()) - } - - require.Equal(t, text, whatsappTestLogTexts(t, memory)[0]) - }) - } -} - -func TestWhatsAppTakesOnlyText(t *testing.T) { - at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - testCases := []struct { - name string - change func(event *events.Message) - }{ - { - name: "an edit", - change: func(event *events.Message) { - event.IsEdit = true - }, - }, - { - name: "old history", - change: func(event *events.Message) { - event.SourceWebMsg = &waWeb.WebMessageInfo{} - }, - }, - { - name: "a view-once message", - change: func(event *events.Message) { - event.IsViewOnce = true - }, - }, - { - name: "a disappearing message", - change: func(event *events.Message) { - event.IsEphemeral = true - }, - }, - { - name: "the chat of the user with itself", - change: func(event *events.Message) { - event.Info.Chat = whatsappTestOwn - }, - }, - { - name: "a status broadcast", - change: func(event *events.Message) { - event.Info.Chat = types.StatusBroadcastJID - }, - }, - { - name: "a photo with a caption", - change: func(event *events.Message) { - event.Message = &waE2E.Message{ - ImageMessage: &waE2E.ImageMessage{ - Caption: new("look"), - }, - } - }, - }, - { - name: "a text of spaces", - change: func(event *events.Message) { - event.Message.Conversation = new(" ") - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - whatsapp, _ := whatsappStartTestLinked(t) - event := whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "hi", at) - tc.change(event) - require.True(t, whatsapp.handle(event)) - require.NoError(t, whatsapp.state.CloseAll()) - require.Empty(t, whatsapp.state.Closed()) - }) - } -} - -// whatsappStartTestLinked opens a WhatsApp with a linked device in a temporary -// directory, with a quiet time of 50 ms for its batches. -func whatsappStartTestLinked(t *testing.T) (*WhatsApp, *WhatsAppFake) { - t.Helper() - - dir := t.TempDir() - whatsappTestSession(t, dir, true) - whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock()) - whatsapp.state.quiet = 50 * time.Millisecond - - return whatsapp, fake -} - -func TestWhatsAppCatchupGivesOneTurn(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - server := newModelFake(t) - socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp) - client := terminalStartTestClient(t, socket) - client.WaitFor(t, "[connected to OneChat]") - <-fake.connects - - require.True(t, fake.Emit(&events.Connected{})) - at := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC) - for index, event := range []*events.Message{ - whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "Good morning", at), - whatsappTestMessage(whatsappTestBruno, whatsappTestBruno, "m2", "Call me", at.Add(time.Hour)), - whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m3", "Are you there?", at.Add(2*time.Hour)), - } { - event.Info.PushName = []string{"Ana", "Bruno", "Ana"}[index] - require.True(t, fake.Emit(event)) - } - - time.Sleep(100 * time.Millisecond) - require.Zero(t, memory.Len()) - - require.True(t, fake.Emit(&events.OfflineSyncCompleted{})) - client.WaitFor(t, "[done]") - require.Equal(t, []string{ - "[WhatsApp] Ana, private chat 15550002222@s.whatsapp.net, Thursday 2026-01-01\n08:00 Ana: Good morning\n" + whatsappBatchEmptyReply, - "[WhatsApp] Bruno, private chat 15550003333@s.whatsapp.net, Thursday 2026-01-01\n09:00 Bruno: Call me\n" + whatsappBatchEmptyReply, - "[WhatsApp] Ana, private chat 15550002222@s.whatsapp.net, Thursday 2026-01-01\n10:00 Ana: Are you there?\n" + whatsappBatchEmptyReply, - }, whatsappTestLogTexts(t, memory)[:3]) - - time.Sleep(100 * time.Millisecond) - require.Equal(t, 1, server.Calls()) - for _, text := range []string{"Good morning", "Call me", "Are you there?"} { - require.Contains(t, string(server.Call(0).raw), text) - } -} - -func TestWhatsAppCatchupEndsAfterItsLimit(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - whatsapp.catchupLimit = 100 * time.Millisecond - socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - client := terminalStartTestClient(t, socket) - client.WaitFor(t, "[connected to OneChat]") - <-fake.connects - - require.True(t, fake.Emit(&events.Connected{})) - require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestOwn, "m1", "On my way.", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)))) - client.WaitFor(t, "12:00 You: On my way.") - require.Len(t, whatsappTestLogTexts(t, memory), 1) -} - -func TestWhatsAppStateOfTheStateFileWaitForTheCatchup(t *testing.T) { - dir := t.TempDir() - whatsappTestSession(t, dir, true) - before, _, _ := whatsappStartTest(t, dir, newTestClock()) - _, err := before.state.Add(whatsappTestChat, WhatsAppMessage{ - ID: "m1", - Sender: "Ana Souza", - Time: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), - Text: "Did you see my message?", - }, time.Now().Add(-time.Hour)) - require.NoError(t, err) - - whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock()) - socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - client := terminalStartTestClient(t, socket) - client.WaitFor(t, "[connected to OneChat]") - <-fake.connects - - time.Sleep(100 * time.Millisecond) - require.Zero(t, memory.Len()) - - require.True(t, fake.Emit(&events.Connected{})) - require.True(t, fake.Emit(&events.OfflineSyncCompleted{})) - client.WaitFor(t, "12:00 Ana Souza: Did you see my message?") - client.WaitFor(t, "[done]") -} - -// whatsappTestConnect waits for the connection of the daemon and gives it the -// events of a connection with an empty catch-up. -func whatsappTestConnect(t *testing.T, fake *WhatsAppFake) { - t.Helper() - - <-fake.connects - require.True(t, fake.Emit(&events.Connected{})) - require.True(t, fake.Emit(&events.OfflineSyncCompleted{})) -} - func TestWhatsAppFindMatchesNamesWithNoRegardToCaseAndAccents(t *testing.T) { whatsapp, fake := whatsappStartTestLinked(t) fake.groups = []*types.GroupInfo{