]> git.giacomolli.uk - onechat/blobdiff - whatsapp.go
Log each change of the WhatsApp connection
[onechat] / whatsapp.go
index 44b9dfa6557336a241348894e8f681c976ea9dc3..2c1b6cb9aaa70e327fd4a342d282fb97ecd4a247 100644 (file)
@@ -1,14 +1,10 @@
 package main
 
 import (
 package main
 
 import (
-       "cmp"
        "context"
        "context"
-       "crypto/sha256"
-       "encoding/hex"
        "errors"
        "fmt"
        "log"
        "errors"
        "fmt"
        "log"
-       "maps"
        "net/url"
        "os"
        "path/filepath"
        "net/url"
        "os"
        "path/filepath"
@@ -16,19 +12,15 @@ import (
        "strings"
        "sync"
        "time"
        "strings"
        "sync"
        "time"
-       "unicode"
 
        "go.mau.fi/whatsmeow"
 
        "go.mau.fi/whatsmeow"
+       "go.mau.fi/whatsmeow/proto/waCompanionReg"
        "go.mau.fi/whatsmeow/proto/waE2E"
        "go.mau.fi/whatsmeow/store"
        "go.mau.fi/whatsmeow/store/sqlstore"
        "go.mau.fi/whatsmeow/types"
        "go.mau.fi/whatsmeow/types/events"
        waLog "go.mau.fi/whatsmeow/util/log"
        "go.mau.fi/whatsmeow/proto/waE2E"
        "go.mau.fi/whatsmeow/store"
        "go.mau.fi/whatsmeow/store/sqlstore"
        "go.mau.fi/whatsmeow/types"
        "go.mau.fi/whatsmeow/types/events"
        waLog "go.mau.fi/whatsmeow/util/log"
-       "golang.org/x/text/runes"
-       "golang.org/x/text/transform"
-       "golang.org/x/text/unicode/norm"
-
        // The session store of the library opens SQLite with this pure Go
        // driver.
        _ "modernc.org/sqlite"
        // The session store of the library opens SQLite with this pure Go
        // driver.
        _ "modernc.org/sqlite"
@@ -77,51 +69,12 @@ const (
        // wait for Run.
        whatsappRequests = 8
 
        // wait for Run.
        whatsappRequests = 8
 
-       // whatsappSource is the source of the work events of the batches.
-       whatsappSource = "whatsapp"
-
        // whatsappLookupTime is the longest time of a read of the names in the
        // session database or of the groups from WhatsApp.
        whatsappLookupTime = 10 * time.Second
 
        // whatsappLookupTime is the longest time of a read of the names in the
        // session database or of the groups from WhatsApp.
        whatsappLookupTime = 10 * time.Second
 
-       // whatsappGroupsRefresh is the shortest time between two reads of the
-       // groups from WhatsApp for a group with no name.
-       whatsappGroupsRefresh = time.Minute
-
-       // whatsappUnknownGroup is the name of a group whose name is not known.
-       whatsappUnknownGroup = "A group with no known name"
-
-       // whatsappCatchupLimit is the longest wait for the end of the catch-up
-       // after a connection.
-       whatsappCatchupLimit = time.Minute
-
-       // whatsappGroupsAge is how long whatsapp_find uses the groups that it
-       // read from WhatsApp.
-       whatsappGroupsAge = time.Hour
-
-       // whatsappFindMatches is the most matches of whatsapp_find.
-       whatsappFindMatches = 20
-
        // whatsappNotLinked is the answer of a WhatsApp tool without a link.
        whatsappNotLinked = "WhatsApp is not linked. Tell the user to run /whatsapp pair PHONE in the terminal or in Telegram."
        // whatsappNotLinked is the answer of a WhatsApp tool without a link.
        whatsappNotLinked = "WhatsApp is not linked. Tell the user to run /whatsapp pair PHONE in the terminal or in Telegram."
-
-       // whatsappSendTime is the longest time of a send.
-       whatsappSendTime = 30 * time.Second
-
-       // whatsappSendBytes is the most bytes of the text of a send.
-       whatsappSendBytes = 65536
-
-       // whatsappSendIDBytes is the most bytes of the ID of a send.
-       whatsappSendIDBytes = 100
-
-       // The states of a send record.
-       whatsappSending = "sending"
-       whatsappSent    = "sent"
-       whatsappFailed  = "failed"
-
-       // whatsappNamesNotReady is the answer of whatsapp_find before the names
-       // arrive from the phone.
-       whatsappNamesNotReady = "The names of the contacts and groups have not arrived from the phone yet. They come some minutes after the link. Try again later."
 )
 
 // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
 )
 
 // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
@@ -145,17 +98,14 @@ type WhatsAppClient interface {
 // the client of a device with its event handler.
 func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *log.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
        w := &WhatsApp{
 // the client of a device with its event handler.
 func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *log.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
        w := &WhatsApp{
-               dir:          filepath.Join(dir, whatsappDirName),
-               clock:        clock,
-               logger:       logger,
-               newClient:    newClient,
-               noticeDelay:  whatsappNoticeDelay,
-               catchupLimit: whatsappCatchupLimit,
-               requests:     make(chan WhatsAppRequest, whatsappRequests),
-               logouts:      make(chan struct{}, 1),
-               wake:         make(chan struct{}, 1),
-               failures:     make(chan error, 1),
-               groupNames:   make(map[string]string),
+               dir:         filepath.Join(dir, whatsappDirName),
+               clock:       clock,
+               logger:      logger,
+               newClient:   newClient,
+               noticeDelay: whatsappNoticeDelay,
+               requests:    make(chan WhatsAppRequest, whatsappRequests),
+               logouts:     make(chan struct{}, 1),
+               groups:      newWhatsAppGroups(logger),
        }
 
        var err error
        }
 
        var err error
@@ -164,6 +114,8 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
                return nil, err
        }
 
                return nil, err
        }
 
+       w.inbox = newWhatsAppInbox(w.state, clock, w.groups)
+
        info, err := os.Stat(w.sessionPath())
        if errors.Is(err, os.ErrNotExist) {
                return w, nil
        info, err := os.Stat(w.sessionPath())
        if errors.Is(err, os.ErrNotExist) {
                return w, nil
@@ -184,7 +136,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
 
        if w.device.ID != nil {
                w.linked = true
 
        if w.device.ID != nil {
                w.linked = true
-               w.catchingUp = true
+               w.inbox.waitForCatchup()
                w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
        }
 
                w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
        }
 
@@ -204,20 +156,9 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
 // client, and the state of the connection, which the event handler of the
 // library changes from its own goroutines.
 //
 // client, and the state of the connection, which the event handler of the
 // library changes from its own goroutines.
 //
-// The text messages go into batches, one chat at a time, as WhatsAppState says.
-// Run logs each closed batch as a work message that starts with [WhatsApp], and
-// a batch that needs the user wakes the turn loop.
-//
-// After each connection, the servers send the messages that came while the
-// daemon was away, and then OfflineSyncCompleted. Until then, or until
-// catchupLimit passes, the closed batches wait in the state file. At the end of
-// the catch-up, Run closes all batches, logs them, and wakes the turn loop
-// once, so the whole catch-up gives one turn and not one turn for each chat.
-// The batches of the state file at the start wait for the first catch-up in the
-// same way. The event handler acknowledges a message only after the state file
-// holds it, so the servers send again a message that a crash lost. A batch
-// leaves the state file only after its work message is durable in the log. A
-// crash between the two makes the batch repeat once.
+// The inbox puts the text messages into batches and logs them, and groups
+// holds the names of the groups. Both get a WhatsAppLink from link for each
+// call, so they use the current device and client.
 //
 // The library logs nothing, because its log lines hold the ID of the own
 // account, which is the phone number of the user. The own number and the own
 //
 // The library logs nothing, because its log lines hold the ID of the own
 // account, which is the phone number of the user. The own number and the own
@@ -227,17 +168,16 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
 // log, and those of private chats hold phone numbers, because the agent needs
 // them to send.
 type WhatsApp struct {
 // log, and those of private chats hold phone numbers, because the agent needs
 // them to send.
 type WhatsApp struct {
-       dir          string
-       clock        func() time.Time
-       logger       *log.Logger
-       newClient    func(device *store.Device, handle func(event any) bool) WhatsAppClient
-       noticeDelay  time.Duration
-       catchupLimit time.Duration
-       requests     chan WhatsAppRequest
-       logouts      chan struct{}
-       wake         chan struct{}
-       failures     chan error
-       state        *WhatsAppState
+       dir         string
+       clock       func() time.Time
+       logger      *log.Logger
+       newClient   func(device *store.Device, handle func(event any) bool) WhatsAppClient
+       noticeDelay time.Duration
+       requests    chan WhatsAppRequest
+       logouts     chan struct{}
+       state       *WhatsAppState
+       groups      *WhatsAppGroups
+       inbox       *WhatsAppInbox
 
        mutex          sync.Mutex
        container      *sqlstore.Container
 
        mutex          sync.Mutex
        container      *sqlstore.Container
@@ -253,13 +193,6 @@ type WhatsApp struct {
        noticed        bool
        timer          *time.Timer
        closed         bool
        noticed        bool
        timer          *time.Timer
        closed         bool
-       groupNames     map[string]string
-       groupsAt       time.Time
-       groupsRead     time.Time
-       catchingUp     bool
-       caughtUp       bool
-       catchups       int
-       catchupTimer   *time.Timer
 }
 
 // Run connects a linked device, logs the closed batches in l, runs the pair and
 }
 
 // Run connects a linked device, logs the closed batches in l, runs the pair and
@@ -288,24 +221,18 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err
        }()
 
        for {
        }()
 
        for {
-               err := w.deliver(ctx, l, false)
+               err := w.inbox.deliver(ctx, l, w.link(), false)
                if err != nil {
                        return err
                }
 
                if err != nil {
                        return err
                }
 
-               var due <-chan time.Time
-               wait, open := w.state.Wait(time.Now())
-               if open {
-                       due = time.After(wait)
-               }
-
                select {
                case <-ctx.Done():
                        return nil
                select {
                case <-ctx.Done():
                        return nil
-               case err = <-w.failures:
+               case err = <-w.inbox.failures:
                        return err
                        return err
-               case <-w.wake:
-               case <-due:
+               case <-w.inbox.wake:
+               case <-w.inbox.due():
                case <-w.logouts:
                        err = w.loggedOut(ctx, l)
                        if err != nil {
                case <-w.logouts:
                        err = w.loggedOut(ctx, l)
                        if err != nil {
@@ -408,6 +335,17 @@ func (w *WhatsApp) Close() error {
        return err
 }
 
        return err
 }
 
+// Find returns the answer of whatsapp_find for name, as whatsappFind says.
+func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) {
+       return whatsappFind(ctx, w.link(), w.groups, name)
+}
+
+// Send returns the answer of whatsapp_send for the send id of text to chat, as
+// whatsappSend says.
+func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) {
+       return whatsappSend(ctx, w.link(), w.groups, w.state, w.clock, id, chat, text)
+}
+
 func (w *WhatsApp) sessionPath() string {
        return filepath.Join(w.dir, whatsappSessionFileName)
 }
 func (w *WhatsApp) sessionPath() string {
        return filepath.Join(w.dir, whatsappSessionFileName)
 }
@@ -443,6 +381,21 @@ func (w *WhatsApp) isLinked() bool {
        return w.linked
 }
 
        return w.linked
 }
 
+// link returns a copy of the link of the device: whether it is linked, the
+// device, the client, whether it is connected, and the own IDs.
+func (w *WhatsApp) link() WhatsAppLink {
+       w.mutex.Lock()
+       defer w.mutex.Unlock()
+
+       return WhatsAppLink{
+               linked:    w.linked,
+               device:    w.device,
+               client:    w.client,
+               connected: w.connected,
+               own:       slices.Clone(w.own),
+       }
+}
+
 // connect makes the client of the linked device and connects it.
 func (w *WhatsApp) connect(ctx context.Context) {
        w.mutex.Lock()
 // connect makes the client of the linked device and connects it.
 func (w *WhatsApp) connect(ctx context.Context) {
        w.mutex.Lock()
@@ -452,7 +405,7 @@ func (w *WhatsApp) connect(ctx context.Context) {
 
        err := client.ConnectContext(ctx)
        if err != nil {
 
        err := client.ConnectContext(ctx)
        if err != nil {
-               w.logger.Printf("connect: %s", w.redact(err.Error()))
+               w.logger.Printf("WhatsApp messages do not come, because the connection failed, and it is tried again only at a restart of the daemon: %s", w.redact(err.Error()))
        }
 }
 
        }
 }
 
@@ -464,14 +417,12 @@ func (w *WhatsApp) close() {
                w.timer.Stop()
        }
 
                w.timer.Stop()
        }
 
-       if w.catchupTimer != nil {
-               w.catchupTimer.Stop()
-       }
-
        client := w.client
        w.client = nil
        w.mutex.Unlock()
 
        client := w.client
        w.client = nil
        w.mutex.Unlock()
 
+       w.inbox.close()
+
        if client != nil {
                client.Disconnect()
        }
        if client != nil {
                client.Disconnect()
        }
@@ -484,19 +435,20 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
                return nil
        }
 
                return nil
        }
 
-       err := w.deliver(ctx, l, true)
+       w.logger.Print("WhatsApp messages stop, because the phone logged out this device (run /whatsapp pair PHONE to link it again)")
+       err := w.inbox.deliver(ctx, l, w.link(), true)
        if err != nil {
                return err
        }
 
        err = w.removeSession()
        if err != nil {
        if err != nil {
                return err
        }
 
        err = w.removeSession()
        if err != nil {
-               w.logger.Printf("delete the session after the logout: %v", err)
+               w.logger.Printf("the session of the logged out device stays on the disk, because its delete failed: %v", err)
        }
 
        err = l.note("WhatsApp was unlinked from the phone.")
        if err != nil {
        }
 
        err = l.note("WhatsApp was unlinked from the phone.")
        if err != nil {
-               w.logger.Printf("note the logout: %v", err)
+               w.logger.Printf("the chat has no note of the logout, because its write failed: %v", err)
        }
 
        w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
        }
 
        w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
@@ -520,7 +472,7 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
                return nil
        }
 
                return nil
        }
 
-       err := w.deliver(ctx, l, true)
+       err := w.inbox.deliver(ctx, l, w.link(), true)
        if err != nil {
                return err
        }
        if err != nil {
                return err
        }
@@ -539,9 +491,10 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
                return nil
        }
 
                return nil
        }
 
+       w.logger.Print("WhatsApp messages stop, because the user unlinked WhatsApp")
        err = l.note("WhatsApp is unlinked.")
        if err != nil {
        err = l.note("WhatsApp is unlinked.")
        if err != nil {
-               w.logger.Printf("note the unlink: %v", err)
+               w.logger.Printf("the chat has no note of the unlink, because its write failed: %v", err)
        }
 
        if client == nil || logoutErr != nil {
        }
 
        if client == nil || logoutErr != nil {
@@ -554,205 +507,6 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
        return nil
 }
 
        return nil
 }
 
-// deliver logs the closed batches in l in order, each as a work message of
-// whatsappSource, and wakes the loop once with the batches that start a turn. A
-// batch leaves the state file after its message is durable in the log. A group
-// with no name gets its name from WhatsApp first. During a catch-up it logs
-// nothing, and at its end it closes all batches first. With all, it closes and
-// logs all batches also during a catch-up.
-func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error {
-       w.mutex.Lock()
-       catchingUp := w.catchingUp
-       caughtUp := w.caughtUp
-       w.caughtUp = false
-       w.mutex.Unlock()
-
-       var err error
-       switch {
-       case all || caughtUp:
-               err = w.state.CloseAll()
-       case catchingUp:
-               return nil
-       default:
-               _, err = w.state.CloseDue(time.Now())
-       }
-
-       if err != nil {
-               return err
-       }
-
-       var turns []Message
-       for _, batch := range w.state.Closed() {
-               if batch.Chat.Group && batch.Chat.Name == "" {
-                       batch.Chat.Name = w.groupName(ctx, batch.Chat.ID)
-               }
-
-               message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "")
-               if err != nil {
-                       return fmt.Errorf("log a WhatsApp batch: %w", err)
-               }
-
-               // A stop of the daemon during the flush is no failure: the
-               // batch stays in the state file and repeats once at the next
-               // start.
-               err = l.Flush(ctx)
-               if ctx.Err() != nil {
-                       return nil
-               }
-
-               if err != nil {
-                       return fmt.Errorf("log a WhatsApp batch: %w", err)
-               }
-
-               err = w.state.Remove(batch.Number)
-               if err != nil {
-                       return err
-               }
-
-               if batch.Turn() {
-                       turns = append(turns, message)
-               }
-       }
-
-       l.Wake(turns...)
-
-       return nil
-}
-
-// groupName returns the name of the group id, or whatsappUnknownGroup. A group
-// that is not in the names of the groups reads the groups from WhatsApp again,
-// at most once in whatsappGroupsRefresh.
-func (w *WhatsApp) groupName(ctx context.Context, id string) string {
-       w.mutex.Lock()
-       name, found := w.groupNames[id]
-       fresh := time.Since(w.groupsAt) < whatsappGroupsRefresh
-       w.mutex.Unlock()
-
-       if !found && !fresh {
-               w.readGroups(ctx)
-
-               w.mutex.Lock()
-               name, found = w.groupNames[id]
-               w.mutex.Unlock()
-       }
-
-       if !found || name == "" {
-               return whatsappUnknownGroup
-       }
-
-       return name
-}
-
-// readGroups reads the names of the groups of the account from WhatsApp, and
-// tells if it could. They stay in memory only.
-func (w *WhatsApp) readGroups(ctx context.Context) bool {
-       w.mutex.Lock()
-       client := w.client
-       w.mutex.Unlock()
-
-       if client == nil {
-               return false
-       }
-
-       ctx, cancel := context.WithTimeout(ctx, whatsappLookupTime)
-       defer cancel()
-
-       groups, err := client.GetJoinedGroups(ctx)
-       if err != nil {
-               w.logger.Printf("read the groups: %s", w.redact(err.Error()))
-       }
-
-       w.mutex.Lock()
-       defer w.mutex.Unlock()
-
-       w.groupsAt = time.Now()
-       if err != nil {
-               return false
-       }
-
-       w.groupsRead = w.groupsAt
-       for _, group := range groups {
-               w.groupNames[group.JID.String()] = group.Name
-       }
-
-       return true
-}
-
-// Find returns the contacts and the groups whose name contains name, with no
-// regard to case and accents, as lines "ID | name | contact" or "ID | name |
-// group", sorted by name. It gives at most whatsappFindMatches lines and the
-// number of the others. The contacts come from the session database, and the
-// groups from WhatsApp, read again after whatsappGroupsAge. When the groups
-// cannot be read and none are known, the answer says so. Before the names
-// arrive from the phone, it says that they are not ready.
-func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) {
-       w.mutex.Lock()
-       linked := w.linked
-       device := w.device
-       own := slices.Clone(w.own)
-       stale := time.Since(w.groupsRead) >= whatsappGroupsAge
-       w.mutex.Unlock()
-
-       if !linked {
-               return whatsappNotLinked, nil
-       }
-
-       query := whatsappFold(name)
-       if query == "" {
-               return "", errors.New("name is empty: give a part of the name of the contact or the group")
-       }
-
-       contacts, err := device.Contacts.GetAllContacts(ctx)
-       if err != nil {
-               return "", fmt.Errorf("read the contacts: %s", w.redact(err.Error()))
-       }
-
-       read := !stale || w.readGroups(ctx)
-       w.mutex.Lock()
-       groups := maps.Clone(w.groupNames)
-       w.mutex.Unlock()
-
-       if len(contacts) == 0 && len(groups) == 0 {
-               if !read {
-                       return "The names are not known yet, and WhatsApp is disconnected, so the groups cannot be read. Try again later.", nil
-               }
-
-               return whatsappNamesNotReady, nil
-       }
-
-       var matches []WhatsAppMatch
-       for jid, contact := range contacts {
-               names := []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName}
-               shown := slices.IndexFunc(names, func(name string) bool {
-                       return name != ""
-               })
-
-               if slices.Contains(own, jid.User) || shown < 0 || !slices.ContainsFunc(names, func(name string) bool {
-                       return strings.Contains(whatsappFold(name), query)
-               }) {
-                       continue
-               }
-
-               matches = append(matches, WhatsAppMatch{
-                       id:   jid.ToNonAD().String(),
-                       name: names[shown],
-                       kind: "contact",
-               })
-       }
-
-       for id, group := range groups {
-               if strings.Contains(whatsappFold(group), query) {
-                       matches = append(matches, WhatsAppMatch{
-                               id:   id,
-                               name: group,
-                               kind: "group",
-                       })
-               }
-       }
-
-       return whatsappFindAnswer(name, matches, read || len(groups) > 0), nil
-}
-
 // removeSession ends the connection, closes the session database, and deletes
 // its files.
 func (w *WhatsApp) removeSession() error {
 // removeSession ends the connection, closes the session database, and deletes
 // its files.
 func (w *WhatsApp) removeSession() error {
@@ -762,7 +516,7 @@ func (w *WhatsApp) removeSession() error {
        w.linked = false
        w.own = nil
        w.connected = false
        w.linked = false
        w.own = nil
        w.connected = false
-       w.catchingUp = false
+       w.disconnectedAt = time.Time{}
        if w.timer != nil {
                w.timer.Stop()
        }
        if w.timer != nil {
                w.timer.Stop()
        }
@@ -773,6 +527,8 @@ func (w *WhatsApp) removeSession() error {
        w.device = nil
        w.mutex.Unlock()
 
        w.device = nil
        w.mutex.Unlock()
 
+       w.inbox.stopCatchup()
+
        if client != nil {
                client.Disconnect()
        }
        if client != nil {
                client.Disconnect()
        }
@@ -842,6 +598,8 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
                return
        }
 
                return
        }
 
+       // The server accepts only a display name of the form "Browser (OS)"
+       // with a common browser and OS, and refuses the pairing otherwise.
        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.")
        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.")
@@ -873,10 +631,11 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
                case item.Event == whatsmeow.QRChannelSuccess.Event:
                        linked = true
                        w.linkedBy(client)
                case item.Event == whatsmeow.QRChannelSuccess.Event:
                        linked = true
                        w.linkedBy(client)
+                       w.logger.Print("WhatsApp is linked")
 
                        err = l.note("WhatsApp is linked.")
                        if err != nil {
 
                        err = l.note("WhatsApp is linked.")
                        if err != nil {
-                               w.logger.Printf("note the link: %v", err)
+                               w.logger.Printf("the chat has no note of the link, because its write failed: %v", err)
                        }
 
                        answer("WhatsApp is linked to +" + phone + ".")
                        }
 
                        answer("WhatsApp is linked to +" + phone + ".")
@@ -933,42 +692,53 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) {
 }
 
 // handle takes one event of the library and tells if it was handled. A message
 }
 
 // handle takes one event of the library and tells if it was handled. A message
-// goes to receive. The names of the groups follow the events of the groups. A
-// connection starts a catch-up, which ends at OfflineSyncCompleted or after
-// catchupLimit. A disconnection keeps the time of its start while the library
-// tries again. After noticeDelay of a disconnection, all clients get one
-// notice, and they get one more when the connection comes back. A disconnection
-// that the library does not try again gives a notice at once.
+// goes to the inbox. The names of the groups follow the events of the groups. A
+// connection starts a catch-up of the inbox, which ends at OfflineSyncCompleted
+// or after its catchupLimit. A disconnection keeps the time of its start while
+// the library tries again. After noticeDelay of a disconnection, all clients
+// get one notice, and they get one more when the connection comes back. A
+// disconnection that the library does not try again gives a notice at once.
+//
+// The log of the daemon gets one line for each change of the connection of the
+// linked device: connected, a failed first connection, a lost connection, and
+// connected again with the length of the outage. The retries of the library
+// between them log nothing.
 func (w *WhatsApp) handle(event any) bool {
 func (w *WhatsApp) handle(event any) bool {
-       message, isMessage := event.(*events.Message)
-       if isMessage {
-               return w.receive(message)
-       }
-
-       var notice string
-       w.mutex.Lock()
        switch event := event.(type) {
        switch event := event.(type) {
+       case *events.Message:
+               return w.inbox.receive(w.link(), event)
        case *events.GroupInfo:
                if event.Name != nil {
        case *events.GroupInfo:
                if event.Name != nil {
-                       w.groupNames[event.JID.String()] = event.Name.Name
+                       w.groups.set(event.JID.String(), event.Name.Name)
                }
                }
+
+               return true
        case *events.JoinedGroup:
        case *events.JoinedGroup:
-               w.groupNames[event.JID.String()] = event.Name
+               w.groups.set(event.JID.String(), event.Name)
+               return true
+       case *events.OfflineSyncCompleted:
+               w.inbox.endCatchup()
+               return true
+       case *events.Connected:
+               w.inbox.startCatchup()
+       }
+
+       var notice, line string
+       w.mutex.Lock()
+       switch event := event.(type) {
        case *events.PairSuccess:
                w.own = whatsappOwnIDs(event.ID.User, event.LID.User)
        case *events.Connected:
        case *events.PairSuccess:
                w.own = whatsappOwnIDs(event.ID.User, event.LID.User)
        case *events.Connected:
-               w.connected = true
-               w.connectedAt = w.clock()
-               w.catchingUp = true
-               w.catchups++
-               catchups := w.catchups
-               if w.catchupTimer != nil {
-                       w.catchupTimer.Stop()
+               switch {
+               case !w.linked || w.connected:
+               case w.disconnectedAt.IsZero():
+                       line = "WhatsApp is connected, and new messages come in"
+               default:
+                       line = "WhatsApp is connected again, after " + durationText(w.clock().Sub(w.disconnectedAt))
                }
 
                }
 
-               w.catchupTimer = time.AfterFunc(w.catchupLimit, func() {
-                       w.caughtUpBy(catchups)
-               })
+               w.connected = true
+               w.connectedAt = w.clock()
                if w.timer != nil {
                        w.timer.Stop()
                }
                if w.timer != nil {
                        w.timer.Stop()
                }
@@ -979,6 +749,11 @@ func (w *WhatsApp) handle(event any) bool {
                }
        case *events.Disconnected:
                if w.linked && (w.connected || w.disconnectedAt.IsZero()) {
                }
        case *events.Disconnected:
                if w.linked && (w.connected || w.disconnectedAt.IsZero()) {
+                       line = "new WhatsApp messages wait, because the connection is lost (the next try is automatic)"
+                       if !w.connected {
+                               line = "new WhatsApp messages wait, because the first connection failed (the next try is automatic)"
+                       }
+
                        w.disconnectedAt = w.clock()
                        w.disconnections++
                        disconnections := w.disconnections
                        w.disconnectedAt = w.clock()
                        w.disconnections++
                        disconnections := w.disconnections
@@ -988,8 +763,6 @@ func (w *WhatsApp) handle(event any) bool {
                }
 
                w.connected = false
                }
 
                w.connected = false
-       case *events.OfflineSyncCompleted:
-               w.endCatchup()
        case *events.LoggedOut:
                select {
                case w.logouts <- struct{}{}:
        case *events.LoggedOut:
                select {
                case w.logouts <- struct{}{}:
@@ -1000,83 +773,27 @@ func (w *WhatsApp) handle(event any) bool {
                        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.connected = false
                        w.noticed = true
                        notice = "WhatsApp is disconnected and does not connect again by itself: " + event.PermanentDisconnectDescription() + ". Restart the daemon to try again."
+                       line = "WhatsApp messages do not come, because the connection is lost and it is tried again only at a restart of the daemon: " + whatsappRedact(w.own, event.PermanentDisconnectDescription())
                }
        }
                }
        }
-       w.mutex.Unlock()
 
 
-       if notice != "" {
-               w.broadcast(notice)
+       if w.closed {
+               line = ""
        }
 
        }
 
-       return true
-}
-
-// receive puts the text of event into its batch and wakes Run. It returns false
-// when the state file cannot hold the message, so that the library does not
-// acknowledge it, and Run stops the daemon. A message that is not text, or that
-// the batches do not take, is handled with nothing to do.
-func (w *WhatsApp) receive(event *events.Message) bool {
-       ctx, cancel := context.WithTimeout(context.Background(), whatsappLookupTime)
-       defer cancel()
-
-       w.mutex.Lock()
-       device := w.device
-       own := slices.Clone(w.own)
        w.mutex.Unlock()
 
        w.mutex.Unlock()
 
-       chat, message, found := whatsappMessage(ctx, device, own, event)
-       if !found {
-               return true
+       if line != "" {
+               w.logger.Print(line)
        }
 
        }
 
-       _, err := w.state.Add(chat, message, time.Now())
-       if err != nil {
-               select {
-               case w.failures <- err:
-               default:
-               }
-
-               return false
-       }
-
-       select {
-       case w.wake <- struct{}{}:
-       default:
+       if notice != "" {
+               w.broadcast(notice)
        }
 
        return true
 }
 
        }
 
        return true
 }
 
-// caughtUpBy ends the catch-up of the connection with the number catchups, when
-// no later connection started a new one.
-func (w *WhatsApp) caughtUpBy(catchups int) {
-       w.mutex.Lock()
-       defer w.mutex.Unlock()
-
-       if w.catchups == catchups {
-               w.endCatchup()
-       }
-}
-
-// endCatchup ends a running catch-up and wakes Run, which logs all batches. The
-// caller holds the mutex.
-func (w *WhatsApp) endCatchup() {
-       if !w.catchingUp {
-               return
-       }
-
-       w.catchingUp = false
-       w.caughtUp = true
-       if w.catchupTimer != nil {
-               w.catchupTimer.Stop()
-       }
-
-       select {
-       case w.wake <- struct{}{}:
-       default:
-       }
-}
-
 // disconnectedLong gives the notice of a long disconnection, unless the
 // disconnection with the number disconnections ended.
 func (w *WhatsApp) disconnectedLong(disconnections int) {
 // disconnectedLong gives the notice of a long disconnection, unless the
 // disconnection with the number disconnections ended.
 func (w *WhatsApp) disconnectedLong(disconnections int) {
@@ -1117,93 +834,14 @@ func (w *WhatsApp) redact(text string) string {
        return whatsappRedact(w.own, text)
 }
 
        return whatsappRedact(w.own, text)
 }
 
-// Send sends text to chat, the ID of a private chat or a group, and returns the
-// answer of the tool. id names the send: a call with an id that has a record
-// gives the answer of the record and sends nothing, and the same id with
-// another chat or text is an error. The record "sending" is durable before the
-// send, so a stop of the daemon during the send gives an answer that the result
-// is unknown, and not a second send. A disconnected WhatsApp sends nothing and
-// makes no record.
-func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) {
-       w.mutex.Lock()
-       linked := w.linked
-       device := w.device
-       client := w.client
-       connected := w.connected
-       own := slices.Clone(w.own)
-       w.mutex.Unlock()
-
-       if !linked {
-               return whatsappNotLinked, nil
-       }
-
-       jid, err := whatsappSendCheck(id, chat, text, own)
-       if err != nil {
-               return "", err
-       }
-
-       digest := sha256.Sum256([]byte(text))
-       record := WhatsAppSend{
-               ID:     id,
-               Chat:   chat,
-               Digest: hex.EncodeToString(digest[:]),
-               State:  whatsappSending,
-               Time:   w.clock(),
-       }
-
-       held, found := w.state.Send(id)
-       switch {
-       case found && (held.Chat != record.Chat || held.Digest != record.Digest):
-               return "", fmt.Errorf("the id %s belongs to another message: give a new id for each message", id)
-       case found && held.State == whatsappSending:
-               return "The send with this id started, but its result is unknown, because the daemon stopped during it. Ask the user to look at the chat in WhatsApp before you send the message again with a new id.", nil
-       case found:
-               return held.Answer + " This is the answer of the first call with this id: nothing was sent again.", nil
-       case client == nil || !connected:
-               return "", errors.New("WhatsApp is disconnected, so nothing was sent: try again later")
-       }
-
-       record.MessageID = client.GenerateMessageID()
-       err = w.state.PutSend(record)
-       if err != nil {
-               return "", err
-       }
-
-       sendCtx, cancel := context.WithTimeout(ctx, whatsappSendTime)
-       defer cancel()
-
-       response, err := client.SendMessage(sendCtx, jid, &waE2E.Message{
-               Conversation: new(text),
-       }, whatsmeow.SendRequestExtra{
-               ID: record.MessageID,
-       })
-
-       record.State = whatsappSent
-       if err != nil {
-               record.State = whatsappFailed
-               record.Answer = "The send failed: " + w.redact(err.Error()) + "."
-       } else {
-               name := w.groupName(ctx, jid.String())
-               if jid.Server != types.GroupServer {
-                       name = whatsappContactName(ctx, device, jid, "")
-               }
-
-               record.Answer = "Sent to " + name + " at " + response.Timestamp.In(w.clock().Location()).Format("15:04") + "."
-       }
-
-       err = w.state.PutSend(record)
-       if err != nil {
-               return "", err
-       }
-
-       return record.Answer, nil
-}
-
-// WhatsAppMatch is one contact or group of Find.
-type WhatsAppMatch struct {
-       id   string
-       name string
-       kind string
+// WhatsAppLink is a copy of the link of the device at one time, for the parts
+// of WhatsApp that do not own it: the groups, the batches, find, and send.
+type WhatsAppLink struct {
+       linked    bool
+       device    *store.Device
+       client    WhatsAppClient
+       connected bool
+       own       []string
 }
 
 // WhatsAppRequest is a pair or unlink command for Run, with the function that
 }
 
 // WhatsAppRequest is a pair or unlink command for Run, with the function that
@@ -1214,12 +852,20 @@ type WhatsAppRequest struct {
        answer func(text string)
 }
 
        answer func(text string)
 }
 
+// whatsappSetDeviceProps sets the device properties that the library sends at
+// the pairing. They are global values of the library, so the daemon sets them
+// once before any client exists. The phone shows the linked device with the
+// name OneChat and a desktop icon only when the platform type is known.
+func whatsappSetDeviceProps() {
+       store.SetOSInfo("OneChat", [3]uint32{0, 1, 0})
+       store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_DESKTOP.Enum()
+}
+
 // 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
 // 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. The phone shows the device with the name OneChat.
+// history.
 func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
 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
        client := whatsmeow.NewClient(device, waLog.Noop)
        client.InitialAutoReconnect = true
        client.SynchronousAck = true
@@ -1229,90 +875,6 @@ func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsA
        return client
 }
 
        return client
 }
 
-// whatsappMessage returns the chat and the batch message of event, or false
-// when the batches do not take it. device is the linked device, and own holds
-// the own IDs. The batches take the text of a private chat or a group, from
-// another person or from the user. They do not take media, reactions, edits,
-// deletions, view-once and disappearing messages, old history, status
-// broadcasts, channels, and the chat of the user with itself.
-//
-// A mention of the user becomes @you, another mention @NAME when the name is
-// known, and the own number and LID become OWN, so that they never go to the
-// log.
-func whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
-       info := event.Info
-       chatJID := info.Chat.ToNonAD()
-       switch {
-       case device == nil:
-       case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil:
-       case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
-       case slices.Contains(own, chatJID.User):
-       default:
-               return whatsappText(ctx, device, own, event)
-       }
-
-       return WhatsAppChat{}, WhatsAppMessage{}, false
-}
-
-// whatsappText returns the chat and the batch message of event, a message that
-// the batches take when it has text.
-func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
-       info := event.Info
-       text := event.Message.GetConversation()
-       extended := event.Message.GetExtendedTextMessage()
-       if text == "" {
-               text = extended.GetText()
-       }
-
-       if strings.TrimSpace(text) == "" {
-               return WhatsAppChat{}, WhatsAppMessage{}, false
-       }
-
-       contextInfo := extended.GetContextInfo()
-       message := WhatsAppMessage{
-               ID:   info.ID,
-               Time: info.Timestamp,
-               Mine: info.IsFromMe,
-       }
-
-       if !message.Mine {
-               message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
-       }
-
-       for _, mentioned := range contextInfo.GetMentionedJID() {
-               jid, err := types.ParseJID(mentioned)
-               switch {
-               case err != nil:
-               case slices.Contains(own, jid.User):
-                       message.Mention = true
-                       text = strings.ReplaceAll(text, "@"+jid.User, "@you")
-               default:
-                       text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, ""))
-               }
-       }
-
-       participant, err := types.ParseJID(contextInfo.GetParticipant())
-       message.Reply = err == nil && slices.Contains(own, participant.User)
-
-       message.Text = whatsappRedact(own, text)
-       chatJID := info.Chat.ToNonAD()
-       chat := WhatsAppChat{
-               ID:    chatJID.String(),
-               Group: chatJID.Server == types.GroupServer,
-       }
-
-       if !chat.Group {
-               push := info.PushName
-               if message.Mine {
-                       push = ""
-               }
-
-               chat.Name = whatsappContactName(ctx, device, chatJID, push)
-       }
-
-       return chat, message, true
-}
-
 // whatsappContactName returns the name of the person jid: the full name, the
 // first name, the business name, or the push name of the contact in the session
 // of device, then push, the push name of a message, then the phone number. A
 // whatsappContactName returns the name of the person jid: the full name, the
 // first name, the business name, or the push name of the contact in the session
 // of device, then push, the push name of a message, then the phone number. A
@@ -1355,65 +917,6 @@ func whatsappContactName(ctx context.Context, device *store.Device, jid types.JI
        }
 }
 
        }
 }
 
-// whatsappFold returns text in lower case with no accents and no space at its
-// ends, so that names match with no regard to case and accents.
-func whatsappFold(text string) string {
-       folded, _, err := transform.String(transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC), text)
-       if err != nil {
-               folded = text
-       }
-
-       return strings.ToLower(strings.TrimSpace(folded))
-}
-
-// whatsappFindAnswer returns the answer of Find for name with matches. Without
-// groups, the answer says that it has no groups.
-func whatsappFindAnswer(name string, matches []WhatsAppMatch, groups bool) string {
-       slices.SortFunc(matches, func(a, b WhatsAppMatch) int {
-               return cmp.Or(strings.Compare(whatsappFold(a.name), whatsappFold(b.name)), strings.Compare(a.id, b.id))
-       })
-
-       var lines []string
-       for _, match := range matches[:min(len(matches), whatsappFindMatches)] {
-               lines = append(lines, match.id+" | "+match.name+" | "+match.kind)
-       }
-
-       if len(matches) == 0 {
-               lines = append(lines, "No contact or group has a name with \""+name+"\".")
-       }
-
-       if len(matches) > whatsappFindMatches {
-               lines = append(lines, fmt.Sprintf("%d more: give a longer part of the name.", len(matches)-whatsappFindMatches))
-       }
-
-       if !groups {
-               lines = append(lines, "The groups are not in this answer, because WhatsApp is disconnected.")
-       }
-
-       return strings.Join(lines, "\n")
-}
-
-// whatsappSendCheck returns the JID of chat, and an error when id, chat, or
-// text cannot go to a send. The chat is a private chat or a group, and not the
-// chat of the user with itself.
-func whatsappSendCheck(id, chat, text string, own []string) (types.JID, error) {
-       jid, err := types.ParseJID(chat)
-       switch {
-       case strings.TrimSpace(id) == "" || len(id) > whatsappSendIDBytes:
-               return types.EmptyJID, fmt.Errorf("the id must have 1 to %d bytes", whatsappSendIDBytes)
-       case strings.TrimSpace(text) == "" || len(text) > whatsappSendBytes:
-               return types.EmptyJID, fmt.Errorf("the text must have 1 to %d bytes", whatsappSendBytes)
-       case err != nil || jid.User == "" || jid != jid.ToNonAD() || jid.String() != chat:
-               return types.EmptyJID, fmt.Errorf("%q is not the ID of a WhatsApp chat: find the ID with whatsapp_find", chat)
-       case jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer && jid.Server != types.GroupServer:
-               return types.EmptyJID, fmt.Errorf("%q is not a private chat or a group", chat)
-       case slices.Contains(own, jid.User):
-               return types.EmptyJID, errors.New("this is the chat of the user with itself: send to another chat")
-       }
-
-       return jid, nil
-}
-
 // whatsappOwnIDs returns the own number and the own LID that are not empty.
 func whatsappOwnIDs(ids ...string) []string {
        var own []string
 // whatsappOwnIDs returns the own number and the own LID that are not empty.
 func whatsappOwnIDs(ids ...string) []string {
        var own []string