+// openSession opens the session database and its first device. The caller holds
+// the mutex, or no other goroutine uses w yet.
+func (w *WhatsApp) openSession(ctx context.Context) error {
+ address, err := whatsappSessionAddress(w.sessionPath())
+ if err != nil {
+ return err
+ }
+
+ container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
+ if err != nil {
+ return fmt.Errorf("open WhatsApp session: %w", err)
+ }
+
+ device, err := container.GetFirstDevice(ctx)
+ if err != nil {
+ return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close())
+ }
+
+ w.container = container
+ w.device = device
+
+ return nil
+}
+
+func (w *WhatsApp) isLinked() bool {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ return w.linked
+}
+
+// connect makes the client of the linked device and connects it.
+func (w *WhatsApp) connect(ctx context.Context) {
+ w.mutex.Lock()
+ w.client = w.newClient(w.device, w.handle)
+ client := w.client
+ w.mutex.Unlock()
+
+ err := client.ConnectContext(ctx)
+ if err != nil {
+ w.logger.Printf("connect: %s", w.redact(err.Error()))
+ }
+}
+
+// close ends the connection and the timer of the notice, and stops all notices.
+func (w *WhatsApp) close() {
+ w.mutex.Lock()
+ w.closed = true
+ if w.timer != nil {
+ w.timer.Stop()
+ }
+
+ 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.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.deliver(ctx, l, true)
+ if err != nil {
+ return err
+ }
+
+ var logoutErr error
+ if client != nil {
+ ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTime)
+ defer cancel()
+
+ logoutErr = client.Logout(ctx)
+ }
+
+ err = w.removeSession()
+ if err != nil {
+ answer("The unlink failed: " + w.redact(err.Error()))
+ return nil
+ }
+
+ err = l.note("WhatsApp is unlinked.")
+ if err != nil {
+ w.logger.Printf("note the unlink: %v", err)
+ }
+
+ if client == nil || logoutErr != nil {
+ answer("WhatsApp is unlinked here, but the phone did not get the logout. In WhatsApp on your phone, open Linked devices and remove the device.")
+ return 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
+ default:
+ _, err = w.state.CloseDue(time.Now())
+ }
+
+ if err != nil {
+ return err
+ }
+
+ var turns []Message
+ for _, batch := range w.state.Closed() {
+ if batch.Chat.Group && batch.Chat.Name == "" {
+ batch.Chat.Name = w.groupName(ctx, batch.Chat.ID)
+ }
+
+ message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "")
+ if err != nil {
+ return fmt.Errorf("log a WhatsApp batch: %w", err)
+ }
+
+ // A stop of the daemon during the flush is no failure: the
+ // batch stays in the state file and repeats once at the next
+ // start.
+ err = l.Flush(ctx)
+ if ctx.Err() != nil {
+ return nil
+ }
+
+ if err != nil {
+ return fmt.Errorf("log a WhatsApp batch: %w", err)
+ }
+
+ err = w.state.Remove(batch.Number)
+ if err != nil {
+ return err
+ }
+
+ if batch.Turn() {
+ turns = append(turns, message)
+ }
+ }
+
+ l.Wake(turns...)
+
+ return nil
+}
+
+// groupName returns the name of the group id, or whatsappUnknownGroup. A group
+// that is not in the names of the groups reads the groups from WhatsApp again,
+// at most once in whatsappGroupsRefresh.
+func (w *WhatsApp) groupName(ctx context.Context, id string) string {
+ w.mutex.Lock()
+ name, found := w.groupNames[id]
+ fresh := time.Since(w.groupsAt) < whatsappGroupsRefresh
+ w.mutex.Unlock()
+
+ if !found && !fresh {
+ w.readGroups(ctx)
+
+ w.mutex.Lock()
+ name, found = w.groupNames[id]
+ w.mutex.Unlock()
+ }
+
+ if !found || name == "" {
+ return whatsappUnknownGroup
+ }
+
+ return name
+}
+
+// readGroups reads the names of the groups of the account from WhatsApp, and
+// tells if it could. They stay in memory only.
+func (w *WhatsApp) readGroups(ctx context.Context) bool {
+ w.mutex.Lock()
+ client := w.client
+ w.mutex.Unlock()
+
+ if client == nil {
+ return false
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, whatsappLookupTime)
+ defer cancel()
+
+ groups, err := client.GetJoinedGroups(ctx)
+ if err != nil {
+ w.logger.Printf("read the groups: %s", w.redact(err.Error()))
+ }
+
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ w.groupsAt = time.Now()
+ if err != nil {
+ return false
+ }
+
+ w.groupsRead = w.groupsAt
+ for _, group := range groups {
+ w.groupNames[group.JID.String()] = group.Name
+ }
+
+ return true
+}
+
+// Find returns the contacts and the groups whose name contains name, with no
+// regard to case and accents, as lines "ID | name | contact" or "ID | name |
+// group", sorted by name. It gives at most whatsappFindMatches lines and the
+// number of the others. The contacts come from the session database, and the
+// groups from WhatsApp, read again after whatsappGroupsAge. When the groups
+// cannot be read and none are known, the answer says so. Before the names
+// arrive from the phone, it says that they are not ready.
+func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) {
+ w.mutex.Lock()
+ linked := w.linked
+ device := w.device
+ own := slices.Clone(w.own)
+ stale := time.Since(w.groupsRead) >= whatsappGroupsAge
+ w.mutex.Unlock()
+
+ if !linked {
+ return whatsappNotLinked, nil
+ }
+
+ query := whatsappFold(name)
+ if query == "" {
+ return "", errors.New("name is empty: give a part of the name of the contact or the group")
+ }
+
+ contacts, err := device.Contacts.GetAllContacts(ctx)
+ if err != nil {
+ return "", fmt.Errorf("read the contacts: %s", w.redact(err.Error()))
+ }
+
+ read := !stale || w.readGroups(ctx)
+ w.mutex.Lock()
+ groups := maps.Clone(w.groupNames)
+ w.mutex.Unlock()
+
+ if len(contacts) == 0 && len(groups) == 0 {
+ if !read {
+ return "The names are not known yet, and WhatsApp is disconnected, so the groups cannot be read. Try again later.", nil
+ }
+
+ return whatsappNamesNotReady, nil
+ }
+
+ var matches []WhatsAppMatch
+ for jid, contact := range contacts {
+ names := []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName}
+ shown := slices.IndexFunc(names, func(name string) bool {
+ return name != ""
+ })
+
+ if slices.Contains(own, jid.User) || shown < 0 || !slices.ContainsFunc(names, func(name string) bool {
+ return strings.Contains(whatsappFold(name), query)
+ }) {
+ continue
+ }
+
+ matches = append(matches, WhatsAppMatch{
+ id: jid.ToNonAD().String(),
+ name: names[shown],
+ kind: "contact",
+ })
+ }
+
+ for id, group := range groups {
+ if strings.Contains(whatsappFold(group), query) {
+ matches = append(matches, WhatsAppMatch{
+ id: id,
+ name: group,
+ kind: "group",
+ })
+ }
+ }
+
+ return whatsappFindAnswer(name, matches, read || len(groups) > 0), nil
+}
+
+// removeSession ends the connection, closes the session database, and deletes
+// its files.
+func (w *WhatsApp) removeSession() error {
+ 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
+}
+
+// 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
+ }
+
+ code, err := client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
+ if err != nil {
+ answer("WhatsApp refused the pairing: " + err.Error() + ". Check the phone number and run /whatsapp pair PHONE again.")
+ return
+ }
+
+ answer(whatsappPairSteps)
+ answer(code)
+
+ for {
+ var item whatsmeow.QRChannelItem
+ open := true
+ select {
+ case item, open = <-qr:
+ case <-ctx.Done():
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ answer(whatsappPairExpired)
+ }
+
+ return
+ }
+
+ switch {
+ case !open || item.Event == whatsmeow.QRChannelTimeout.Event:
+ answer(whatsappPairExpired)
+ return
+ case item.Event == whatsmeow.QRChannelEventCode:
+ continue
+ case item.Event == whatsmeow.QRChannelSuccess.Event:
+ linked = true
+ w.linkedBy(client)
+
+ err = l.note("WhatsApp is linked.")
+ if err != nil {
+ w.logger.Printf("note the link: %v", err)
+ }
+
+ answer("WhatsApp is linked to +" + phone + ".")
+
+ return
+ default:
+ answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.")
+ return
+ }
+ }
+}
+
+// newDevice returns a new device in the session database. It makes the
+// directory and the file of the session first, with their modes, when they do
+// not exist.
+func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ if w.container == nil {
+ err := os.MkdirAll(w.dir, 0o700)
+ if err != nil {
+ return nil, fmt.Errorf("create WhatsApp directory: %w", err)
+ }
+
+ file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("create WhatsApp session: %w", err)
+ }
+
+ err = file.Close()
+ if err != nil {
+ return nil, fmt.Errorf("create WhatsApp session: %w", err)
+ }
+
+ err = w.openSession(ctx)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ w.device = w.container.NewDevice()
+
+ return w.device, nil
+}
+
+// linkedBy makes client the client of the linked device.
+func (w *WhatsApp) linkedBy(client WhatsAppClient) {