]> git.giacomolli.uk - onechat/blobdiff - whatsapp.go
Find WhatsApp contacts and groups
[onechat] / whatsapp.go
index 830673478ade69fd154b734a54f6929d300e595b..0e71e98c2770c3113a20e58624c505be87897aa0 100644 (file)
@@ -1,10 +1,12 @@
 package main
 
 import (
+       "cmp"
        "context"
        "errors"
        "fmt"
        "log"
+       "maps"
        "net/url"
        "os"
        "path/filepath"
@@ -12,6 +14,7 @@ import (
        "strings"
        "sync"
        "time"
+       "unicode"
 
        "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"
+       "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.
@@ -85,6 +91,20 @@ const (
        // 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
@@ -216,6 +236,7 @@ type WhatsApp struct {
        closed         bool
        groupNames     map[string]string
        groupsAt       time.Time
+       groupsRead     time.Time
        catchingUp     bool
        caughtUp       bool
        catchups       int
@@ -585,18 +606,35 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error {
 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()
 
-       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 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()
 
@@ -609,16 +647,91 @@ func (w *WhatsApp) groupName(ctx context.Context, id string) string {
        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
        }
 
-       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
@@ -1070,6 +1183,13 @@ func (w *WhatsApp) redact(text string) string {
        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 {
@@ -1135,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