X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/38a9b0502a1ccefc6ccb3472c4a21805b222caa6..e737005f89c7c663e1636830001730ad79396bdc:/whatsapp.go diff --git a/whatsapp.go b/whatsapp.go index 0e71e98..75de73f 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -1,12 +1,10 @@ package main import ( - "cmp" "context" "errors" "fmt" "log" - "maps" "net/url" "os" "path/filepath" @@ -14,18 +12,15 @@ import ( "strings" "sync" "time" - "unicode" "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waCompanionReg" + "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" "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. _ "modernc.org/sqlite" @@ -74,37 +69,12 @@ const ( // wait for Run. whatsappRequests = 8 - // whatsappSource is the source of the work events of the batches. - whatsappSource = "whatsapp" - // whatsappLookupTime is the longest time of a read of the names in the // 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 - // 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 @@ -117,6 +87,8 @@ type WhatsAppClient interface { PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error) Logout(ctx context.Context) error GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error) + GenerateMessageID() types.MessageID + SendMessage(ctx context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error) } // openWhatsApp opens the WhatsApp session in the data directory dir. Without @@ -126,25 +98,24 @@ 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{ - 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), + dir: filepath.Join(dir, whatsappDirName), + clock: clock, + logger: logger, + newClient: newClient, + noticeDelay: whatsappNoticeDelay, + requests: make(chan WhatsAppRequest, whatsappRequests), + logouts: make(chan struct{}, 1), + groups: newWhatsAppGroups(logger), } var err error - w.batches, err = openWhatsAppBatches(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow) + w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow) if err != nil { return nil, err } + w.inbox = newWhatsAppInbox(w.state, clock, w.groups) + info, err := os.Stat(w.sessionPath()) if errors.Is(err, os.ErrNotExist) { return w, nil @@ -165,7 +136,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge if w.device.ID != nil { w.linked = true - w.catchingUp = true + w.inbox.waitForCatchup() w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User) } @@ -185,20 +156,9 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge // client, and the state of the connection, which the event handler of the // library changes from its own goroutines. // -// 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. -// -// 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 inbox puts the text messages into batches and logs them, and groups +// holds the names of the groups. Both get a WhatsAppLink from link for each +// call, so they use the current device and client. // // 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 @@ -208,17 +168,16 @@ 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 { - 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 + 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{} + state *WhatsAppState + groups *WhatsAppGroups + inbox *WhatsAppInbox mutex sync.Mutex container *sqlstore.Container @@ -234,13 +193,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 - catchupTimer *time.Timer } // Run connects a linked device, logs the closed batches in l, runs the pair and @@ -269,24 +221,18 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err }() for { - err := w.deliver(ctx, l, false) + err := w.inbox.deliver(ctx, l, w.link(), false) if err != nil { return err } - var due <-chan time.Time - wait, open := w.batches.Wait(time.Now()) - if open { - due = time.After(wait) - } - select { case <-ctx.Done(): return nil - case err = <-w.failures: + case err = <-w.inbox.failures: return err - case <-w.wake: - case <-due: + case <-w.inbox.wake: + case <-w.inbox.due(): case <-w.logouts: err = w.loggedOut(ctx, l) if err != nil { @@ -311,7 +257,7 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err pairing.Go(func() { defer cancelPair() - w.pair(pairCtx, l, request.phone, request.answer) + w.pair(pairCtx, ctx, l, request.phone, request.answer) }) } } @@ -389,6 +335,17 @@ func (w *WhatsApp) Close() error { return err } +// Find returns the answer of whatsapp_find for name, as whatsappFind says. +func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) { + return whatsappFind(ctx, w.link(), w.groups, name) +} + +// Send returns the answer of whatsapp_send for the send id of text to chat, as +// whatsappSend says. +func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) { + return whatsappSend(ctx, w.link(), w.groups, w.state, w.clock, id, chat, text) +} + func (w *WhatsApp) sessionPath() string { return filepath.Join(w.dir, whatsappSessionFileName) } @@ -424,6 +381,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() @@ -445,14 +417,12 @@ func (w *WhatsApp) close() { w.timer.Stop() } - if w.catchupTimer != nil { - w.catchupTimer.Stop() - } - client := w.client w.client = nil w.mutex.Unlock() + w.inbox.close() + if client != nil { client.Disconnect() } @@ -465,7 +435,7 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error { return nil } - err := w.deliver(ctx, l, true) + err := w.inbox.deliver(ctx, l, w.link(), true) if err != nil { return err } @@ -501,7 +471,7 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str return nil } - err := w.deliver(ctx, l, true) + err := w.inbox.deliver(ctx, l, w.link(), true) if err != nil { return err } @@ -535,205 +505,6 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str return nil } -// deliver logs the closed batches in l in order, each as a work message of -// 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 - } - - 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) - } - - message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "") - if err != nil { - return fmt.Errorf("log a WhatsApp batch: %w", err) - } - - // A stop of the daemon during the flush is no failure: the - // batch stays in the state file and repeats once at the next - // start. - err = l.Flush(ctx) - if ctx.Err() != nil { - return nil - } - - if err != nil { - return fmt.Errorf("log a WhatsApp batch: %w", err) - } - - err = w.batches.Remove(batch.Number) - if err != nil { - return err - } - - if batch.Turn() { - turns = append(turns, message) - } - } - - l.Wake(turns...) - - 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 -// 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 - } - - 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 // its files. func (w *WhatsApp) removeSession() error { @@ -743,7 +514,6 @@ func (w *WhatsApp) removeSession() error { w.linked = false w.own = nil w.connected = false - w.catchingUp = false if w.timer != nil { w.timer.Stop() } @@ -754,6 +524,8 @@ func (w *WhatsApp) removeSession() error { w.device = nil w.mutex.Unlock() + w.inbox.stopCatchup() + if client != nil { client.Disconnect() } @@ -778,7 +550,13 @@ func (w *WhatsApp) removeSession() error { // 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()) @@ -795,7 +573,7 @@ func (w *WhatsApp) pair(ctx context.Context, l *TurnLoop, phone string, answer f qr, err := client.GetQRChannel(ctx) if err == nil { - err = client.ConnectContext(ctx) + err = client.ConnectContext(runCtx) } if err != nil { @@ -817,6 +595,8 @@ func (w *WhatsApp) pair(ctx context.Context, l *TurnLoop, phone string, answer f return } + // The server accepts only a display name of the form "Browser (OS)" + // with a common browser and OS, and refuses the pairing otherwise. code, err := client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)") if err != nil { answer("WhatsApp refused the pairing: " + err.Error() + ". Check the phone number and run /whatsapp pair PHONE again.") @@ -908,42 +688,40 @@ 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 -// 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. +// goes to the inbox. The names of the groups follow the events of the groups. A +// connection starts a catch-up of the inbox, which ends at OfflineSyncCompleted +// or after its 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 { - return w.receive(message) - } - - var notice string - w.mutex.Lock() switch event := event.(type) { + case *events.Message: + return w.inbox.receive(w.link(), 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 + case *events.OfflineSyncCompleted: + w.inbox.endCatchup() + return true + case *events.Connected: + w.inbox.startCatchup() + } + + 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: 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() } @@ -963,8 +741,6 @@ func (w *WhatsApp) handle(event any) bool { } w.connected = false - case *events.OfflineSyncCompleted: - w.endCatchup() case *events.LoggedOut: select { case w.logouts <- struct{}{}: @@ -986,159 +762,6 @@ func (w *WhatsApp) handle(event any) bool { return true } -// receive puts the text of event into its batch and wakes Run. It returns false -// when the state file cannot hold the message, so that the library does not -// acknowledge it, and Run stops the daemon. A message that is not text, or that -// the batches do not take, is handled with nothing to do. -func (w *WhatsApp) receive(event *events.Message) bool { - ctx, cancel := context.WithTimeout(context.Background(), whatsappLookupTime) - defer cancel() - - chat, message, found := w.message(ctx, event) - if !found { - return true - } - - _, err := w.batches.Add(chat, message, time.Now()) - if err != nil { - select { - case w.failures <- err: - default: - } - - return false - } - - select { - case w.wake <- struct{}{}: - default: - } - - 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() - - 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) { @@ -1176,18 +799,17 @@ func (w *WhatsApp) redact(text string) string { 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) } -// WhatsAppMatch is one contact or group of Find. -type WhatsAppMatch struct { - id string - name 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 @@ -1198,12 +820,20 @@ type WhatsAppRequest struct { answer func(text string) } +// whatsappSetDeviceProps sets the device properties that the library sends at +// the pairing. They are global values of the library, so the daemon sets them +// once before any client exists. The phone shows the linked device with the +// name OneChat and a desktop icon only when the platform type is known. +func whatsappSetDeviceProps() { + store.SetOSInfo("OneChat", [3]uint32{0, 1, 0}) + store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_DESKTOP.Enum() +} + // whatsappNewClient returns the client of the library for device, with handle // as its event handler. The client tries again after a failed first connection, // acknowledges a message only after handle returns true, and downloads no old -// history. The phone shows the device with the name OneChat. +// history. func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient { - store.SetOSInfo("OneChat", [3]uint32{0, 1, 0}) client := whatsmeow.NewClient(device, waLog.Noop) client.InitialAutoReconnect = true client.SynchronousAck = true @@ -1255,44 +885,6 @@ 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 @@ -1305,6 +897,15 @@ func whatsappOwnIDs(ids ...string) []string { 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.