From: Anderson A. Giacomolli Date: Sun, 8 Nov 2026 14:44:00 +0000 (-0300) Subject: Link WhatsApp with a pairing code X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/0018014ab36f3de61a4b8a5515009dab803d6c38?ds=inline Link WhatsApp with a pairing code The line /whatsapp pair PHONE in the terminal client or in Telegram links the WhatsApp account of PHONE as a linked device. The daemon answers with the steps and then with the pairing code in its own message, so that it is easy to copy, and later with the linked number, an expired code, or the failure. A new pairing ends the one before. /whatsapp unlink logs the device out and deletes the session, and tells the user to remove the device in the phone when the logout cannot reach WhatsApp. The phone number and the code go only to the client that asked. The log of the agent gets only the notes that WhatsApp is linked or unlinked, and the errors in the daemon log show the own number and the own LID as OWN. When the user removes the device in the phone, the daemon deletes the session, notes it, and tells all clients one time. A disconnection that lasts 10 minutes gives all clients one notice, not one for each retry of the library, and one more when the connection comes back. A disconnection that the library does not retry gives a notice at once. The pair and unlink commands run one at a time in the WhatsApp goroutine of the daemon, which ends a running pairing at the stop of the daemon. Issue: 6f69ca1 --- diff --git a/README.rst b/README.rst index 3a2d25b..fd6cb95 100644 --- a/README.rst +++ b/README.rst @@ -177,19 +177,44 @@ daemon. ********** The daemon connects to the WhatsApp account of the user as a linked -device. In the terminal client or in Telegram, the line ``/whatsapp -status`` shows the link: not linked, linked and connected, or linked and -disconnected, with the times of the connection. The answer goes only to -the client that asked, and the line does not go to the agent. - -The session is ``whatsapp/session.db`` in the data directory. It gives -full access to the WhatsApp account, so the daemon refuses the file when -its mode is not 0600. The shell of the agent runs as the user, so it can -read the session. If the session leaks, remove the linked device in -WhatsApp on the phone, and the session stops working. Without the file, -WhatsApp is not linked. When the session holds a linked device, the -daemon connects at its start, and after a network failure it connects -again by itself. +device. The terminal client and Telegram take three commands: + +``/whatsapp pair PHONE`` + Links the account of PHONE, the number with its country code, such as + ``+15550001111``. The daemon answers with the steps and then with a + pairing code in its own message: in WhatsApp on the phone, open + Linked devices, tap Link a device, then tap Link with phone number + instead, and enter the code. The code expires in about 2 minutes, and + the same command gives a new code. When the link succeeds, the daemon + answers with the linked number. + +``/whatsapp status`` + Shows the link: not linked, linked and connected, or linked and + disconnected, with the times of the connection. + +``/whatsapp unlink`` + Ends the link and deletes the session. When WhatsApp is disconnected, + the phone does not get the logout, so remove the device in Linked + devices on the phone. + +The answers go only to the client that asked, and the commands do not go +to the agent, so the agent never gets the phone number or the code. The +chat gets only the notes that WhatsApp is linked or unlinked. + +The session is ``whatsapp/session.db`` in the data directory, with mode +0600 in a directory with mode 0700. It gives full access to the WhatsApp +account, so the daemon refuses the file when its mode is not 0600. The +shell of the agent runs as the user, so it can read the session. If the +session leaks, remove the linked device in WhatsApp on the phone, and +the session stops working. Without the file, WhatsApp is not linked. + +When the session holds a linked device, the daemon connects at its +start, and after a network failure it connects again by itself. When a +disconnection lasts 10 minutes, all clients get one notice, and one more +when the connection comes back. When WhatsApp ends the connection for +good, for example because another program uses the same session, all +clients get a notice at once. When the user removes the device in the +phone, the daemon deletes the session and tells all clients one time. ******* Probe diff --git a/daemon.go b/daemon.go index d67c5be..41b3ab6 100644 --- a/daemon.go +++ b/daemon.go @@ -305,8 +305,8 @@ func daemonListenSocket(path string) (net.Listener, error) { } // daemonServe runs the loop that start makes and serves the clients of listener -// until ctx ends or the loop fails. It closes listener. It keeps whatsapp -// connected. When bot is not nil, it runs the bot too, and a failure of the bot +// until ctx ends or the loop fails. It closes listener. It runs whatsapp, which +// logs its notes in the loop and tells all clients its notices. When bot is not nil, it runs the bot too, and a failure of the bot // stops the agent. func daemonServe(ctx context.Context, listener net.Listener, bot *TelegramBot, whatsapp *WhatsApp, start func(notify func(Event)) (*TurnLoop, error)) error { ctx, cancel := context.WithCancel(ctx) @@ -341,7 +341,7 @@ func daemonServe(ctx context.Context, listener net.Listener, bot *TelegramBot, w go func() { defer workers.Done() - whatsapp.Run(ctx) + whatsapp.Run(ctx, l, notify) }() var botErr error diff --git a/daemon_start_helper_test.go b/daemon_start_helper_test.go index cf1fbf6..b2c3787 100644 --- a/daemon_start_helper_test.go +++ b/daemon_start_helper_test.go @@ -13,6 +13,17 @@ import ( func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string { t.Helper() + whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock()) + socket, _ := daemonStartTestWithWhatsApp(t, server, bot, whatsapp) + + return socket +} + +// daemonStartTestWithWhatsApp is daemonStartTestWithBot with whatsapp. It also +// returns the memory of the agent. +func daemonStartTestWithWhatsApp(t *testing.T, server *ModelFake, bot *TelegramBot, whatsapp *WhatsApp) (string, *Memory) { + t.Helper() + dir := t.TempDir() agent, err := openAgent(filepath.Join(dir, daemonAgentDirName)) require.NoError(t, err) @@ -22,7 +33,6 @@ func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) s memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer()) require.NoError(t, err) - whatsapp, _ := whatsappStartTest(t, dir) socket := filepath.Join(dir, socketFileName) listener, err := daemonListenSocket(socket) require.NoError(t, err) @@ -42,5 +52,5 @@ func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) s require.NoError(t, memory.Close()) }) - return socket + return socket, memory } diff --git a/whatsapp.go b/whatsapp.go index 05db5e4..3c519e8 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -37,10 +37,33 @@ const ( whatsappCommand = "/whatsapp" // whatsappUsage is the answer to a WhatsApp command that does not exist. - whatsappUsage = "Use /whatsapp status." + whatsappUsage = "Use /whatsapp pair PHONE, /whatsapp status, or /whatsapp unlink. PHONE is the number of your WhatsApp account with its country code, such as +15550001111." + + // whatsappPairSteps tells the user what to do with the pairing code, + // which comes in the next message. + whatsappPairSteps = "In WhatsApp on your phone, open Linked devices, tap Link a device, then tap Link with phone number instead, and enter the code of the next message. The code expires in about 2 minutes." + + // whatsappPairExpired is the answer when the pairing code expires. + whatsappPairExpired = "The pairing code expired. Run /whatsapp pair PHONE again for a new code." // whatsappTimeLayout is the layout of the times in the answers. whatsappTimeLayout = "2006-01-02 15:04" + + // whatsappPairTime is the longest time of a pairing. The library closes + // the pairing connection after about 160 seconds. + whatsappPairTime = 3 * time.Minute + + // whatsappLogoutTime is the longest time of the logout call of an + // unlink. + whatsappLogoutTime = 30 * time.Second + + // whatsappNoticeDelay is how long a disconnection lasts before the user + // gets a notice. + whatsappNoticeDelay = 10 * time.Minute + + // whatsappRequests is the number of pair and unlink commands that can + // wait for Run. + whatsappRequests = 8 ) // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The @@ -49,23 +72,28 @@ const ( type WhatsAppClient interface { ConnectContext(ctx context.Context) error Disconnect() + GetQRChannel(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) + PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error) + Logout(ctx context.Context) error } // openWhatsApp opens the WhatsApp session in the data directory dir. Without // the session file, WhatsApp is not linked. The session gives full access to // the account, so a file that another user can read is an error. The clock // gives the times of the status, logger gets the failures, and newClient makes -// the client of a linked device with its event handler. +// 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, + dir: filepath.Join(dir, whatsappDirName), + clock: clock, + logger: logger, + newClient: newClient, + noticeDelay: whatsappNoticeDelay, + requests: make(chan WhatsAppRequest, whatsappRequests), + logouts: make(chan struct{}, 1), } - path := filepath.Join(w.dir, whatsappSessionFileName) - info, err := os.Stat(path) + info, err := os.Stat(w.sessionPath()) if errors.Is(err, os.ErrNotExist) { return w, nil } @@ -78,19 +106,14 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge return nil, fmt.Errorf("%s holds the WhatsApp session, so its mode must be 0600", filepath.Join(whatsappDirName, whatsappSessionFileName)) } - address, err := whatsappSessionAddress(path) + err = w.openSession(ctx) if err != nil { return nil, err } - w.container, err = sqlstore.New(ctx, "sqlite", address, waLog.Noop) - if err != nil { - return nil, fmt.Errorf("open WhatsApp session: %w", err) - } - - w.device, err = w.container.GetFirstDevice(ctx) - if err != nil { - return nil, errors.Join(fmt.Errorf("read WhatsApp device: %w", err), w.container.Close()) + if w.device.ID != nil { + w.linked = true + w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User) } return w, nil @@ -98,56 +121,129 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge // WhatsApp is the link of the daemon to the WhatsApp account of the user, as // a linked device. The session database of the library in dir holds the keys -// of the device. A device with an ID is linked, and Run keeps it connected. -// The library connects again by itself after a network failure. The mutex -// protects the state of the connection, which the event handler of the library -// changes from its own goroutines. +// of the device. Run keeps a linked device connected, and the library connects +// again by itself after a network failure. +// +// The commands pair and unlink go to Run as requests, and Run runs one at a +// time. A pairing runs in its own goroutine until its code expires, and a new +// pairing ends the one before. Its phone number and code go only to the answer +// of the client that asked. A logout from the phone comes as an event of the +// library, and Run deletes the session. The mutex protects the session, the +// client, and the state of the connection, which the event handler of the +// library changes from its own goroutines. // // The library logs nothing, because its log lines hold the ID of the own -// account, which is the phone number of the user. +// account, which is the phone number of the user. The own number and the own +// LID never go to the log of the agent or to the log of the daemon: redact +// removes them from the errors. type WhatsApp struct { - dir string - clock func() time.Time - logger *log.Logger - newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient - container *sqlstore.Container - device *store.Device - client WhatsAppClient + 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{} mutex sync.Mutex + container *sqlstore.Container + device *store.Device + client WhatsAppClient + linked bool + own []string + notify func(Event) connected bool connectedAt time.Time disconnectedAt time.Time + disconnections int + noticed bool + timer *time.Timer + closed bool } -// Run connects a linked device and keeps the connection until ctx ends. A -// failed first connection is logged, and the library tries again when the -// failure is one of the network. -func (w *WhatsApp) Run(ctx context.Context) { - if !w.linked() { - <-ctx.Done() - return - } +// Run connects a linked device, runs the pair and unlink commands, and handles +// a logout from the phone, until ctx ends. Its notes go to the log of l, and +// notify gets the notices for all clients. A failed first connection is +// logged, and the library tries again when the failure is one of the network. +func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) { + w.mutex.Lock() + w.notify = notify + linked := w.linked + w.mutex.Unlock() - w.client = w.newClient(w.device, w.handle) - err := w.client.ConnectContext(ctx) - if err != nil { - w.logger.Printf("connect: %v", err) + if linked { + w.connect(ctx) } - <-ctx.Done() - w.client.Disconnect() + var pairing sync.WaitGroup + cancel := func() {} + defer func() { + cancel() + pairing.Wait() + w.close() + }() + + for { + select { + case <-ctx.Done(): + return + case <-w.logouts: + w.loggedOut(l) + case request := <-w.requests: + cancel() + pairing.Wait() + cancel = func() {} + + switch { + case request.unlink: + w.unlink(ctx, l, request.answer) + case w.isLinked(): + request.answer("WhatsApp is linked already. Run /whatsapp unlink first.") + default: + pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTime) + cancel = cancelPair + pairing.Go(func() { + defer cancelPair() + + w.pair(pairCtx, l, request.phone, request.answer) + }) + } + } + } } // Command runs the WhatsApp command args, the text after /whatsapp, and gives // each answer to answer. The answers go only to the client that sent the // command. func (w *WhatsApp) Command(args string, answer func(text string)) { - switch strings.TrimSpace(args) { - case "status": + fields := strings.Fields(args) + request := WhatsAppRequest{ + answer: answer, + } + + switch { + case len(fields) == 1 && fields[0] == "status": answer(w.Status()) + return + case len(fields) == 1 && fields[0] == "unlink": + request.unlink = true + case len(fields) >= 2 && fields[0] == "pair": + phone, valid := whatsappPhone(strings.Join(fields[1:], "")) + if !valid { + answer("PHONE must be the number of your WhatsApp account with its country code: 7 to 15 digits, such as +15550001111.") + return + } + + request.phone = phone default: answer(whatsappUsage) + return + } + + select { + case w.requests <- request: + default: + answer("Too many WhatsApp commands wait. Try again in a minute.") } } @@ -159,8 +255,8 @@ func (w *WhatsApp) Status() string { defer w.mutex.Unlock() switch { - case !w.linked(): - return "WhatsApp is not linked." + case !w.linked: + return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it." case w.connected: return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "." case w.disconnectedAt.IsZero(): @@ -174,44 +270,431 @@ func (w *WhatsApp) Status() string { // Close closes the session database. func (w *WhatsApp) Close() error { + w.mutex.Lock() + defer w.mutex.Unlock() + if w.container == nil { return nil } - return w.container.Close() + err := w.container.Close() + w.container = nil + + return err +} + +func (w *WhatsApp) sessionPath() string { + return filepath.Join(w.dir, whatsappSessionFileName) +} + +// openSession opens the session database and its first device. The caller +// holds the mutex, or no other goroutine uses w yet. +func (w *WhatsApp) openSession(ctx context.Context) error { + address, err := whatsappSessionAddress(w.sessionPath()) + if err != nil { + return err + } + + container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop) + if err != nil { + return fmt.Errorf("open WhatsApp session: %w", err) + } + + device, err := container.GetFirstDevice(ctx) + if err != nil { + return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close()) + } + + w.container = container + w.device = device + + return nil +} + +func (w *WhatsApp) isLinked() bool { + w.mutex.Lock() + defer w.mutex.Unlock() + + return w.linked +} + +// connect makes the client of the linked device and connects it. +func (w *WhatsApp) connect(ctx context.Context) { + w.mutex.Lock() + w.client = w.newClient(w.device, w.handle) + client := w.client + w.mutex.Unlock() + + err := client.ConnectContext(ctx) + if err != nil { + w.logger.Printf("connect: %s", w.redact(err.Error())) + } +} + +// close ends the connection and the timer of the notice, and stops all +// notices. +func (w *WhatsApp) close() { + w.mutex.Lock() + w.closed = true + if w.timer != nil { + w.timer.Stop() + } + + client := w.client + w.client = nil + w.mutex.Unlock() + + if client != nil { + client.Disconnect() + } +} + +// loggedOut deletes the session after a logout from the phone, notes it in the +// log of l, and tells all clients one time. +func (w *WhatsApp) loggedOut(l *TurnLoop) { + if !w.isLinked() { + return + } + + err := w.removeSession() + if err != nil { + w.logger.Printf("delete the session after the logout: %v", err) + } + + err = l.note("WhatsApp was unlinked from the phone.") + if err != nil { + w.logger.Printf("note the logout: %v", err) + } + + w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.") } -// linked tells if the session holds a linked device. -func (w *WhatsApp) linked() bool { - return w.device != nil && w.device.ID != nil +// unlink logs out the linked device and deletes the session. When the logout +// call fails, as it does while WhatsApp is disconnected, the session is +// deleted all the same, and answer tells the user to remove the device in the +// phone. +func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) { + w.mutex.Lock() + linked := w.linked + client := w.client + w.mutex.Unlock() + + if !linked { + answer("WhatsApp is not linked.") + return + } + + var logoutErr error + if client != nil { + ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTime) + defer cancel() + + logoutErr = client.Logout(ctx) + } + + err := w.removeSession() + if err != nil { + answer("The unlink failed: " + w.redact(err.Error())) + return + } + + err = l.note("WhatsApp is unlinked.") + if err != nil { + w.logger.Printf("note the unlink: %v", err) + } + + if client == nil || logoutErr != nil { + 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.") + return + } + + answer("WhatsApp is unlinked.") +} + +// removeSession ends the connection, closes the session database, and deletes +// its files. +func (w *WhatsApp) removeSession() error { + w.mutex.Lock() + client := w.client + w.client = nil + w.linked = false + w.own = nil + w.connected = false + if w.timer != nil { + w.timer.Stop() + } + + w.noticed = false + container := w.container + w.container = nil + w.device = nil + w.mutex.Unlock() + + if client != nil { + client.Disconnect() + } + + var err error + if container != nil { + err = container.Close() + } + + for _, suffix := range []string{"", "-wal", "-shm"} { + removeErr := os.Remove(w.sessionPath() + suffix) + if !errors.Is(removeErr, os.ErrNotExist) { + err = errors.Join(err, removeErr) + } + } + + return err +} + +// pair links a new device to the account of phone with a pairing code. It +// gives the steps and the code to answer, and then the end of the pairing: +// the linked number, an expired code, or a failure. A pairing that a new one +// ends gives no answer. A link notes "WhatsApp is linked." in the log of l, +// with no number. The client of a pairing that does not link is disconnected. +func (w *WhatsApp) pair(ctx context.Context, l *TurnLoop, phone string, answer func(text string)) { + device, err := w.newDevice(ctx) + if err != nil { + answer("The pairing failed: " + err.Error()) + return + } + + client := w.newClient(device, w.handle) + linked := false + defer func() { + if !linked { + client.Disconnect() + } + }() + + qr, err := client.GetQRChannel(ctx) + if err == nil { + err = client.ConnectContext(ctx) + } + + if err != nil { + answer("The connection to WhatsApp failed: " + err.Error()) + return + } + + select { + case item, open := <-qr: + if !open || item.Event != whatsmeow.QRChannelEventCode { + answer("WhatsApp did not start the pairing. Try again later.") + return + } + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + answer("The connection to WhatsApp did not start the pairing in time. Try again later.") + } + + return + } + + code, err := client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)") + if err != nil { + answer("WhatsApp refused the pairing: " + err.Error() + ". Check the phone number and run /whatsapp pair PHONE again.") + return + } + + answer(whatsappPairSteps) + answer(code) + + for { + var item whatsmeow.QRChannelItem + open := true + select { + case item, open = <-qr: + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + answer(whatsappPairExpired) + } + + return + } + + switch { + case !open || item.Event == whatsmeow.QRChannelTimeout.Event: + answer(whatsappPairExpired) + return + case item.Event == whatsmeow.QRChannelEventCode: + continue + case item.Event == whatsmeow.QRChannelSuccess.Event: + linked = true + w.linkedBy(client) + + err = l.note("WhatsApp is linked.") + if err != nil { + w.logger.Printf("note the link: %v", err) + } + + answer("WhatsApp is linked to +" + phone + ".") + + return + default: + answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.") + return + } + } +} + +// newDevice returns a new device in the session database. It makes the +// directory and the file of the session first, with their modes, when they do +// not exist. +func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) { + w.mutex.Lock() + defer w.mutex.Unlock() + + if w.container == nil { + err := os.MkdirAll(w.dir, 0o700) + if err != nil { + return nil, fmt.Errorf("create WhatsApp directory: %w", err) + } + + file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("create WhatsApp session: %w", err) + } + + err = file.Close() + if err != nil { + return nil, fmt.Errorf("create WhatsApp session: %w", err) + } + + err = w.openSession(ctx) + if err != nil { + return nil, err + } + } + + w.device = w.container.NewDevice() + + return w.device, nil +} + +// linkedBy makes client the client of the linked device. +func (w *WhatsApp) linkedBy(client WhatsAppClient) { + w.mutex.Lock() + defer w.mutex.Unlock() + + w.client = client + w.linked = true } // handle takes one event of the library and tells if it was handled. 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 { + var notice string w.mutex.Lock() - defer w.mutex.Unlock() - - switch event.(type) { + switch event := event.(type) { + case *events.PairSuccess: + w.own = whatsappOwnIDs(event.ID.User, event.LID.User) case *events.Connected: w.connected = true w.connectedAt = w.clock() + if w.timer != nil { + w.timer.Stop() + } + + if w.noticed { + w.noticed = false + notice = "WhatsApp is connected again." + } case *events.Disconnected: - if w.connected || w.disconnectedAt.IsZero() { + if w.linked && (w.connected || w.disconnectedAt.IsZero()) { w.disconnectedAt = w.clock() + w.disconnections++ + disconnections := w.disconnections + w.timer = time.AfterFunc(w.noticeDelay, func() { + w.disconnectedLong(disconnections) + }) } w.connected = false + case *events.LoggedOut: + select { + case w.logouts <- struct{}{}: + default: + } + case events.PermanentDisconnect: + if w.linked { + w.connected = false + w.noticed = true + notice = "WhatsApp is disconnected and does not connect again by itself: " + event.PermanentDisconnectDescription() + ". Restart the daemon to try again." + } + } + w.mutex.Unlock() + + if notice != "" { + w.broadcast(notice) } return true } +// disconnectedLong gives the notice of a long disconnection, unless the +// disconnection with the number disconnections ended. +func (w *WhatsApp) disconnectedLong(disconnections int) { + w.mutex.Lock() + long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections + if long { + w.noticed = true + } + + since := w.disconnectedAt + w.mutex.Unlock() + + if long { + w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.") + } +} + +// broadcast gives text as a notice to all clients. +func (w *WhatsApp) broadcast(text string) { + w.mutex.Lock() + notify := w.notify + closed := w.closed + w.mutex.Unlock() + + if notify != nil && !closed { + notify(Event{ + Kind: "notice", + Text: text, + }) + } +} + +// redact replaces the own number and the own LID in text with OWN. +func (w *WhatsApp) redact(text string) string { + w.mutex.Lock() + defer w.mutex.Unlock() + + for _, id := range w.own { + text = strings.ReplaceAll(text, id, "OWN") + } + + return text +} + +// WhatsAppRequest is a pair or unlink command for Run, with the function that +// gives its answers to the client that asked. A pairing has the phone number. +type WhatsAppRequest struct { + phone string + unlink bool + answer func(text string) +} + // whatsappNewClient returns the client of the library for device, with handle // as its event handler. The client tries again after a failed first // connection, acknowledges a message only after handle returns true, and -// downloads no old history. +// downloads no old history. The phone shows the device with the name OneChat. func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient { + store.SetOSInfo("OneChat", [3]uint32{0, 1, 0}) client := whatsmeow.NewClient(device, waLog.Noop) client.InitialAutoReconnect = true client.SynchronousAck = true @@ -221,6 +704,18 @@ func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsA return client } +// whatsappOwnIDs returns the own number and the own LID that are not empty. +func whatsappOwnIDs(ids ...string) []string { + var own []string + for _, id := range ids { + if id != "" { + own = append(own, id) + } + } + + return own +} + // whatsappSessionAddress returns the SQLite address of the session database at // path, with foreign keys on, as the library needs, and a wait for a busy // database. @@ -254,3 +749,23 @@ func whatsappCommandArgs(line string) (string, bool) { return strings.TrimSpace(args), true } + +// whatsappPhone returns the digits of phone, a number with its country code +// and an optional + and dashes, and tells if it has the form of a number: 7 to +// 15 digits, with no 0 first. +func whatsappPhone(phone string) (string, bool) { + phone = strings.ReplaceAll(strings.TrimPrefix(phone, "+"), "-", "") + valid := len(phone) >= 7 && len(phone) <= 15 && phone[0] != '0' && strings.Trim(phone, "0123456789") == "" + + return phone, valid +} + +// whatsappItemError returns the error of a pairing item that is not a code, a +// success, or a timeout. +func whatsappItemError(item whatsmeow.QRChannelItem) string { + if item.Error != nil { + return item.Error.Error() + } + + return item.Event +} diff --git a/whatsapp_fake_helper_test.go b/whatsapp_fake_helper_test.go index f7e6cad..1a7130c 100644 --- a/whatsapp_fake_helper_test.go +++ b/whatsapp_fake_helper_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "go.mau.fi/whatsmeow" waAdv "go.mau.fi/whatsmeow/proto/waAdv" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" @@ -17,29 +18,93 @@ import ( waLog "go.mau.fi/whatsmeow/util/log" ) -// whatsappTestNumber is the phone number of the own account in the tests. It -// is a number for fiction. -const whatsappTestNumber = "15550001111" +const ( + // whatsappTestNumber is the phone number of the own account in the + // tests. It is a number for fiction. + whatsappTestNumber = "15550001111" + + // whatsappTestLID is the LID of the own account in the tests. + whatsappTestLID = "987654321012345" + + // whatsappTestCode is the pairing code of the fake. + whatsappTestCode = "ABCD-EFGH" +) // WhatsAppFake is a fake WhatsApp client. It keeps the event handler that the // daemon gives it, and Emit gives events to the handler, as the library does. +// Each connection sends to connects, and each pairing sends its channel to +// pairings, so that the test gives the items of the pairing. A connection +// fails with connectErr, and a logout with logoutErr. type WhatsAppFake struct { - mutex sync.Mutex - handle func(event any) bool - connected chan struct{} + connects chan struct{} + pairings chan chan whatsmeow.QRChannelItem + + mutex sync.Mutex + handle func(event any) bool + connectErr error + logoutErr error + phones []string + logouts int } // ConnectContext tells the test that the daemon connected. func (f *WhatsAppFake) ConnectContext(ctx context.Context) error { - close(f.connected) + f.connects <- struct{}{} - return nil + f.mutex.Lock() + defer f.mutex.Unlock() + + return f.connectErr } // Disconnect does nothing. func (f *WhatsAppFake) Disconnect() { } +// GetQRChannel gives a new channel of pairing items to the test. +func (f *WhatsAppFake) GetQRChannel(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) { + items := make(chan whatsmeow.QRChannelItem, 8) + f.pairings <- items + + return items, nil +} + +// PairPhone keeps phone and returns whatsappTestCode. +func (f *WhatsAppFake) PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + f.phones = append(f.phones, phone) + + return whatsappTestCode, nil +} + +// Logout counts the logouts and returns logoutErr. +func (f *WhatsAppFake) Logout(ctx context.Context) error { + f.mutex.Lock() + defer f.mutex.Unlock() + + f.logouts++ + + return f.logoutErr +} + +// Phones returns the phone numbers of the pairings. +func (f *WhatsAppFake) Phones() []string { + f.mutex.Lock() + defer f.mutex.Unlock() + + return append([]string{}, f.phones...) +} + +// Logouts returns the number of logouts. +func (f *WhatsAppFake) Logouts() int { + f.mutex.Lock() + defer f.mutex.Unlock() + + return f.logouts +} + // Emit gives event to the handler of the daemon and returns its answer. func (f *WhatsAppFake) Emit(event any) bool { f.mutex.Lock() @@ -60,27 +125,30 @@ func (f *WhatsAppFake) newClient(device *store.Device, handle func(event any) bo } // whatsappStartTest opens the WhatsApp of the data directory dir with a fake -// client and the test clock, and closes it at the end of the test. -func whatsappStartTest(t *testing.T, dir string) (*WhatsApp, *WhatsAppFake) { +// client and the test clock, and closes it at the end of the test. The daemon +// log goes to the returned buffer. +func whatsappStartTest(t *testing.T, dir string, clock *TestClock) (*WhatsApp, *WhatsAppFake, *SyncBuffer) { t.Helper() fake := &WhatsAppFake{ - connected: make(chan struct{}), + connects: make(chan struct{}, 16), + pairings: make(chan chan whatsmeow.QRChannelItem, 16), } - whatsapp, err := openWhatsApp(t.Context(), dir, newTestClock().Now, log.New(io.Discard, "", 0), fake.newClient) + logs := &SyncBuffer{} + whatsapp, err := openWhatsApp(t.Context(), dir, clock.Now, log.New(logs, "", 0), fake.newClient) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, whatsapp.Close()) }) - return whatsapp, fake + return whatsapp, fake, logs } // whatsappTestSession writes a session database with mode 0600 to the data // directory dir. When linked is true, it holds a device linked to -// whatsappTestNumber, and else a device with no link. +// whatsappTestNumber and whatsappTestLID, and else no device. func whatsappTestSession(t *testing.T, dir string, linked bool) { t.Helper() @@ -98,6 +166,8 @@ func whatsappTestSession(t *testing.T, dir string, linked bool) { device := container.NewDevice() jid := types.NewADJID(whatsappTestNumber, 0, 1) device.ID = &jid + device.LID = types.NewADJID(whatsappTestLID, 0, 1) + device.LID.Server = types.HiddenUserServer device.Account = &waAdv.ADVSignedDeviceIdentity{ Details: []byte{}, AccountSignature: make([]byte, 64), @@ -109,3 +179,9 @@ func whatsappTestSession(t *testing.T, dir string, linked bool) { require.NoError(t, container.Close()) } + +// whatsappDiscardLog is a logger for a WhatsApp whose log a test does not +// read. +func whatsappDiscardLog() *log.Logger { + return log.New(io.Discard, "", 0) +} diff --git a/whatsapp_test.go b/whatsapp_test.go index 2504bd9..e0018e5 100644 --- a/whatsapp_test.go +++ b/whatsapp_test.go @@ -1,15 +1,16 @@ package main import ( - "context" - "io" - "log" + "errors" "os" "path/filepath" + "strings" "testing" "time" "github.com/stretchr/testify/require" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" ) @@ -17,31 +18,10 @@ func TestWhatsAppStatusFollowsTheConnection(t *testing.T) { dir := t.TempDir() whatsappTestSession(t, dir, true) clock := newTestClock() - fake := &WhatsAppFake{ - connected: make(chan struct{}), - } - - whatsapp, err := openWhatsApp(t.Context(), dir, clock.Now, log.New(io.Discard, "", 0), fake.newClient) - require.NoError(t, err) - - t.Cleanup(func() { - require.NoError(t, whatsapp.Close()) - }) + whatsapp, fake, _ := whatsappStartTest(t, dir, clock) + daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan struct{}) - go func() { - defer close(done) - - whatsapp.Run(ctx) - }() - - t.Cleanup(func() { - cancel() - <-done - }) - - <-fake.connected + <-fake.connects require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status()) require.True(t, fake.Emit(&events.Connected{})) @@ -57,22 +37,10 @@ func TestWhatsAppStatusFollowsTheConnection(t *testing.T) { func TestWhatsAppStatusOfAFirstConnectionThatFailed(t *testing.T) { dir := t.TempDir() whatsappTestSession(t, dir, true) - whatsapp, fake := whatsappStartTest(t, dir) - - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan struct{}) - go func() { - defer close(done) + whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock()) + daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - whatsapp.Run(ctx) - }() - - t.Cleanup(func() { - cancel() - <-done - }) - - <-fake.connected + <-fake.connects require.True(t, fake.Emit(&events.Disconnected{})) require.Equal(t, "WhatsApp is linked and disconnected since 2026-01-01 12:00.", whatsapp.Status()) } @@ -99,8 +67,8 @@ func TestWhatsAppIsNotLinkedWithoutALinkedDevice(t *testing.T) { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() tc.session(t, dir) - whatsapp, _ := whatsappStartTest(t, dir) - require.Equal(t, "WhatsApp is not linked.", whatsapp.Status()) + whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock()) + require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status()) }) } } @@ -110,7 +78,7 @@ func TestWhatsAppRefusesASessionThatOthersCanRead(t *testing.T) { whatsappTestSession(t, dir, true) require.NoError(t, os.Chmod(filepath.Join(dir, whatsappDirName, whatsappSessionFileName), 0o644)) - _, err := openWhatsApp(t.Context(), dir, time.Now, log.New(io.Discard, "", 0), whatsappNewClient) + _, err := openWhatsApp(t.Context(), dir, time.Now, whatsappDiscardLog(), whatsappNewClient) require.EqualError(t, err, "whatsapp/session.db holds the WhatsApp session, so its mode must be 0600") } @@ -128,16 +96,314 @@ func TestWhatsAppCommandsAnswerOnlyTheClientThatAsked(t *testing.T) { asking.WaitFor(t, "WhatsApp is not linked.") asking.Type(" /whatsapp unknown ") asking.WaitFor(t, whatsappUsage) + asking.Type("/whatsapp pair 0123") + asking.WaitFor(t, "PHONE must be the number of your WhatsApp account") telegram.Add(TelegramUpdate{ UpdateID: 1, Message: telegramOwnerMessage("/whatsapp status"), }) - telegramWaitForSent(t, telegram, "WhatsApp is not linked.") - require.Equal(t, []string{"WhatsApp is not linked."}, telegram.Sent()) + telegramWaitForSent(t, telegram, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.") + require.Len(t, telegram.Sent(), 1) + require.Equal(t, "[connected to OneChat]\n", other.Output()) + require.Zero(t, server.Calls()) +} + +func TestWhatsAppPairsWithACode(t *testing.T) { + dir := t.TempDir() + whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock()) + server := newModelFake(t) + socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp) + asking := terminalStartTestClient(t, socket) + other := terminalStartTestClient(t, socket) + asking.WaitFor(t, "[connected to OneChat]") + other.WaitFor(t, "[connected to OneChat]") + + asking.Type("/whatsapp pair +1 555-000-1111") + items := <-fake.pairings + items <- whatsmeow.QRChannelItem{ + Event: whatsmeow.QRChannelEventCode, + } + + asking.WaitFor(t, whatsappPairSteps+"\n"+whatsappTestCode+"\n") + require.True(t, fake.Emit(&events.PairSuccess{ + ID: types.NewADJID(whatsappTestNumber, 0, 1), + LID: types.NewADJID(whatsappTestLID, 0, 1), + })) + items <- whatsmeow.QRChannelSuccess + asking.WaitFor(t, "WhatsApp is linked to +15550001111.") + + require.Equal(t, []string{whatsappTestNumber}, fake.Phones()) + require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status()) require.Equal(t, "[connected to OneChat]\n", other.Output()) require.Zero(t, server.Calls()) + require.Equal(t, []string{"WhatsApp is linked."}, whatsappTestLogTexts(t, memory)) + + info, err := os.Stat(filepath.Join(dir, whatsappDirName)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) + + info, err = os.Stat(filepath.Join(dir, whatsappDirName, whatsappSessionFileName)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestWhatsAppPairingCodeIsATelegramMessageAlone(t *testing.T) { + whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock()) + telegram := newTelegramFake(t) + bot, _ := telegramStartTestBot(t, telegram) + daemonStartTestWithWhatsApp(t, newModelFake(t), bot, whatsapp) + + telegram.Add(TelegramUpdate{ + UpdateID: 1, + Message: telegramOwnerMessage("/whatsapp pair 15550001111"), + }) + + items := <-fake.pairings + items <- whatsmeow.QRChannelItem{ + Event: whatsmeow.QRChannelEventCode, + } + + telegramWaitForSent(t, telegram, whatsappPairSteps, whatsappTestCode) +} + +func TestWhatsAppPairingEnds(t *testing.T) { + testCases := []struct { + name string + end func(items chan whatsmeow.QRChannelItem) + answer string + }{ + { + name: "the code expires", + end: func(items chan whatsmeow.QRChannelItem) { + items <- whatsmeow.QRChannelTimeout + }, + answer: whatsappPairExpired, + }, + { + name: "the library closes the pairing", + end: func(items chan whatsmeow.QRChannelItem) { + close(items) + }, + answer: whatsappPairExpired, + }, + { + name: "the pairing fails", + end: func(items chan whatsmeow.QRChannelItem) { + items <- whatsmeow.QRChannelItem{ + Event: whatsmeow.QRChannelEventError, + Error: errors.New("bad handshake"), + } + }, + answer: "The pairing failed: bad handshake. Run /whatsapp pair PHONE again.", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock()) + socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) + client := terminalStartTestClient(t, socket) + client.Type("/whatsapp pair 15550001111") + + items := <-fake.pairings + items <- whatsmeow.QRChannelItem{ + Event: whatsmeow.QRChannelEventCode, + } + + client.WaitFor(t, whatsappTestCode) + tc.end(items) + client.WaitFor(t, tc.answer) + require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status()) + }) + } +} + +func TestWhatsAppNewPairingEndsTheOneBefore(t *testing.T) { + whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock()) + socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) + client := terminalStartTestClient(t, socket) + + client.Type("/whatsapp pair 15550001111") + <-fake.pairings + client.Type("/whatsapp pair 15550002222") + items := <-fake.pairings + items <- whatsmeow.QRChannelItem{ + Event: whatsmeow.QRChannelEventCode, + } + + client.WaitFor(t, whatsappTestCode) + require.Equal(t, []string{"15550002222"}, fake.Phones()) + require.NotContains(t, client.Output(), "did not start the pairing") +} + +func TestWhatsAppPairWhenLinked(t *testing.T) { + dir := t.TempDir() + whatsappTestSession(t, dir, true) + whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock()) + socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) + client := terminalStartTestClient(t, socket) + + client.Type("/whatsapp pair 15550001111") + client.WaitFor(t, "WhatsApp is linked already. Run /whatsapp unlink first.") +} + +func TestWhatsAppUnlink(t *testing.T) { + testCases := []struct { + name string + logoutErr error + answer string + }{ + { + name: "with a logout", + answer: "WhatsApp is unlinked.", + }, + { + name: "with no logout", + logoutErr: errors.New("not connected"), + 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.", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + whatsappTestSession(t, dir, true) + whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock()) + fake.logoutErr = tc.logoutErr + socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) + client := terminalStartTestClient(t, socket) + <-fake.connects + + client.Type("/whatsapp unlink") + client.WaitFor(t, tc.answer) + require.Equal(t, 1, fake.Logouts()) + require.NoFileExists(t, filepath.Join(dir, whatsappDirName, whatsappSessionFileName)) + require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status()) + require.Equal(t, []string{"WhatsApp is unlinked."}, whatsappTestLogTexts(t, memory)) + + client.Type("/whatsapp unlink") + client.WaitFor(t, "WhatsApp is not linked.\n") + }) + } +} + +func TestWhatsAppLogoutFromThePhone(t *testing.T) { + dir := t.TempDir() + whatsappTestSession(t, dir, true) + whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock()) + telegram := newTelegramFake(t) + bot, _ := telegramStartTestBot(t, telegram) + socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), bot, whatsapp) + client := terminalStartTestClient(t, socket) + client.WaitFor(t, "[connected to OneChat]") + <-fake.connects + + require.True(t, fake.Emit(&events.LoggedOut{})) + notice := "WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again." + client.WaitFor(t, notice) + telegramWaitForSent(t, telegram, notice) + require.NoFileExists(t, filepath.Join(dir, whatsappDirName, whatsappSessionFileName)) + require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status()) + require.Equal(t, []string{"WhatsApp was unlinked from the phone."}, whatsappTestLogTexts(t, memory)) +} + +func TestWhatsAppLongDisconnectionGivesOneNotice(t *testing.T) { + dir := t.TempDir() + whatsappTestSession(t, dir, true) + clock := newTestClock() + whatsapp, fake, _ := whatsappStartTest(t, dir, clock) + whatsapp.noticeDelay = 50 * time.Millisecond + socket, _ := 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(&events.Disconnected{})) + require.True(t, fake.Emit(&events.Connected{})) + time.Sleep(100 * time.Millisecond) + require.Equal(t, "[connected to OneChat]\n", client.Output()) + + clock.now = clock.now.Add(time.Hour) + require.True(t, fake.Emit(&events.Disconnected{})) + require.True(t, fake.Emit(&events.Disconnected{})) + client.WaitFor(t, "WhatsApp is disconnected since 2026-01-01 13:00. OneChat tries again.\n") + require.True(t, fake.Emit(&events.Disconnected{})) + time.Sleep(100 * time.Millisecond) + + require.True(t, fake.Emit(&events.Connected{})) + client.WaitFor(t, "WhatsApp is connected again.\n") + require.Equal(t, 1, strings.Count(client.Output(), "WhatsApp is disconnected since")) +} + +func TestWhatsAppDisconnectionWithNoRetryGivesANoticeAtOnce(t *testing.T) { + dir := t.TempDir() + whatsappTestSession(t, dir, true) + whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock()) + socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) + client := terminalStartTestClient(t, socket) + client.WaitFor(t, "[connected to OneChat]") + <-fake.connects + + require.True(t, fake.Emit(&events.StreamReplaced{})) + client.WaitFor(t, "WhatsApp is disconnected and does not connect again by itself: stream replaced. Restart the daemon to try again.") +} + +func TestWhatsAppLogsNoOwnNumber(t *testing.T) { + dir := t.TempDir() + whatsappTestSession(t, dir, true) + whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock()) + fake.connectErr = errors.New("no route for " + whatsappTestNumber + "@s.whatsapp.net and " + whatsappTestLID + "@lid") + daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) + <-fake.connects + + require.Eventually(t, func() bool { + return logs.String() != "" + }, 10*time.Second, time.Millisecond) + require.Equal(t, "connect: no route for OWN@s.whatsapp.net and OWN@lid\n", logs.String()) +} + +func TestWhatsAppPhone(t *testing.T) { + testCases := []struct { + name string + phone string + valid bool + }{ + { + name: "a number with a plus and dashes", + phone: "+1-555-000-1111", + valid: true, + }, + { + name: "a number with no country code", + phone: "0115550001111", + valid: false, + }, + { + name: "a short number", + phone: "123456", + valid: false, + }, + { + name: "a long number", + phone: "1234567890123456", + valid: false, + }, + { + name: "a number with letters", + phone: "1555ABC1111", + valid: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, valid := whatsappPhone(tc.phone) + require.Equal(t, tc.valid, valid) + }) + } } func TestWhatsAppCommandArgs(t *testing.T) { @@ -181,3 +447,23 @@ 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 +}