]> git.giacomolli.uk - onechat/blobdiff - whatsapp.go
Add local context to chat operation failures
[onechat] / whatsapp.go
index 42b95a8b42bf08f7313f87dfc27bfc36ecf8966f..27dd3c3e88a706ee2e6a18e4ad32930c522d736c 100644 (file)
@@ -1,14 +1,12 @@
 package main
 
 import (
 package main
 
 import (
-       "cmp"
        "context"
        "crypto/sha256"
        "context"
        "crypto/sha256"
-       "encoding/hex"
+       "database/sql"
        "errors"
        "fmt"
        "errors"
        "fmt"
-       "log"
-       "maps"
+       "log/slog"
        "net/url"
        "os"
        "path/filepath"
        "net/url"
        "os"
        "path/filepath"
@@ -16,19 +14,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"
@@ -61,67 +55,31 @@ const (
        // whatsappTimeLayout is the layout of the times in the answers.
        whatsappTimeLayout = "2006-01-02 15:04"
 
        // 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
+       // whatsappPairTimeout is the longest time of a pairing. The library
+       // closes the pairing connection after about 160 seconds.
+       whatsappPairTimeout = 3 * time.Minute
 
 
-       // whatsappLogoutTime is the longest time of the logout call of an
+       // whatsappLogoutTimeout is the longest time of the logout call of an
        // unlink.
        // unlink.
-       whatsappLogoutTime = 30 * time.Second
+       whatsappLogoutTimeout = 30 * time.Second
+
+       // whatsappOutageNoticeDelay is the delay from a disconnection to its
+       // notice to the user.
+       whatsappOutageNoticeDelay = 10 * time.Minute
 
 
-       // whatsappNoticeDelay is how long a disconnection lasts before the user
-       // gets a notice.
-       whatsappNoticeDelay = 10 * time.Minute
+       // whatsappRetryDelay is the first wait after a failed starting
+       // connection.
+       whatsappRetryDelay = 2 * time.Second
+
+       // whatsappRetryLimit is the longest wait between starting connections.
+       whatsappRetryLimit = 5 * time.Minute
 
        // whatsappRequests is the number of pair and unlink commands that can
        // wait for Run.
        whatsappRequests = 8
 
 
        // whatsappRequests is the number of pair and unlink commands that can
        // 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
-
-       // 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
@@ -134,36 +92,116 @@ type WhatsAppClient interface {
        PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error)
        Logout(ctx context.Context) error
        GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error)
        PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error)
        Logout(ctx context.Context) error
        GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error)
+       Download(ctx context.Context, message whatsmeow.DownloadableMessage) ([]byte, error)
+       Upload(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error)
        GenerateMessageID() types.MessageID
        SendMessage(ctx context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error)
 }
 
        GenerateMessageID() types.MessageID
        SendMessage(ctx context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error)
 }
 
+// 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. 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 sessionMutex serializes session
+// creation, close, removal, and snapshots. It is held before mutex, and stays
+// held when removal releases mutex for client callbacks.
+//
+// ctx is the caller context until Run sets its context for library callbacks.
+// The mutex protects it, so callbacks follow daemon cancellation.
+//
+// The inbox puts 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
+// LID never go to the log of the agent or to the log of the daemon: redact
+// removes them from the errors, and a batch shows the messages of the user as
+// You and a mention of the user as @you. The IDs of the other chats go to the
+// log, and those of private chats hold phone numbers, because the agent needs
+// them to send.
+type WhatsApp struct {
+       ctx       context.Context
+       dir       string
+       clock     func() time.Time
+       logger    *slog.Logger
+       newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient
+       requests  chan WhatsAppRequest
+       logouts   chan struct{}
+       state     *WhatsAppState
+       groups    *WhatsAppGroups
+       inbox     *WhatsAppInbox
+       routes    *Routes
+
+       sessionMutex   sync.Mutex
+       mutex          sync.Mutex
+       container      *sqlstore.Container
+       device         *store.Device
+       client         WhatsAppClient
+       linked         bool
+       own            []string
+       notify         func(Event)
+       connected      bool
+       retrying       bool
+       connectedAt    time.Time
+       disconnectedAt time.Time
+       disconnections int
+       noticed        bool
+       timer          *time.Timer
+       closed         bool
+}
+
+// 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
+// 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)
+}
+
 // 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 device with its event handler.
 // 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 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) {
+func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *slog.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
        w := &WhatsApp{
        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),
+               ctx:       ctx,
+               dir:       filepath.Join(dir, whatsappDirName),
+               clock:     clock,
+               logger:    logger,
+               newClient: newClient,
+               requests:  make(chan WhatsAppRequest, whatsappRequests),
+               logouts:   make(chan struct{}, 1),
+               groups:    newWhatsAppGroups(logger),
        }
 
        var err error
        }
 
        var err error
-       w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow)
+
+       w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName))
        if err != nil {
        if err != nil {
-               return nil, err
+               return nil, fmt.Errorf("open WhatsApp state: %w", err)
        }
 
        }
 
+       w.inbox = newWhatsAppInbox(w.state, clock, w.groups, &ImageStore{dir: filepath.Join(dir, imageDirName)}, logger)
+
        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
@@ -179,153 +217,154 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
 
        err = w.openSession(ctx)
        if err != nil {
 
        err = w.openSession(ctx)
        if err != nil {
-               return nil, err
+               return nil, fmt.Errorf("open WhatsApp session: %w", err)
        }
 
        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)
        }
 
        return w, nil
 }
 
                w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
        }
 
        return w, nil
 }
 
-// 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. 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 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 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
-// LID never go to the log of the agent or to the log of the daemon: redact
-// removes them from the errors, and a batch shows the messages of the user as
-// You and a mention of the user as @you. The IDs of the other chats go to the
-// 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
+func (w *WhatsApp) sessionPath() string {
+       return filepath.Join(w.dir, whatsappSessionFileName)
+}
 
 
-       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
-       groupNames     map[string]string
-       groupsAt       time.Time
-       groupsRead     time.Time
-       catchingUp     bool
-       caughtUp       bool
-       catchups       int
-       catchupTimer   *time.Timer
+// 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 fmt.Errorf("find WhatsApp session address: %w", 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
 }
 
 // Run connects a linked device, logs the closed batches in l, 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
 }
 
 // Run connects a linked device, logs the closed batches in l, 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. Run returns the error of a write of the log or
-// of the state file, which stops the daemon, because then messages would be
-// lost.
+// failed first connection starts the same outage notice as a lost connection. A
+// timer in the select retries it with a doubled wait from 2 seconds to 5
+// minutes, without a retry goroutine or a wait that blocks commands or the
+// inbox. A connection, unlink, logout, or ctx end stops retries. The library
+// owns retries after a connection opens. Run returns the error of a write of
+// the log or of the state file, which stops the daemon, because then messages
+// would be lost.
 func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error {
        w.mutex.Lock()
 func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error {
        w.mutex.Lock()
+
+       w.ctx = ctx
        w.notify = notify
        linked := w.linked
        w.notify = notify
        linked := w.linked
+       w.retrying = linked
        w.mutex.Unlock()
 
        w.mutex.Unlock()
 
-       if linked {
-               w.connect(ctx)
+       timer := time.NewTimer(whatsappRetryDelay)
+       timer.Stop()
+       defer timer.Stop()
+
+       var retry <-chan time.Time
+
+       delay := whatsappRetryDelay
+       connect := func() {
+               retry = nil
+               if w.connect(ctx) {
+                       timer.Reset(delay)
+
+                       retry = timer.C
+                       delay = min(2*delay, whatsappRetryLimit)
+               }
+       }
+
+       stopRetry := func() {
+               timer.Stop()
+
+               retry = nil
+
+               w.mutex.Lock()
+
+               w.retrying = false
+               w.mutex.Unlock()
        }
 
        var pairing sync.WaitGroup
        }
 
        var pairing sync.WaitGroup
-       cancel := func() {}
+
+       cancel := func() {
+       }
+
        defer func() {
                cancel()
                pairing.Wait()
                w.close()
        }()
 
        defer func() {
                cancel()
                pairing.Wait()
                w.close()
        }()
 
+       if linked {
+               connect()
+       }
+
        for {
        for {
-               err := w.deliver(ctx, l, false)
+               err := w.inbox.deliver(ctx, l, w.link(), false)
                if err != nil {
                if err != nil {
-                       return err
-               }
-
-               var due <-chan time.Time
-               wait, open := w.state.Wait(time.Now())
-               if open {
-                       due = time.After(wait)
+                       return fmt.Errorf("deliver WhatsApp inbox: %w", err)
                }
 
                select {
                case <-ctx.Done():
                        return nil
                }
 
                select {
                case <-ctx.Done():
                        return nil
-               case err = <-w.failures:
-                       return err
-               case <-w.wake:
-               case <-due:
+               case err = <-w.inbox.failures:
+                       if err != nil {
+                               return fmt.Errorf("receive WhatsApp inbox: %w", err)
+                       }
+
+                       return nil
+               case <-w.inbox.wake:
+               case <-w.inbox.due():
+               case <-retry:
+                       connect()
                case <-w.logouts:
                case <-w.logouts:
+                       stopRetry()
+
                        err = w.loggedOut(ctx, l)
                        if err != nil {
                        err = w.loggedOut(ctx, l)
                        if err != nil {
-                               return err
+                               return fmt.Errorf("handle WhatsApp logout: %w", err)
                        }
                case request := <-w.requests:
                        cancel()
                        pairing.Wait()
                        }
                case request := <-w.requests:
                        cancel()
                        pairing.Wait()
-                       cancel = func() {}
+
+                       cancel = func() {
+                       }
 
                        switch {
                        case request.unlink:
 
                        switch {
                        case request.unlink:
+                               stopRetry()
+
                                err = w.unlink(ctx, l, request.answer)
                                if err != nil {
                                err = w.unlink(ctx, l, request.answer)
                                if err != nil {
-                                       return err
+                                       return fmt.Errorf("unlink WhatsApp: %w", err)
                                }
                        case w.isLinked():
                                request.answer("WhatsApp is linked already. Run /whatsapp unlink first.")
                        default:
                                }
                        case w.isLinked():
                                request.answer("WhatsApp is linked already. Run /whatsapp unlink first.")
                        default:
-                               pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTime)
+                               pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTimeout)
                                cancel = cancelPair
                                pairing.Go(func() {
                                        defer cancelPair()
                                cancel = cancelPair
                                pairing.Go(func() {
                                        defer cancelPair()
@@ -337,166 +376,106 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err
        }
 }
 
        }
 }
 
-// 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)) {
-       fields := strings.Fields(args)
-       request := WhatsAppRequest{
-               answer: answer,
-       }
+// connect tries the starting connection of the linked device with one client.
+// It returns whether Run must retry. A failed call starts the shared outage
+// state and logs its redacted cause once, because a returned ConnectContext
+// error does not emit Disconnected. Connected logs recovery after
+// authentication.
+func (w *WhatsApp) connect(ctx context.Context) bool {
+       w.mutex.Lock()
 
 
-       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
-               }
+       if ctx.Err() != nil || !w.linked || !w.retrying || w.closed {
+               w.mutex.Unlock()
 
 
-               request.phone = phone
-       default:
-               answer(whatsappUsage)
-               return
+               return false
        }
 
        }
 
-       select {
-       case w.requests <- request:
-       default:
-               answer("Too many WhatsApp commands wait. Try again in a minute.")
+       if w.client == nil {
+               w.client = w.newClient(w.device, w.handle)
        }
        }
-}
 
 
-// Status returns the state of the link: not linked, linked and connected, or
-// linked and disconnected, with the times of the connection. It never holds the
-// phone number.
-func (w *WhatsApp) Status() string {
+       client := w.client
+       w.mutex.Unlock()
+
+       err := client.ConnectContext(ctx)
+
        w.mutex.Lock()
        w.mutex.Lock()
-       defer w.mutex.Unlock()
 
 
-       switch {
-       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():
-               return "WhatsApp is linked and not connected yet."
-       case w.connectedAt.IsZero():
-               return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "."
-       default:
-               return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "."
+       if err == nil || ctx.Err() != nil || !w.retrying || !w.linked {
+               w.retrying = false
+               w.mutex.Unlock()
+
+               return false
+       }
+
+       first := w.startOutage()
+       cause := whatsappRedact(w.own, err.Error())
+       w.mutex.Unlock()
+
+       if first {
+               w.logger.Warn("first connect failed, retrying", "err", cause)
        }
        }
+
+       return true
 }
 
 }
 
-// Close closes the session database.
-func (w *WhatsApp) Close() error {
+// close ends the connection and the timer of the notice, and stops all notices.
+func (w *WhatsApp) close() {
        w.mutex.Lock()
        w.mutex.Lock()
-       defer w.mutex.Unlock()
 
 
-       if w.container == nil {
-               return nil
+       w.closed = true
+       if w.timer != nil {
+               w.timer.Stop()
        }
 
        }
 
-       err := w.container.Close()
-       w.container = nil
+       client := w.client
+       w.client = nil
+       w.mutex.Unlock()
 
 
-       return err
+       w.inbox.close()
+
+       if client != nil {
+               client.Disconnect()
+       }
 }
 
 }
 
-func (w *WhatsApp) sessionPath() string {
-       return filepath.Join(w.dir, whatsappSessionFileName)
+// 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),
+       }
 }
 
 }
 
-// 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())
+// loggedOut logs the open batches, 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(ctx context.Context, l *TurnLoop) error {
+       if !w.isLinked() {
+               return nil
+       }
+
+       w.logger.Error("logged out by the phone")
+
+       err := w.inbox.deliver(ctx, l, w.link(), true)
        if err != nil {
                return err
        }
 
        if err != nil {
                return err
        }
 
-       container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
+       err = w.removeSession()
        if err != nil {
        if err != nil {
-               return fmt.Errorf("open WhatsApp session: %w", err)
+               w.logger.Error("session delete failed", "err", err)
        }
 
        }
 
-       device, err := container.GetFirstDevice(ctx)
+       err = l.note("WhatsApp was unlinked from the phone.")
        if err != nil {
        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()
-       }
-
-       if w.catchupTimer != nil {
-               w.catchupTimer.Stop()
-       }
-
-       client := w.client
-       w.client = nil
-       w.mutex.Unlock()
-
-       if client != nil {
-               client.Disconnect()
-       }
-}
-
-// loggedOut logs the open batches, 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(ctx context.Context, l *TurnLoop) error {
-       if !w.isLinked() {
-               return nil
-       }
-
-       err := w.deliver(ctx, l, true)
-       if err != nil {
-               return err
-       }
-
-       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.logger.Error("chat note failed", "err", 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.")
@@ -511,23 +490,26 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
 // file.
 func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) error {
        w.mutex.Lock()
 // file.
 func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) error {
        w.mutex.Lock()
+
        linked := w.linked
        client := w.client
        w.mutex.Unlock()
 
        if !linked {
                answer("WhatsApp is not linked.")
        linked := w.linked
        client := w.client
        w.mutex.Unlock()
 
        if !linked {
                answer("WhatsApp is not linked.")
+
                return nil
        }
 
                return nil
        }
 
-       err := w.deliver(ctx, l, true)
+       err := w.inbox.deliver(ctx, l, w.link(), true)
        if err != nil {
                return err
        }
 
        var logoutErr error
        if err != nil {
                return err
        }
 
        var logoutErr error
+
        if client != nil {
        if client != nil {
-               ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTime)
+               ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTimeout)
                defer cancel()
 
                logoutErr = client.Logout(ctx)
                defer cancel()
 
                logoutErr = client.Logout(ctx)
@@ -536,260 +518,33 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
        err = w.removeSession()
        if err != nil {
                answer("The unlink failed: " + w.redact(err.Error()))
        err = w.removeSession()
        if err != nil {
                answer("The unlink failed: " + w.redact(err.Error()))
+
                return nil
        }
 
                return nil
        }
 
+       w.logger.Info("unlinked")
+
        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.Error("chat note failed", "err", 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.")
        }
 
        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 nil
-       }
-
-       answer("WhatsApp is unlinked.")
-
-       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
                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...)
+       answer("WhatsApp is unlinked.")
 
        return nil
 }
 
 
        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()))
-       }
-
+func (w *WhatsApp) isLinked() bool {
        w.mutex.Lock()
        defer w.mutex.Unlock()
 
        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 {
-       w.mutex.Lock()
-       client := w.client
-       w.client = nil
-       w.linked = false
-       w.own = nil
-       w.connected = false
-       w.catchingUp = 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
+       return w.linked
 }
 
 // pair links a new device to the account of phone with a pairing code. It gives
 }
 
 // pair links a new device to the account of phone with a pairing code. It gives
@@ -807,6 +562,7 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
        device, err := w.newDevice(ctx)
        if err != nil {
                answer("The pairing failed: " + err.Error())
        device, err := w.newDevice(ctx)
        if err != nil {
                answer("The pairing failed: " + err.Error())
+
                return
        }
 
                return
        }
 
@@ -825,6 +581,7 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
 
        if err != nil {
                answer("The connection to WhatsApp failed: " + err.Error())
 
        if err != nil {
                answer("The connection to WhatsApp failed: " + err.Error())
+
                return
        }
 
                return
        }
 
@@ -832,6 +589,7 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
        case item, open := <-qr:
                if !open || item.Event != whatsmeow.QRChannelEventCode {
                        answer("WhatsApp did not start the pairing. Try again later.")
        case item, open := <-qr:
                if !open || item.Event != whatsmeow.QRChannelEventCode {
                        answer("WhatsApp did not start the pairing. Try again later.")
+
                        return
                }
        case <-ctx.Done():
                        return
                }
        case <-ctx.Done():
@@ -842,9 +600,12 @@ 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.")
+
                return
        }
 
                return
        }
 
@@ -853,6 +614,7 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
 
        for {
                var item whatsmeow.QRChannelItem
 
        for {
                var item whatsmeow.QRChannelItem
+
                open := true
                select {
                case item, open = <-qr:
                open := true
                select {
                case item, open = <-qr:
@@ -867,16 +629,19 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
                switch {
                case !open || item.Event == whatsmeow.QRChannelTimeout.Event:
                        answer(whatsappPairExpired)
                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
                        return
                case item.Event == whatsmeow.QRChannelEventCode:
                        continue
                case item.Event == whatsmeow.QRChannelSuccess.Event:
                        linked = true
+
                        w.linkedBy(client)
                        w.linkedBy(client)
+                       w.logger.Info("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.Error("chat note failed", "err", err)
                        }
 
                        answer("WhatsApp is linked to +" + phone + ".")
                        }
 
                        answer("WhatsApp is linked to +" + phone + ".")
@@ -884,91 +649,76 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
                        return
                default:
                        answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.")
                        return
                default:
                        answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.")
+
                        return
                }
        }
 }
 
                        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) {
+// handle takes one event of the library and tells if it was handled. A message
+// 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 whatsappCatchupTimeout. A disconnection keeps the time of its start
+// while Run or the library tries again. After whatsappOutageNoticeDelay 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 and
+// Run between them log nothing.
+func (w *WhatsApp) handle(event any) bool {
        w.mutex.Lock()
        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)
-               }
+       ctx := w.ctx
+       w.mutex.Unlock()
 
 
-               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)
+       switch event := event.(type) {
+       case *events.Message:
+               return w.inbox.receive(ctx, w.link(), event)
+       case *events.GroupInfo:
+               if event.Name != nil {
+                       w.groups.set(event.JID.String(), event.Name.Name)
                }
 
                }
 
-               err = file.Close()
-               if err != nil {
-                       return nil, fmt.Errorf("create WhatsApp session: %w", err)
-               }
+               return true
+       case *events.JoinedGroup:
+               w.groups.set(event.JID.String(), event.Name)
 
 
-               err = w.openSession(ctx)
-               if err != nil {
-                       return nil, err
-               }
+               return true
+       case *events.OfflineSyncCompleted:
+               w.inbox.endCatchup()
+
+               return true
+       case *events.Connected:
+               w.inbox.startCatchup()
        }
 
        }
 
-       w.device = w.container.NewDevice()
+       var notice, line string
 
 
-       return w.device, nil
-}
+       level := slog.LevelInfo
 
 
-// linkedBy makes client the client of the linked device.
-func (w *WhatsApp) linkedBy(client WhatsAppClient) {
-       w.mutex.Lock()
-       defer w.mutex.Unlock()
+       var attrs []any
 
 
-       w.client = client
-       w.linked = true
-}
+       w.mutex.Lock()
 
 
-// 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.
-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.GroupInfo:
-               if event.Name != nil {
-                       w.groupNames[event.JID.String()] = event.Name.Name
-               }
-       case *events.JoinedGroup:
-               w.groupNames[event.JID.String()] = event.Name
        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 = "connected"
+               default:
+                       line = "connected again"
+                       attrs = append(attrs, "after", w.clock().Sub(w.disconnectedAt))
                }
 
                }
 
-               w.catchupTimer = time.AfterFunc(w.catchupLimit, func() {
-                       w.caughtUpBy(catchups)
-               })
+               w.connected = true
+               w.retrying = false
+               w.connectedAt = w.clock()
                if w.timer != nil {
                        w.timer.Stop()
                }
                if w.timer != nil {
                        w.timer.Stop()
                }
@@ -978,32 +728,42 @@ func (w *WhatsApp) handle(event any) bool {
                        notice = "WhatsApp is connected again."
                }
        case *events.Disconnected:
                        notice = "WhatsApp is connected again."
                }
        case *events.Disconnected:
-               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)
-                       })
+               connected := w.connected
+               if w.startOutage() {
+                       level = slog.LevelWarn
+                       line = "disconnected, retrying"
+                       if !connected {
+                               line = "first connect failed, retrying"
+                       }
                }
                }
-
-               w.connected = false
-       case *events.OfflineSyncCompleted:
-               w.endCatchup()
        case *events.LoggedOut:
        case *events.LoggedOut:
+               w.retrying = false
                select {
                case w.logouts <- struct{}{}:
                default:
                }
        case events.PermanentDisconnect:
                select {
                case w.logouts <- struct{}{}:
                default:
                }
        case events.PermanentDisconnect:
+               w.retrying = false
                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."
                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."
+                       level = slog.LevelError
+                       line = "disconnected for good, restart to retry"
+                       attrs = append(attrs, "err", whatsappRedact(w.own, event.PermanentDisconnectDescription()))
                }
        }
                }
        }
+
+       if w.closed {
+               line = ""
+       }
+
        w.mutex.Unlock()
 
        w.mutex.Unlock()
 
+       if line != "" {
+               w.logger.Log(ctx, level, line, attrs...)
+       }
+
        if notice != "" {
                w.broadcast(notice)
        }
        if notice != "" {
                w.broadcast(notice)
        }
@@ -1011,163 +771,147 @@ func (w *WhatsApp) handle(event any) bool {
        return true
 }
 
        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()
-
-       chat, message, found := w.message(ctx, event)
-       if !found {
-               return true
-       }
-
-       _, err := w.state.Add(chat, message, time.Now())
-       if err != nil {
-               select {
-               case w.failures <- err:
-               default:
-               }
-
-               return false
+// startOutage starts the shared outage time and notice timer once. The caller
+// holds the mutex. Its result tells whether this is the first failure.
+func (w *WhatsApp) startOutage() bool {
+       first := w.linked && (w.connected || w.disconnectedAt.IsZero())
+       if first {
+               w.disconnectedAt = w.clock()
+               w.disconnections++
+               disconnections := w.disconnections
+               w.timer = time.AfterFunc(whatsappOutageNoticeDelay, func() {
+                       w.disconnectedLong(disconnections)
+               })
        }
 
        }
 
-       select {
-       case w.wake <- struct{}{}:
-       default:
-       }
+       w.connected = false
 
 
-       return true
+       return first
 }
 
 }
 
-// message returns the chat and the batch message of event, or false when the
-// batches do not take it. They 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 (w *WhatsApp) message(ctx context.Context, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
+// removeSession ends the connection, closes the session database, and deletes
+// its files. The session lock covers the cleanup after mutex is released, so a
+// snapshot or a new session cannot open files that cleanup removes.
+func (w *WhatsApp) removeSession() error {
+       w.sessionMutex.Lock()
+       defer w.sessionMutex.Unlock()
+
        w.mutex.Lock()
        w.mutex.Lock()
-       device := w.device
-       own := slices.Clone(w.own)
-       w.mutex.Unlock()
 
 
-       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 w.text(ctx, device, own, event)
+       client := w.client
+       w.client = nil
+       w.linked = false
+       w.retrying = false
+       w.own = nil
+       w.connected = false
+       w.disconnectedAt = time.Time{}
+       if w.timer != nil {
+               w.timer.Stop()
        }
 
        }
 
-       return WhatsAppChat{}, WhatsAppMessage{}, false
-}
+       w.noticed = false
+       container := w.container
+       w.container = nil
+       w.device = nil
+       w.mutex.Unlock()
 
 
-// text returns the chat and the batch message of event, a message that the
-// batches take when it has text.
-func (w *WhatsApp) text(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()
-       }
+       w.inbox.stopCatchup()
 
 
-       if strings.TrimSpace(text) == "" {
-               return WhatsAppChat{}, WhatsAppMessage{}, false
+       if client != nil {
+               client.Disconnect()
        }
 
        }
 
-       contextInfo := extended.GetContextInfo()
-       message := WhatsAppMessage{
-               ID:   info.ID,
-               Time: info.Timestamp,
-               Mine: info.IsFromMe,
-       }
+       var err error
 
 
-       if !message.Mine {
-               message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
+       if container != nil {
+               err = container.Close()
        }
 
        }
 
-       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, ""))
+       for _, suffix := range []string{"", "-wal", "-shm"} {
+               removeErr := os.Remove(w.sessionPath() + suffix)
+               if !errors.Is(removeErr, os.ErrNotExist) {
+                       err = errors.Join(err, removeErr)
                }
        }
 
                }
        }
 
-       participant, err := types.ParseJID(contextInfo.GetParticipant())
-       message.Reply = err == nil && slices.Contains(own, participant.User)
-
-       for _, id := range own {
-               text = strings.ReplaceAll(text, id, "OWN")
-       }
+       return err
+}
 
 
-       message.Text = text
-       chatJID := info.Chat.ToNonAD()
-       chat := WhatsAppChat{
-               ID:    chatJID.String(),
-               Group: chatJID.Server == types.GroupServer,
-       }
+// broadcast gives text as a notice to all clients.
+func (w *WhatsApp) broadcast(text string) {
+       w.mutex.Lock()
 
 
-       if !chat.Group {
-               push := info.PushName
-               if message.Mine {
-                       push = ""
-               }
+       notify := w.notify
+       closed := w.closed
+       w.mutex.Unlock()
 
 
-               chat.Name = whatsappContactName(ctx, device, chatJID, push)
+       if notify != nil && !closed {
+               notify(Event{
+                       Kind: "notice",
+                       Text: text,
+               })
        }
        }
-
-       return chat, message, 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) {
+// 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()
 
        w.mutex.Lock()
        defer w.mutex.Unlock()
 
-       if w.catchups == catchups {
-               w.endCatchup()
-       }
+       return whatsappRedact(w.own, text)
 }
 
 }
 
-// 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
-       }
+// 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.sessionMutex.Lock()
+       defer w.sessionMutex.Unlock()
 
 
-       w.catchingUp = false
-       w.caughtUp = true
-       if w.catchupTimer != nil {
-               w.catchupTimer.Stop()
-       }
+       w.mutex.Lock()
+       defer w.mutex.Unlock()
 
 
-       select {
-       case w.wake <- struct{}{}:
-       default:
+       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, fmt.Errorf("open new WhatsApp session: %w", 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
 }
 
 // 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()
 }
 
 // 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
        long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections
        if long {
                w.noticed = true
@@ -1181,138 +925,231 @@ func (w *WhatsApp) disconnectedLong(disconnections int) {
        }
 }
 
        }
 }
 
-// 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()
+// 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)) {
+       fields := strings.Fields(args)
+       request := WhatsAppRequest{
+               answer: answer,
+       }
 
 
-       if notify != nil && !closed {
-               notify(Event{
-                       Kind: "notice",
-                       Text: text,
-               })
+       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.")
        }
 }
 
        }
 }
 
-// redact replaces the own number and the own LID in text with OWN.
-func (w *WhatsApp) redact(text string) string {
+// Status returns the state of the link: not linked, linked and connected, or
+// linked and disconnected, with the times of the connection and the routes.
+// Route source IDs can hold the phone numbers of other chats.
+func (w *WhatsApp) Status() string {
        w.mutex.Lock()
        defer w.mutex.Unlock()
 
        w.mutex.Lock()
        defer w.mutex.Unlock()
 
-       for _, id := range w.own {
-               text = strings.ReplaceAll(text, id, "OWN")
+       routes := ""
+       if w.routes != nil {
+               listed := w.routes.List()
+               if listed != "No routes." {
+                       routes = "\nWhatsApp routes:\n" + listed
+               }
        }
 
        }
 
-       return text
+       switch {
+       case !w.linked:
+               return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it." + routes
+       case w.connected:
+               return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
+       case w.disconnectedAt.IsZero():
+               return "WhatsApp is linked and not connected yet." + routes
+       case w.connectedAt.IsZero():
+               return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "." + routes
+       default:
+               return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
+       }
 }
 
 }
 
-// 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) {
+// BackupSession writes a consistent SQLite snapshot to path while the session
+// cannot be created, closed, or removed. The source opens without creation
+// permission. No session file means no linked state to copy.
+func (w *WhatsApp) BackupSession(ctx context.Context, path string) (err error) {
+       w.sessionMutex.Lock()
+       defer w.sessionMutex.Unlock()
+
        w.mutex.Lock()
        w.mutex.Lock()
-       linked := w.linked
-       device := w.device
-       client := w.client
-       connected := w.connected
-       own := slices.Clone(w.own)
-       w.mutex.Unlock()
+       defer w.mutex.Unlock()
 
 
-       if !linked {
-               return whatsappNotLinked, nil
+       _, err = os.Stat(w.sessionPath())
+       if errors.Is(err, os.ErrNotExist) {
+               return nil
+       }
+
+       if err != nil {
+               return fmt.Errorf("read session file: %w", err)
        }
 
        }
 
-       jid, err := whatsappSendCheck(id, chat, text, own)
+       err = os.MkdirAll(filepath.Dir(path), 0o700)
        if err != nil {
        if err != nil {
-               return "", err
+               return fmt.Errorf("create session snapshot directory: %w", err)
        }
 
        }
 
-       digest := sha256.Sum256([]byte(text))
-       record := WhatsAppSend{
-               ID:     id,
-               Chat:   chat,
-               Digest: hex.EncodeToString(digest[:]),
-               State:  whatsappSending,
-               Time:   w.clock(),
+       address, err := whatsappSessionAddress(w.sessionPath())
+       if err != nil {
+               return fmt.Errorf("find session database: %w", err)
        }
 
        }
 
-       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)
+       database, err := sql.Open("sqlite", address+"&mode=rw")
        if err != nil {
        if err != nil {
-               return "", err
+               return fmt.Errorf("open session snapshot source: %w", err)
        }
 
        }
 
-       sendCtx, cancel := context.WithTimeout(ctx, whatsappSendTime)
-       defer cancel()
+       defer func() {
+               err = errors.Join(err, database.Close())
+       }()
 
 
-       response, err := client.SendMessage(sendCtx, jid, &waE2E.Message{
-               Conversation: new(text),
-       }, whatsmeow.SendRequestExtra{
-               ID: record.MessageID,
-       })
+       _, err = database.ExecContext(ctx, "VACUUM INTO ?", path)
+       if err != nil {
+               return fmt.Errorf("snapshot session database: %w", err)
+       }
 
 
-       record.State = whatsappSent
+       err = os.Chmod(path, 0o600)
        if err != nil {
        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, "")
-               }
+               return fmt.Errorf("set session snapshot mode: %w", err)
+       }
 
 
-               record.Answer = "Sent to " + name + " at " + response.Timestamp.In(w.clock().Location()).Format("15:04") + "."
+       return nil
+}
+
+// Close closes the session database.
+func (w *WhatsApp) Close() error {
+       w.sessionMutex.Lock()
+       defer w.sessionMutex.Unlock()
+
+       w.mutex.Lock()
+       defer w.mutex.Unlock()
+
+       if w.container == nil {
+               return nil
        }
 
        }
 
-       err = w.state.PutSend(record)
+       err := w.container.Close()
+       w.container = nil
+
+       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 text and an optional stored
+// image to chat, with the durable send id that whatsappSend describes.
+func (w *WhatsApp) Send(ctx context.Context, id, chat, text, image string) (string, error) {
+       return whatsappSend(ctx, w.link(), w.groups, w.state, w.inbox.images, w.logger, w.clock, id, chat, text, image)
+}
+
+// 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.
+func whatsappSessionAddress(path string) (string, error) {
+       path, err := filepath.Abs(path)
        if err != nil {
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("find WhatsApp session: %w", err)
+       }
+
+       address := url.URL{
+               Scheme:   "file",
+               Path:     path,
+               RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
        }
 
        }
 
-       return record.Answer, nil
+       return address.String(), nil
 }
 
 }
 
-// WhatsAppMatch is one contact or group of Find.
-type WhatsAppMatch struct {
-       id   string
-       name string
-       kind string
+// whatsappRedact replaces each of the own IDs own in text with OWN.
+func whatsappRedact(own []string, text string) string {
+       for _, id := range 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)
+// 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
+}
+
+// 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
+}
+
+// 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
 }
 
 // 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.
+// as its event handler. Run owns failed starting connections of linked devices,
+// so only an unlinked device enables the initial retry of the library for
+// pairing. The library still retries a lost connection. The client acknowledges
+// a message only after handle returns true, and downloads no old 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 := whatsmeow.NewClient(device, waLog.Noop)
-       client.InitialAutoReconnect = true
+       client.InitialAutoReconnect = device.ID == nil
        client.SynchronousAck = true
        client.ManualHistorySyncDownload = true
        client.AddEventHandlerWithSuccessStatus(handle)
        client.SynchronousAck = true
        client.ManualHistorySyncDownload = true
        client.AddEventHandlerWithSuccessStatus(handle)
@@ -1362,93 +1199,12 @@ 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
-       }
+// whatsappChatHash returns a short hash of chat for the daemon log, so that a
+// log line can tell chats apart without a phone number.
+func whatsappChatHash(chat types.JID) string {
+       digest := sha256.Sum256([]byte(chat.ToNonAD().String()))
 
 
-       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
-       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.
-func whatsappSessionAddress(path string) (string, error) {
-       path, err := filepath.Abs(path)
-       if err != nil {
-               return "", fmt.Errorf("find WhatsApp session: %w", err)
-       }
-
-       address := url.URL{
-               Scheme:   "file",
-               Path:     path,
-               RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
-       }
-
-       return address.String(), nil
+       return fmt.Sprintf("%x", digest[:6])
 }
 
 // whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
 }
 
 // whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
@@ -1466,23 +1222,3 @@ func whatsappCommandArgs(line string) (string, bool) {
 
        return strings.TrimSpace(args), true
 }
 
        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
-}