"errors"
"fmt"
"log"
- "maps"
"net/url"
"os"
"path/filepath"
// 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
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
wake chan struct{}
failures chan error
state *WhatsAppState
+ groups *WhatsAppGroups
mutex sync.Mutex
container *sqlstore.Container
noticed bool
timer *time.Timer
closed bool
- groupNames map[string]string
- groupsAt time.Time
- groupsRead time.Time
catchingUp bool
caughtUp bool
catchups int
pairing.Go(func() {
defer cancelPair()
- w.pair(pairCtx, l, request.phone, request.answer)
+ w.pair(pairCtx, ctx, l, request.phone, request.answer)
})
}
}
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()
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()), "")
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
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
}
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 {
// 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.
-func (w *WhatsApp) pair(ctx context.Context, l *TurnLoop, phone string, answer func(text string)) {
+//
+// 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())
qr, err := client.GetQRChannel(ctx)
if err == nil {
- err = client.ConnectContext(ctx)
+ err = client.ConnectContext(runCtx)
}
if err != nil {
// 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:
ctx, cancel := context.WithTimeout(context.Background(), whatsappLookupTime)
defer cancel()
- chat, message, found := w.message(ctx, event)
+ w.mutex.Lock()
+ device := w.device
+ own := slices.Clone(w.own)
+ w.mutex.Unlock()
+
+ chat, message, found := whatsappMessage(ctx, device, own, event)
if !found {
return true
}
return true
}
-// message returns the chat and the batch message of event, or false when the
-// batches do not take it. They take the text of a private chat or a group, from
-// another person or from the user. They do not take media, reactions, edits,
-// deletions, view-once and disappearing messages, old history, status
-// broadcasts, channels, and the chat of the user with itself.
-//
-// A mention of the user becomes @you, another mention @NAME when the name is
-// known, and the own number and LID become OWN, so that they never go to the
-// log.
-func (w *WhatsApp) message(ctx context.Context, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
- w.mutex.Lock()
- device := w.device
- own := slices.Clone(w.own)
- w.mutex.Unlock()
-
- info := event.Info
- chatJID := info.Chat.ToNonAD()
- switch {
- case device == nil:
- case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil:
- case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
- case slices.Contains(own, chatJID.User):
- default:
- return w.text(ctx, device, own, event)
- }
-
- return WhatsAppChat{}, WhatsAppMessage{}, false
-}
-
-// text returns the chat and the batch message of event, a message that the
-// batches take when it has text.
-func (w *WhatsApp) text(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
- info := event.Info
- text := event.Message.GetConversation()
- extended := event.Message.GetExtendedTextMessage()
- if text == "" {
- text = extended.GetText()
- }
-
- if strings.TrimSpace(text) == "" {
- return WhatsAppChat{}, WhatsAppMessage{}, false
- }
-
- contextInfo := extended.GetContextInfo()
- message := WhatsAppMessage{
- ID: info.ID,
- Time: info.Timestamp,
- Mine: info.IsFromMe,
- }
-
- if !message.Mine {
- message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
- }
-
- for _, mentioned := range contextInfo.GetMentionedJID() {
- jid, err := types.ParseJID(mentioned)
- switch {
- case err != nil:
- case slices.Contains(own, jid.User):
- message.Mention = true
- text = strings.ReplaceAll(text, "@"+jid.User, "@you")
- default:
- text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, ""))
- }
- }
-
- participant, err := types.ParseJID(contextInfo.GetParticipant())
- message.Reply = err == nil && slices.Contains(own, participant.User)
-
- for _, id := range own {
- text = strings.ReplaceAll(text, id, "OWN")
- }
-
- message.Text = text
- chatJID := info.Chat.ToNonAD()
- chat := WhatsAppChat{
- ID: chatJID.String(),
- Group: chatJID.Server == types.GroupServer,
- }
-
- if !chat.Group {
- push := info.PushName
- if message.Mine {
- push = ""
- }
-
- chat.Name = whatsappContactName(ctx, device, chatJID, push)
- }
-
- 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()
- for _, id := range w.own {
- text = strings.ReplaceAll(text, id, "OWN")
- }
-
- return text
+ return whatsappRedact(w.own, text)
}
// Send sends text to chat, the ID of a private chat or a group, and returns the
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, "")
}
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 {
return client
}
+// whatsappMessage returns the chat and the batch message of event, or false
+// when the batches do not take it. device is the linked device, and own holds
+// the own IDs. The batches take the text of a private chat or a group, from
+// another person or from the user. They do not take media, reactions, edits,
+// deletions, view-once and disappearing messages, old history, status
+// broadcasts, channels, and the chat of the user with itself.
+//
+// A mention of the user becomes @you, another mention @NAME when the name is
+// known, and the own number and LID become OWN, so that they never go to the
+// log.
+func whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
+ info := event.Info
+ chatJID := info.Chat.ToNonAD()
+ switch {
+ case device == nil:
+ case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil:
+ case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
+ case slices.Contains(own, chatJID.User):
+ default:
+ return whatsappText(ctx, device, own, event)
+ }
+
+ return WhatsAppChat{}, WhatsAppMessage{}, false
+}
+
+// whatsappText returns the chat and the batch message of event, a message that
+// the batches take when it has text.
+func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
+ info := event.Info
+ text := event.Message.GetConversation()
+ extended := event.Message.GetExtendedTextMessage()
+ if text == "" {
+ text = extended.GetText()
+ }
+
+ if strings.TrimSpace(text) == "" {
+ return WhatsAppChat{}, WhatsAppMessage{}, false
+ }
+
+ contextInfo := extended.GetContextInfo()
+ message := WhatsAppMessage{
+ ID: info.ID,
+ Time: info.Timestamp,
+ Mine: info.IsFromMe,
+ }
+
+ if !message.Mine {
+ message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
+ }
+
+ for _, mentioned := range contextInfo.GetMentionedJID() {
+ jid, err := types.ParseJID(mentioned)
+ switch {
+ case err != nil:
+ case slices.Contains(own, jid.User):
+ message.Mention = true
+ text = strings.ReplaceAll(text, "@"+jid.User, "@you")
+ default:
+ text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, ""))
+ }
+ }
+
+ participant, err := types.ParseJID(contextInfo.GetParticipant())
+ message.Reply = err == nil && slices.Contains(own, participant.User)
+
+ message.Text = whatsappRedact(own, text)
+ chatJID := info.Chat.ToNonAD()
+ chat := WhatsAppChat{
+ ID: chatJID.String(),
+ Group: chatJID.Server == types.GroupServer,
+ }
+
+ if !chat.Group {
+ push := info.PushName
+ if message.Mine {
+ push = ""
+ }
+
+ chat.Name = whatsappContactName(ctx, device, chatJID, push)
+ }
+
+ return chat, message, true
+}
+
// whatsappContactName returns the name of the person jid: the full name, the
// first name, the business name, or the push name of the contact in the session
// of device, then push, the push name of a message, then the phone number. A
return own
}
+// whatsappRedact replaces each of the own IDs own in text with OWN.
+func whatsappRedact(own []string, text string) string {
+ for _, id := range own {
+ text = strings.ReplaceAll(text, id, "OWN")
+ }
+
+ return text
+}
+
// whatsappSessionAddress returns the SQLite address of the session database at
// path, with foreign keys on, as the library needs, and a wait for a busy
// database.