]> git.giacomolli.uk - onechat/commitdiff
Link WhatsApp with a pairing code
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 14:44:00 +0000 (11:44 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 14:44:00 +0000 (11:44 -0300)
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

README.rst
daemon.go
daemon_start_helper_test.go
whatsapp.go
whatsapp_fake_helper_test.go
whatsapp_test.go

index 3a2d25bdff8f45a31b755ed4d573c6822abb5acc..fd6cb95b67e99124f6b6254fb51f2d7bf9b10a77 100644 (file)
@@ -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
index d67c5be60c663aa9f712de3ac5819776a2ff3785..41b3ab66a419000e2f4247cd39a9dcfc4999855f 100644 (file)
--- 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
index cf1fbf6a507e8bde0923646e679150f699daa209..b2c3787d4189a8a285b754feb1ffbe207e5100b7 100644 (file)
@@ -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
 }
index 05db5e415a22245457a5a019f78ee72d12d66ab3..3c519e8b127c04b2bb64ddb14b89edb4d63c6bcc 100644 (file)
@@ -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
+}
index f7e6cad948f88fe2c46967f3ffce18852c3311da..1a7130c9b21099832180d9fc996c5576eaff9fde 100644 (file)
@@ -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)
+}
index 2504bd9b9fc1c44907c42cbaca28b8b0b768cd6b..e0018e5ba2a744d99f6130ed03b269dd366ebfc1 100644 (file)
@@ -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
+}