]> git.giacomolli.uk - onechat/commitdiff
Give the WhatsApp group names their own type
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 21:34:05 +0000 (18:34 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 21:34:05 +0000 (18:34 -0300)
WhatsAppGroups in whatsapp_groups.go holds the names of the groups, the
time of the last read of the groups from WhatsApp, and the time of the
last read that succeeded, under its own mutex. The batches, find, and
send use it, and the event handler of WhatsApp sets the names of the
group events in it.

WhatsAppLink is a copy of the link of the device: whether it is linked,
the device, the client, whether it is connected, and the own IDs.
WhatsApp keeps these fields under its mutex and gives a copy to each
call of the groups, so the groups read with the current client and never
keep it. No type calls another type while it holds its mutex. The
behavior does not change.

Issue: 6f69ca1

whatsapp.go
whatsapp_groups.go [new file with mode: 0644]

index 44b9dfa6557336a241348894e8f681c976ea9dc3..929ae5dd5785d20be1d6719adb7d63ff852ba7bb 100644 (file)
@@ -8,7 +8,6 @@ import (
        "errors"
        "fmt"
        "log"
        "errors"
        "fmt"
        "log"
-       "maps"
        "net/url"
        "os"
        "path/filepath"
        "net/url"
        "os"
        "path/filepath"
@@ -84,21 +83,10 @@ const (
        // session database or of the groups from WhatsApp.
        whatsappLookupTime = 10 * time.Second
 
        // 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
 
        // 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
 
        // whatsappFindMatches is the most matches of whatsapp_find.
        whatsappFindMatches = 20
 
@@ -155,7 +143,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
                logouts:      make(chan struct{}, 1),
                wake:         make(chan struct{}, 1),
                failures:     make(chan error, 1),
                logouts:      make(chan struct{}, 1),
                wake:         make(chan struct{}, 1),
                failures:     make(chan error, 1),
-               groupNames:   make(map[string]string),
+               groups:       newWhatsAppGroups(logger),
        }
 
        var err error
        }
 
        var err error
@@ -238,6 +226,7 @@ type WhatsApp struct {
        wake         chan struct{}
        failures     chan error
        state        *WhatsAppState
        wake         chan struct{}
        failures     chan error
        state        *WhatsAppState
+       groups       *WhatsAppGroups
 
        mutex          sync.Mutex
        container      *sqlstore.Container
 
        mutex          sync.Mutex
        container      *sqlstore.Container
@@ -253,9 +242,6 @@ type WhatsApp struct {
        noticed        bool
        timer          *time.Timer
        closed         bool
        noticed        bool
        timer          *time.Timer
        closed         bool
-       groupNames     map[string]string
-       groupsAt       time.Time
-       groupsRead     time.Time
        catchingUp     bool
        caughtUp       bool
        catchups       int
        catchingUp     bool
        caughtUp       bool
        catchups       int
@@ -443,6 +429,21 @@ func (w *WhatsApp) isLinked() bool {
        return w.linked
 }
 
        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 makes the client of the linked device and connects it.
 func (w *WhatsApp) connect(ctx context.Context) {
        w.mutex.Lock()
 // connect makes the client of the linked device and connects it.
 func (w *WhatsApp) connect(ctx context.Context) {
        w.mutex.Lock()
@@ -584,7 +585,7 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error {
        var turns []Message
        for _, batch := range w.state.Closed() {
                if batch.Chat.Group && batch.Chat.Name == "" {
        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)
+                       batch.Chat.Name = w.groups.name(ctx, w.link(), batch.Chat.ID)
                }
 
                message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "")
                }
 
                message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "")
@@ -619,65 +620,6 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error {
        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()))
-       }
-
-       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
 // 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
@@ -690,9 +632,10 @@ func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) {
        linked := w.linked
        device := w.device
        own := slices.Clone(w.own)
        linked := w.linked
        device := w.device
        own := slices.Clone(w.own)
-       stale := time.Since(w.groupsRead) >= whatsappGroupsAge
        w.mutex.Unlock()
 
        w.mutex.Unlock()
 
+       stale := w.groups.stale()
+
        if !linked {
                return whatsappNotLinked, nil
        }
        if !linked {
                return whatsappNotLinked, nil
        }
@@ -707,10 +650,8 @@ func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) {
                return "", fmt.Errorf("read the contacts: %s", w.redact(err.Error()))
        }
 
                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()
+       read := !stale || w.groups.read(ctx, w.link())
+       groups := w.groups.all()
 
        if len(contacts) == 0 && len(groups) == 0 {
                if !read {
 
        if len(contacts) == 0 && len(groups) == 0 {
                if !read {
@@ -940,20 +881,23 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) {
 // 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 {
 // 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.Message:
+               return w.receive(event)
        case *events.GroupInfo:
                if event.Name != nil {
        case *events.GroupInfo:
                if event.Name != nil {
-                       w.groupNames[event.JID.String()] = event.Name.Name
+                       w.groups.set(event.JID.String(), event.Name.Name)
                }
                }
+
+               return true
        case *events.JoinedGroup:
        case *events.JoinedGroup:
-               w.groupNames[event.JID.String()] = event.Name
+               w.groups.set(event.JID.String(), event.Name)
+               return true
+       }
+
+       var notice string
+       w.mutex.Lock()
+       switch event := event.(type) {
        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:
@@ -1183,7 +1127,7 @@ func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, err
                record.State = whatsappFailed
                record.Answer = "The send failed: " + w.redact(err.Error()) + "."
        } else {
                record.State = whatsappFailed
                record.Answer = "The send failed: " + w.redact(err.Error()) + "."
        } else {
-               name := w.groupName(ctx, jid.String())
+               name := w.groups.name(ctx, w.link(), jid.String())
                if jid.Server != types.GroupServer {
                        name = whatsappContactName(ctx, device, jid, "")
                }
                if jid.Server != types.GroupServer {
                        name = whatsappContactName(ctx, device, jid, "")
                }
@@ -1206,6 +1150,16 @@ type WhatsAppMatch struct {
        kind string
 }
 
        kind string
 }
 
+// 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 {
 // 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 {
diff --git a/whatsapp_groups.go b/whatsapp_groups.go
new file mode 100644 (file)
index 0000000..ab5ed61
--- /dev/null
@@ -0,0 +1,124 @@
+package main
+
+import (
+       "context"
+       "log"
+       "maps"
+       "sync"
+       "time"
+)
+
+const (
+       // 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"
+
+       // whatsappGroupsAge is how long whatsapp_find uses the groups that it
+       // read from WhatsApp.
+       whatsappGroupsAge = time.Hour
+)
+
+// newWhatsAppGroups returns the names of the groups with no names yet. logger
+// gets the failures of a read.
+func newWhatsAppGroups(logger *log.Logger) *WhatsAppGroups {
+       return &WhatsAppGroups{
+               logger: logger,
+               names:  make(map[string]string),
+       }
+}
+
+// WhatsAppGroups holds the names of the groups of the account, by the ID of the
+// group. They come from the events of the groups and from reads of the groups
+// from WhatsApp, and they stay in memory only. triedAt is the time of the last
+// read, and readAt the time of the last read that succeeded. The mutex
+// protects all fields after logger.
+type WhatsAppGroups struct {
+       logger *log.Logger
+
+       mutex   sync.Mutex
+       names   map[string]string
+       triedAt time.Time
+       readAt  time.Time
+}
+
+// name returns the name of the group id, or whatsappUnknownGroup. A group that
+// is not in the names reads the groups from WhatsApp with the client of link
+// again, at most once in whatsappGroupsRefresh.
+func (g *WhatsAppGroups) name(ctx context.Context, link WhatsAppLink, id string) string {
+       g.mutex.Lock()
+       name, found := g.names[id]
+       fresh := time.Since(g.triedAt) < whatsappGroupsRefresh
+       g.mutex.Unlock()
+
+       if !found && !fresh {
+               g.read(ctx, link)
+
+               g.mutex.Lock()
+               name, found = g.names[id]
+               g.mutex.Unlock()
+       }
+
+       if !found || name == "" {
+               return whatsappUnknownGroup
+       }
+
+       return name
+}
+
+// read reads the names of the groups of the account from WhatsApp with the
+// client of link, and tells if it could.
+func (g *WhatsAppGroups) read(ctx context.Context, link WhatsAppLink) bool {
+       if link.client == nil {
+               return false
+       }
+
+       ctx, cancel := context.WithTimeout(ctx, whatsappLookupTime)
+       defer cancel()
+
+       groups, err := link.client.GetJoinedGroups(ctx)
+       if err != nil {
+               g.logger.Printf("read the groups: %s", whatsappRedact(link.own, err.Error()))
+       }
+
+       g.mutex.Lock()
+       defer g.mutex.Unlock()
+
+       g.triedAt = time.Now()
+       if err != nil {
+               return false
+       }
+
+       g.readAt = g.triedAt
+       for _, group := range groups {
+               g.names[group.JID.String()] = group.Name
+       }
+
+       return true
+}
+
+// set makes name the name of the group id.
+func (g *WhatsAppGroups) set(id, name string) {
+       g.mutex.Lock()
+       defer g.mutex.Unlock()
+
+       g.names[id] = name
+}
+
+// stale tells if the last read that succeeded is older than whatsappGroupsAge.
+func (g *WhatsAppGroups) stale() bool {
+       g.mutex.Lock()
+       defer g.mutex.Unlock()
+
+       return time.Since(g.readAt) >= whatsappGroupsAge
+}
+
+// all returns a copy of the names of the groups.
+func (g *WhatsAppGroups) all() map[string]string {
+       g.mutex.Lock()
+       defer g.mutex.Unlock()
+
+       return maps.Clone(g.names)
+}