+ 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 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()
+
+ if ctx.Err() != nil || !w.linked || !w.retrying || w.closed {
+ w.mutex.Unlock()
+
+ return false
+ }
+
+ if w.client == nil {
+ w.client = w.newClient(w.device, w.handle)
+ }
+
+ client := w.client
+ w.mutex.Unlock()
+
+ err := client.ConnectContext(ctx)
+
+ w.mutex.Lock()
+
+ 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
+}
+
+// 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)
+ })
+ }
+
+ w.connected = false
+
+ return first
+}
+
+// close ends the connection and the timer of the notice, and stops all notices.
+func (w *WhatsApp) close() {
+ w.mutex.Lock()
+
+ w.closed = true
+ if w.timer != nil {
+ w.timer.Stop()
+ }
+
+ client := w.client
+ w.client = nil
+ w.mutex.Unlock()
+
+ w.inbox.close()
+
+ 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
+ }
+
+ w.logger.Error("logged out by the phone, run /whatsapp pair PHONE")
+
+ err := w.inbox.deliver(ctx, l, w.link(), true)
+ if err != nil {
+ return err
+ }
+
+ err = w.removeSession()
+ if err != nil {
+ w.logger.Error("session delete failed", "err", err)
+ }
+
+ err = l.note("WhatsApp was unlinked from the phone.")
+ if err != nil {
+ w.logger.Error("chat note failed", "err", err)
+ }
+
+ w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
+
+ return nil
+}
+
+// unlink logs the open batches, logs out the linked device, and deletes the
+// session. When the logout call fails, as it does while WhatsApp is
+// disconnected, the session is deleted all the same, and answer tells the user
+// to remove the device in the phone. Its error is one of the log or the state
+// 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.")
+
+ return nil
+ }
+
+ err := w.inbox.deliver(ctx, l, w.link(), true)
+ if err != nil {
+ return err
+ }
+
+ var logoutErr error
+
+ if client != nil {
+ ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTimeout)
+ defer cancel()
+
+ logoutErr = client.Logout(ctx)
+ }
+
+ err = w.removeSession()
+ if err != nil {
+ answer("The unlink failed: " + w.redact(err.Error()))
+
+ return nil
+ }
+
+ w.logger.Info("unlinked")
+
+ err = l.note("WhatsApp is unlinked.")
+ if err != nil {
+ 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.")
+
+ return nil
+ }
+
+ answer("WhatsApp is unlinked.")
+
+ return nil
+}
+
+// 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()
+
+ 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()
+ }
+
+ w.noticed = false
+ container := w.container
+ w.container = nil
+ w.device = nil
+ w.mutex.Unlock()
+
+ w.inbox.stopCatchup()
+
+ if client != nil {
+ client.Disconnect()
+ }
+
+ var err error
+
+ if container != nil {
+ err = container.Close()
+ }
+
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ removeErr := os.Remove(w.sessionPath() + suffix)
+ if !errors.Is(removeErr, os.ErrNotExist) {
+ err = errors.Join(err, removeErr)
+ }
+ }
+
+ return err
+}
+
+// pair links a new device to the account of phone with a pairing code. It gives
+// the steps and the code to answer, and then the end of the pairing: the linked
+// number, an expired code, or a failure. A pairing that a new one ends gives no
+// answer. A link notes "WhatsApp is linked." in the log of l, with no number.
+// The client of a pairing that does not link is disconnected.
+//
+// ctx limits the pairing, but the connection uses runCtx, the context of Run.
+// The library keeps the context of the connection for its keep-alive, its event
+// handler, and its reconnections, and after the link the server ends the stream
+// and the library connects again. With ctx, the end of the pairing would stop
+// that reconnection, and the linked device would never connect.
+func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string, answer func(text string)) {
+ device, err := w.newDevice(ctx)
+ if err != nil {
+ answer("The pairing failed: " + err.Error())
+
+ return
+ }
+
+ client := w.newClient(device, w.handle)
+ linked := false
+ defer func() {
+ if !linked {
+ client.Disconnect()
+ }
+ }()
+
+ qr, err := client.GetQRChannel(ctx)
+ if err == nil {
+ err = client.ConnectContext(runCtx)
+ }
+
+ if err != nil {
+ answer("The connection to WhatsApp failed: " + err.Error())
+
+ return
+ }
+
+ select {
+ case item, open := <-qr:
+ if !open || item.Event != whatsmeow.QRChannelEventCode {
+ answer("WhatsApp did not start the pairing. Try again later.")
+
+ return
+ }
+ case <-ctx.Done():
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ answer("The connection to WhatsApp did not start the pairing in time. Try again later.")
+ }
+
+ return
+ }
+
+ // 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.")
+
+ return
+ }
+
+ answer(whatsappPairSteps)
+ answer(code)
+
+ for {
+ var item whatsmeow.QRChannelItem
+
+ open := true
+ select {
+ case item, open = <-qr:
+ case <-ctx.Done():
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ answer(whatsappPairExpired)
+ }
+
+ return
+ }
+
+ switch {
+ case !open || item.Event == whatsmeow.QRChannelTimeout.Event:
+ answer(whatsappPairExpired)
+
+ return
+ case item.Event == whatsmeow.QRChannelEventCode:
+ continue
+ case item.Event == whatsmeow.QRChannelSuccess.Event:
+ linked = true
+
+ w.linkedBy(client)
+ w.logger.Info("linked")
+
+ err = l.note("WhatsApp is linked.")
+ if err != nil {
+ w.logger.Error("chat note failed", "err", err)
+ }
+
+ answer("WhatsApp is linked to +" + phone + ".")
+
+ return
+ default:
+ answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.")
+
+ return
+ }
+ }
+}
+
+// newDevice returns a new device in the session database. It makes the
+// directory and the file of the session first, with their modes, when they do
+// not exist.
+func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) {
+ w.sessionMutex.Lock()
+ defer w.sessionMutex.Unlock()
+
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ if w.container == nil {
+ err := os.MkdirAll(w.dir, 0o700)
+ if err != nil {
+ return nil, fmt.Errorf("create WhatsApp directory: %w", err)
+ }
+
+ file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("create WhatsApp session: %w", err)
+ }
+
+ err = file.Close()
+ if err != nil {
+ return nil, fmt.Errorf("create WhatsApp session: %w", err)
+ }
+
+ err = w.openSession(ctx)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ w.device = w.container.NewDevice()
+
+ return w.device, nil
+}
+
+// linkedBy makes client the client of the linked device.
+func (w *WhatsApp) linkedBy(client WhatsAppClient) {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ w.client = client
+ w.linked = true
+}
+
+// handle takes one event of the library and tells if it was handled. A 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()
+
+ ctx := w.ctx
+ w.mutex.Unlock()
+
+ 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)
+ }
+
+ return true
+ case *events.JoinedGroup:
+ w.groups.set(event.JID.String(), event.Name)
+
+ return true
+ case *events.OfflineSyncCompleted:
+ w.inbox.endCatchup()
+
+ return true
+ case *events.Connected:
+ w.inbox.startCatchup()
+ }
+
+ var notice, line string
+
+ level := slog.LevelInfo
+
+ var attrs []any
+
+ w.mutex.Lock()
+
+ switch event := event.(type) {
+ case *events.PairSuccess:
+ w.own = whatsappOwnIDs(event.ID.User, event.LID.User)