]> git.giacomolli.uk - onechat/blobdiff - whatsapp.go
Find WhatsApp contacts and groups
[onechat] / whatsapp.go
index 20143a9fee0c6727776e4985ed0051ebeb72c595..0e71e98c2770c3113a20e58624c505be87897aa0 100644 (file)
@@ -1,10 +1,12 @@
 package main
 
 import (
 package main
 
 import (
+       "cmp"
        "context"
        "errors"
        "fmt"
        "log"
        "context"
        "errors"
        "fmt"
        "log"
+       "maps"
        "net/url"
        "os"
        "path/filepath"
        "net/url"
        "os"
        "path/filepath"
@@ -12,6 +14,7 @@ import (
        "strings"
        "sync"
        "time"
        "strings"
        "sync"
        "time"
+       "unicode"
 
        "go.mau.fi/whatsmeow"
        "go.mau.fi/whatsmeow/store"
 
        "go.mau.fi/whatsmeow"
        "go.mau.fi/whatsmeow/store"
@@ -19,6 +22,9 @@ import (
        "go.mau.fi/whatsmeow/types"
        "go.mau.fi/whatsmeow/types/events"
        waLog "go.mau.fi/whatsmeow/util/log"
        "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.
 
        // The session store of the library opens SQLite with this pure Go
        // driver.
@@ -81,6 +87,24 @@ const (
 
        // whatsappUnknownGroup is the name of a group whose name is not known.
        whatsappUnknownGroup = "A group with no known name"
 
        // 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."
+
+       // 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
@@ -102,16 +126,17 @@ type WhatsAppClient interface {
 // the client of a device with its event handler.
 func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *log.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
        w := &WhatsApp{
 // the client of a device with its event handler.
 func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *log.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
        w := &WhatsApp{
-               dir:         filepath.Join(dir, whatsappDirName),
-               clock:       clock,
-               logger:      logger,
-               newClient:   newClient,
-               noticeDelay: whatsappNoticeDelay,
-               requests:    make(chan WhatsAppRequest, whatsappRequests),
-               logouts:     make(chan struct{}, 1),
-               wake:        make(chan struct{}, 1),
-               failures:    make(chan error, 1),
-               groupNames:  make(map[string]string),
+               dir:          filepath.Join(dir, whatsappDirName),
+               clock:        clock,
+               logger:       logger,
+               newClient:    newClient,
+               noticeDelay:  whatsappNoticeDelay,
+               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),
        }
 
        var err error
        }
 
        var err error
@@ -140,6 +165,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
 
        if w.device.ID != nil {
                w.linked = true
 
        if w.device.ID != nil {
                w.linked = true
+               w.catchingUp = true
                w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
        }
 
                w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
        }
 
@@ -161,11 +187,18 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
 //
 // The text messages go into batches, one chat at a time, as WhatsAppBatches
 // says. Run logs each closed batch as a work message that starts with
 //
 // The text messages go into batches, one chat at a time, as WhatsAppBatches
 // 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. 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.
+// [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
 //
 // 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
@@ -175,16 +208,17 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
 // log, and those of private chats hold phone numbers, because the agent needs
 // them to send.
 type WhatsApp struct {
 // log, and those of private chats hold phone numbers, because the agent needs
 // them to send.
 type WhatsApp struct {
-       dir         string
-       clock       func() time.Time
-       logger      *log.Logger
-       newClient   func(device *store.Device, handle func(event any) bool) WhatsAppClient
-       noticeDelay time.Duration
-       requests    chan WhatsAppRequest
-       logouts     chan struct{}
-       wake        chan struct{}
-       failures    chan error
-       batches     *WhatsAppBatches
+       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
+       batches      *WhatsAppBatches
 
        mutex          sync.Mutex
        container      *sqlstore.Container
 
        mutex          sync.Mutex
        container      *sqlstore.Container
@@ -202,6 +236,11 @@ type WhatsApp struct {
        closed         bool
        groupNames     map[string]string
        groupsAt       time.Time
        closed         bool
        groupNames     map[string]string
        groupsAt       time.Time
+       groupsRead     time.Time
+       catchingUp     bool
+       caughtUp       bool
+       catchups       int
+       catchupTimer   *time.Timer
 }
 
 // Run connects a linked device, logs the closed batches in l, runs the pair and
 }
 
 // Run connects a linked device, logs the closed batches in l, runs the pair and
@@ -230,7 +269,7 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err
        }()
 
        for {
        }()
 
        for {
-               err := w.deliver(ctx, l)
+               err := w.deliver(ctx, l, false)
                if err != nil {
                        return err
                }
                if err != nil {
                        return err
                }
@@ -406,6 +445,10 @@ func (w *WhatsApp) close() {
                w.timer.Stop()
        }
 
                w.timer.Stop()
        }
 
+       if w.catchupTimer != nil {
+               w.catchupTimer.Stop()
+       }
+
        client := w.client
        w.client = nil
        w.mutex.Unlock()
        client := w.client
        w.client = nil
        w.mutex.Unlock()
@@ -422,7 +465,7 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
                return nil
        }
 
                return nil
        }
 
-       err := w.deliverAll(ctx, l)
+       err := w.deliver(ctx, l, true)
        if err != nil {
                return err
        }
        if err != nil {
                return err
        }
@@ -458,7 +501,7 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
                return nil
        }
 
                return nil
        }
 
-       err := w.deliverAll(ctx, l)
+       err := w.deliver(ctx, l, true)
        if err != nil {
                return err
        }
        if err != nil {
                return err
        }
@@ -493,21 +536,39 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
 }
 
 // deliver logs the closed batches in l in order, each as a work message of
 }
 
 // deliver logs the closed batches in l in order, each as a work message of
-// whatsappSource that wakes the loop when the batch starts 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.
-func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop) error {
-       _, err := w.batches.CloseDue(time.Now())
+// 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.batches.CloseAll()
+       case catchingUp:
+               return nil
+       default:
+               _, err = w.batches.CloseDue(time.Now())
+       }
+
        if err != nil {
                return err
        }
 
        if err != nil {
                return err
        }
 
+       var turns []Message
        for _, batch := range w.batches.Closed() {
                if batch.Chat.Group && batch.Chat.Name == "" {
                        batch.Chat.Name = w.groupName(ctx, batch.Chat.ID)
                }
 
        for _, batch := range w.batches.Closed() {
                if batch.Chat.Group && batch.Chat.Name == "" {
                        batch.Chat.Name = w.groupName(ctx, batch.Chat.ID)
                }
 
-               _, err = l.Work(whatsappSource, batch.Text(w.clock().Location()), "", batch.Turn())
+               message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "")
                if err != nil {
                        return fmt.Errorf("log a WhatsApp batch: %w", err)
                }
                if err != nil {
                        return fmt.Errorf("log a WhatsApp batch: %w", err)
                }
@@ -528,19 +589,15 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop) error {
                if err != nil {
                        return err
                }
                if err != nil {
                        return err
                }
-       }
-
-       return nil
-}
 
 
-// deliverAll closes every open batch and logs all of them.
-func (w *WhatsApp) deliverAll(ctx context.Context, l *TurnLoop) error {
-       err := w.batches.CloseAll()
-       if err != nil {
-               return err
+               if batch.Turn() {
+                       turns = append(turns, message)
+               }
        }
 
        }
 
-       return w.deliver(ctx, l)
+       l.Wake(turns...)
+
+       return nil
 }
 
 // groupName returns the name of the group id, or whatsappUnknownGroup. A group
 }
 
 // groupName returns the name of the group id, or whatsappUnknownGroup. A group
@@ -549,18 +606,35 @@ func (w *WhatsApp) deliverAll(ctx context.Context, l *TurnLoop) error {
 func (w *WhatsApp) groupName(ctx context.Context, id string) string {
        w.mutex.Lock()
        name, found := w.groupNames[id]
 func (w *WhatsApp) groupName(ctx context.Context, id string) string {
        w.mutex.Lock()
        name, found := w.groupNames[id]
-       client := w.client
        fresh := time.Since(w.groupsAt) < whatsappGroupsRefresh
        w.mutex.Unlock()
 
        fresh := time.Since(w.groupsAt) < whatsappGroupsRefresh
        w.mutex.Unlock()
 
-       if found {
-               return name
+       if !found && !fresh {
+               w.readGroups(ctx)
+
+               w.mutex.Lock()
+               name, found = w.groupNames[id]
+               w.mutex.Unlock()
        }
 
        }
 
-       if client == nil || fresh {
+       if !found || name == "" {
                return whatsappUnknownGroup
        }
 
                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()
 
        ctx, cancel := context.WithTimeout(ctx, whatsappLookupTime)
        defer cancel()
 
@@ -573,16 +647,91 @@ func (w *WhatsApp) groupName(ctx context.Context, id string) string {
        defer w.mutex.Unlock()
 
        w.groupsAt = time.Now()
        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
        }
 
        for _, group := range groups {
                w.groupNames[group.JID.String()] = group.Name
        }
 
-       name, found = w.groupNames[id]
-       if !found || name == "" {
-               return whatsappUnknownGroup
+       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
        }
 
        }
 
-       return name
+       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
 }
 
 // removeSession ends the connection, closes the session database, and deletes
@@ -594,6 +743,7 @@ func (w *WhatsApp) removeSession() error {
        w.linked = false
        w.own = nil
        w.connected = false
        w.linked = false
        w.own = nil
        w.connected = false
+       w.catchingUp = false
        if w.timer != nil {
                w.timer.Stop()
        }
        if w.timer != nil {
                w.timer.Stop()
        }
@@ -759,10 +909,11 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) {
 
 // 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
 
 // 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
-// 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.
+// 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 {
 func (w *WhatsApp) handle(event any) bool {
        message, isMessage := event.(*events.Message)
        if isMessage {
@@ -783,6 +934,16 @@ func (w *WhatsApp) handle(event any) bool {
        case *events.Connected:
                w.connected = true
                w.connectedAt = w.clock()
        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()
+               }
+
+               w.catchupTimer = time.AfterFunc(w.catchupLimit, func() {
+                       w.caughtUpBy(catchups)
+               })
                if w.timer != nil {
                        w.timer.Stop()
                }
                if w.timer != nil {
                        w.timer.Stop()
                }
@@ -802,6 +963,8 @@ func (w *WhatsApp) handle(event any) bool {
                }
 
                w.connected = false
                }
 
                w.connected = false
+       case *events.OfflineSyncCompleted:
+               w.endCatchup()
        case *events.LoggedOut:
                select {
                case w.logouts <- struct{}{}:
        case *events.LoggedOut:
                select {
                case w.logouts <- struct{}{}:
@@ -946,6 +1109,36 @@ func (w *WhatsApp) text(ctx context.Context, device *store.Device, own []string,
        return chat, message, true
 }
 
        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) {
+       w.mutex.Lock()
+       defer w.mutex.Unlock()
+
+       if w.catchups == catchups {
+               w.endCatchup()
+       }
+}
+
+// endCatchup ends a running catch-up and wakes Run, which logs all batches. The
+// caller holds the mutex.
+func (w *WhatsApp) endCatchup() {
+       if !w.catchingUp {
+               return
+       }
+
+       w.catchingUp = false
+       w.caughtUp = true
+       if w.catchupTimer != nil {
+               w.catchupTimer.Stop()
+       }
+
+       select {
+       case w.wake <- struct{}{}:
+       default:
+       }
+}
+
 // disconnectedLong gives the notice of a long disconnection, unless the
 // disconnection with the number disconnections ended.
 func (w *WhatsApp) disconnectedLong(disconnections int) {
 // disconnectedLong gives the notice of a long disconnection, unless the
 // disconnection with the number disconnections ended.
 func (w *WhatsApp) disconnectedLong(disconnections int) {
@@ -990,6 +1183,13 @@ func (w *WhatsApp) redact(text string) string {
        return text
 }
 
        return text
 }
 
+// WhatsAppMatch is one contact or group of Find.
+type WhatsAppMatch struct {
+       id   string
+       name string
+       kind 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 {
 // 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 {
@@ -1055,6 +1255,44 @@ func whatsappContactName(ctx context.Context, device *store.Device, jid types.JI
        }
 }
 
        }
 }
 
+// whatsappFold returns text in lower case with no accents and no space at its
+// ends, so that names match with no regard to case and accents.
+func whatsappFold(text string) string {
+       folded, _, err := transform.String(transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC), text)
+       if err != nil {
+               folded = text
+       }
+
+       return strings.ToLower(strings.TrimSpace(folded))
+}
+
+// whatsappFindAnswer returns the answer of Find for name with matches. Without
+// groups, the answer says that it has no groups.
+func whatsappFindAnswer(name string, matches []WhatsAppMatch, groups bool) string {
+       slices.SortFunc(matches, func(a, b WhatsAppMatch) int {
+               return cmp.Or(strings.Compare(whatsappFold(a.name), whatsappFold(b.name)), strings.Compare(a.id, b.id))
+       })
+
+       var lines []string
+       for _, match := range matches[:min(len(matches), whatsappFindMatches)] {
+               lines = append(lines, match.id+" | "+match.name+" | "+match.kind)
+       }
+
+       if len(matches) == 0 {
+               lines = append(lines, "No contact or group has a name with \""+name+"\".")
+       }
+
+       if len(matches) > whatsappFindMatches {
+               lines = append(lines, fmt.Sprintf("%d more: give a longer part of the name.", len(matches)-whatsappFindMatches))
+       }
+
+       if !groups {
+               lines = append(lines, "The groups are not in this answer, because WhatsApp is disconnected.")
+       }
+
+       return strings.Join(lines, "\n")
+}
+
 // whatsappOwnIDs returns the own number and the own LID that are not empty.
 func whatsappOwnIDs(ids ...string) []string {
        var own []string
 // whatsappOwnIDs returns the own number and the own LID that are not empty.
 func whatsappOwnIDs(ids ...string) []string {
        var own []string