From: Anderson A. Giacomolli Date: Sun, 8 Nov 2026 21:34:05 +0000 (-0300) Subject: Give the WhatsApp group names their own type X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/1804474c7a03cca8bf780dd870a985e7badb4331 Give the WhatsApp group names their own type 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 --- diff --git a/whatsapp.go b/whatsapp.go index 44b9dfa..929ae5d 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "log" - "maps" "net/url" "os" "path/filepath" @@ -84,21 +83,10 @@ const ( // 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 - // 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 @@ -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), - groupNames: make(map[string]string), + groups: newWhatsAppGroups(logger), } var err error @@ -238,6 +226,7 @@ type WhatsApp struct { wake chan struct{} failures chan error state *WhatsAppState + groups *WhatsAppGroups mutex sync.Mutex container *sqlstore.Container @@ -253,9 +242,6 @@ type WhatsApp struct { noticed bool timer *time.Timer closed bool - groupNames map[string]string - groupsAt time.Time - groupsRead time.Time catchingUp bool caughtUp bool catchups int @@ -443,6 +429,21 @@ func (w *WhatsApp) isLinked() bool { 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() @@ -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 == "" { - 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()), "") @@ -619,65 +620,6 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error { 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 @@ -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) - stale := time.Since(w.groupsRead) >= whatsappGroupsAge w.mutex.Unlock() + stale := w.groups.stale() + 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())) } - 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 { @@ -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 { - message, isMessage := event.(*events.Message) - if isMessage { - return w.receive(message) - } - - var notice string - w.mutex.Lock() switch event := event.(type) { + case *events.Message: + return w.receive(event) 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: - 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: @@ -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 { - 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, "") } @@ -1206,6 +1150,16 @@ type WhatsAppMatch struct { 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 { diff --git a/whatsapp_groups.go b/whatsapp_groups.go new file mode 100644 index 0000000..ab5ed61 --- /dev/null +++ b/whatsapp_groups.go @@ -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) +}