From 2e2524881dfe8f6c5cabfd9d342d5d3492953bd4 Mon Sep 17 00:00:00 2001 From: "Anderson A. Giacomolli" Date: Sun, 8 Nov 2026 18:39:43 -0300 Subject: [PATCH] Move whatsapp_find to its own file The search of whatsapp_find is now the function whatsappFind in whatsapp_find.go, with its matches, its fold of the names, and its answer. It owns no state: it gets a WhatsAppLink for the device, the client, and the own IDs, and the WhatsAppGroups for the names of the groups. WhatsApp.Find stays the entry point of the tool and gives the current link. The tests of the tool move to whatsapp_find_test.go. The behavior does not change. Issue: 6f69ca1 --- whatsapp.go | 136 ++-------------------------------- whatsapp_fake_helper_test.go | 13 ++++ whatsapp_find.go | 139 +++++++++++++++++++++++++++++++++++ whatsapp_find_test.go | 118 +++++++++++++++++++++++++++++ whatsapp_test.go | 121 ------------------------------ 5 files changed, 275 insertions(+), 252 deletions(-) create mode 100644 whatsapp_find.go create mode 100644 whatsapp_find_test.go diff --git a/whatsapp.go b/whatsapp.go index c5af946..5b07300 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -1,7 +1,6 @@ package main import ( - "cmp" "context" "crypto/sha256" "encoding/hex" @@ -15,7 +14,6 @@ import ( "strings" "sync" "time" - "unicode" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" @@ -24,9 +22,6 @@ 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. @@ -80,9 +75,6 @@ const ( // session database or of the groups from WhatsApp. whatsappLookupTime = 10 * time.Second - // 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." @@ -99,10 +91,6 @@ const ( whatsappSending = "sending" whatsappSent = "sent" whatsappFailed = "failed" - - // 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 @@ -363,6 +351,11 @@ 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) +} + func (w *WhatsApp) sessionPath() string { return filepath.Join(w.dir, whatsappSessionFileName) } @@ -522,80 +515,6 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str return nil } -// 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) - w.mutex.Unlock() - - stale := w.groups.stale() - - 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.groups.read(ctx, w.link()) - groups := w.groups.all() - - 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 { @@ -973,13 +892,6 @@ func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, err return record.Answer, nil } -// 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 { @@ -1055,44 +967,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") -} - // whatsappSendCheck returns the JID of chat, and an error when id, chat, or // text cannot go to a send. The chat is a private chat or a group, and not the // chat of the user with itself. diff --git a/whatsapp_fake_helper_test.go b/whatsapp_fake_helper_test.go index 3d7d117..ee5321e 100644 --- a/whatsapp_fake_helper_test.go +++ b/whatsapp_fake_helper_test.go @@ -352,3 +352,16 @@ func whatsappTestConnect(t *testing.T, fake *WhatsAppFake) { require.True(t, fake.Emit(&events.Connected{})) require.True(t, fake.Emit(&events.OfflineSyncCompleted{})) } + +// whatsappTestContacts puts the contacts of the tests into the session of +// whatsapp: Ana Souza, Joao Silva with an accent, a business, and the user. +func whatsappTestContacts(t *testing.T, whatsapp *WhatsApp) { + t.Helper() + + contacts := whatsapp.device.Contacts + require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza")) + require.NoError(t, contacts.PutContactName(t.Context(), types.NewJID("15550004444", types.DefaultUserServer), "João", "João Silva")) + _, _, err := contacts.PutBusinessName(t.Context(), types.NewJID("15550005555", types.DefaultUserServer), "Corner Bakery") + require.NoError(t, err) + require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestOwn, "Me", "Me Souza")) +} diff --git a/whatsapp_find.go b/whatsapp_find.go new file mode 100644 index 0000000..545e01c --- /dev/null +++ b/whatsapp_find.go @@ -0,0 +1,139 @@ +package main + +import ( + "cmp" + "context" + "errors" + "fmt" + "slices" + "strings" + "unicode" + + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +const ( + // whatsappFindMatches is the most matches of whatsapp_find. + whatsappFindMatches = 20 + + // 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." +) + +// whatsappFind 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. link gives the +// device, the client to read the groups, and the own IDs, which are not in the +// answer. +func whatsappFind(ctx context.Context, link WhatsAppLink, groups *WhatsAppGroups, name string) (string, error) { + stale := groups.stale() + + if !link.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 := link.device.Contacts.GetAllContacts(ctx) + if err != nil { + return "", fmt.Errorf("read the contacts: %s", whatsappRedact(link.own, err.Error())) + } + + read := !stale || groups.read(ctx, link) + names := groups.all() + + if len(contacts) == 0 && len(names) == 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(link.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 names { + if strings.Contains(whatsappFold(group), query) { + matches = append(matches, WhatsAppMatch{ + id: id, + name: group, + kind: "group", + }) + } + } + + return whatsappFindAnswer(name, matches, read || len(names) > 0), nil +} + +// WhatsAppMatch is one contact or group of whatsappFind. +type WhatsAppMatch struct { + id string + name string + kind string +} + +// 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 whatsappFind 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") +} diff --git a/whatsapp_find_test.go b/whatsapp_find_test.go new file mode 100644 index 0000000..30f84ff --- /dev/null +++ b/whatsapp_find_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.mau.fi/whatsmeow/types" +) + +func TestWhatsAppFindMatchesNamesWithNoRegardToCaseAndAccents(t *testing.T) { + whatsapp, fake := whatsappStartTestLinked(t) + fake.groups = []*types.GroupInfo{ + { + JID: whatsappTestFamily, + GroupName: types.GroupName{ + Name: "Família Souza", + }, + }, + } + + whatsappTestContacts(t, whatsapp) + daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) + whatsappTestConnect(t, fake) + + testCases := []struct { + name string + query string + answer string + }{ + { + name: "no accent finds an accent", + query: "joao", + answer: "15550004444@s.whatsapp.net | João Silva | contact", + }, + { + name: "a part of a name finds contacts and groups", + query: "SOUZA", + answer: "15550002222@s.whatsapp.net | Ana Souza | contact\n120363000000000001@g.us | Família Souza | group", + }, + { + name: "a business name", + query: "bakery", + answer: "15550005555@s.whatsapp.net | Corner Bakery | contact", + }, + { + name: "no match", + query: "Zed", + answer: "No contact or group has a name with \"Zed\".", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + answer, err := whatsapp.Find(t.Context(), tc.query) + require.NoError(t, err) + require.Equal(t, tc.answer, answer) + }) + } +} + +func TestWhatsAppFindGivesAtMostTwentyMatches(t *testing.T) { + whatsapp, _ := whatsappStartTestLinked(t) + for index := range 25 { + jid := types.NewJID(fmt.Sprintf("1555001%04d", index), types.DefaultUserServer) + require.NoError(t, whatsapp.device.Contacts.PutContactName(t.Context(), jid, "", fmt.Sprintf("Member %02d", index))) + } + + answer, err := whatsapp.Find(t.Context(), "member") + require.NoError(t, err) + + lines := strings.Split(answer, "\n") + require.Len(t, lines, 22) + require.Equal(t, "15550010000@s.whatsapp.net | Member 00 | contact", lines[0]) + require.Equal(t, "5 more: give a longer part of the name.", lines[20]) + require.Equal(t, "The groups are not in this answer, because WhatsApp is disconnected.", lines[21]) +} + +func TestWhatsAppFindBeforeTheNamesArrive(t *testing.T) { + whatsapp, fake := whatsappStartTestLinked(t) + daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) + whatsappTestConnect(t, fake) + + answer, err := whatsapp.Find(t.Context(), "ana") + require.NoError(t, err) + require.Equal(t, whatsappNamesNotReady, answer) +} + +func TestWhatsAppFindWithoutALink(t *testing.T) { + whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock()) + + answer, err := whatsapp.Find(t.Context(), "ana") + require.NoError(t, err) + require.Equal(t, whatsappNotLinked, answer) +} + +func TestAgentFindsAWhatsAppContact(t *testing.T) { + whatsapp, fake := whatsappStartTestLinked(t) + whatsappTestContacts(t, whatsapp) + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + if call.number == 0 { + modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "whatsapp_find", `{"name":"ana"}`)}, 100, 0, 5)) + return + } + + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Found Ana.")}, 100, 0, 5)) + } + + socket, _ := daemonStartTestWithWhatsApp(t, server, nil, whatsapp) + client := terminalStartTestClient(t, socket) + whatsappTestConnect(t, fake) + + client.Type("Find Ana on WhatsApp.") + client.WaitFor(t, "[result] 15550002222@s.whatsapp.net | Ana Souza | contact\nFound Ana.\n[done]") +} diff --git a/whatsapp_test.go b/whatsapp_test.go index 3d79def..2bdb4cc 100644 --- a/whatsapp_test.go +++ b/whatsapp_test.go @@ -4,7 +4,6 @@ import ( "crypto/sha256" "encoding/hex" "errors" - "fmt" "net/http" "os" "path/filepath" @@ -460,132 +459,12 @@ func TestWhatsAppCommandArgs(t *testing.T) { } } -func TestWhatsAppFindMatchesNamesWithNoRegardToCaseAndAccents(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - fake.groups = []*types.GroupInfo{ - { - JID: whatsappTestFamily, - GroupName: types.GroupName{ - Name: "Família Souza", - }, - }, - } - - whatsappTestContacts(t, whatsapp) - daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - whatsappTestConnect(t, fake) - - testCases := []struct { - name string - query string - answer string - }{ - { - name: "no accent finds an accent", - query: "joao", - answer: "15550004444@s.whatsapp.net | João Silva | contact", - }, - { - name: "a part of a name finds contacts and groups", - query: "SOUZA", - answer: "15550002222@s.whatsapp.net | Ana Souza | contact\n120363000000000001@g.us | Família Souza | group", - }, - { - name: "a business name", - query: "bakery", - answer: "15550005555@s.whatsapp.net | Corner Bakery | contact", - }, - { - name: "no match", - query: "Zed", - answer: "No contact or group has a name with \"Zed\".", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - answer, err := whatsapp.Find(t.Context(), tc.query) - require.NoError(t, err) - require.Equal(t, tc.answer, answer) - }) - } -} - -func TestWhatsAppFindGivesAtMostTwentyMatches(t *testing.T) { - whatsapp, _ := whatsappStartTestLinked(t) - for index := range 25 { - jid := types.NewJID(fmt.Sprintf("1555001%04d", index), types.DefaultUserServer) - require.NoError(t, whatsapp.device.Contacts.PutContactName(t.Context(), jid, "", fmt.Sprintf("Member %02d", index))) - } - - answer, err := whatsapp.Find(t.Context(), "member") - require.NoError(t, err) - - lines := strings.Split(answer, "\n") - require.Len(t, lines, 22) - require.Equal(t, "15550010000@s.whatsapp.net | Member 00 | contact", lines[0]) - require.Equal(t, "5 more: give a longer part of the name.", lines[20]) - require.Equal(t, "The groups are not in this answer, because WhatsApp is disconnected.", lines[21]) -} - -func TestWhatsAppFindBeforeTheNamesArrive(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - whatsappTestConnect(t, fake) - - answer, err := whatsapp.Find(t.Context(), "ana") - require.NoError(t, err) - require.Equal(t, whatsappNamesNotReady, answer) -} - -func TestWhatsAppFindWithoutALink(t *testing.T) { - whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock()) - - answer, err := whatsapp.Find(t.Context(), "ana") - require.NoError(t, err) - require.Equal(t, whatsappNotLinked, answer) -} - -func TestAgentFindsAWhatsAppContact(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - whatsappTestContacts(t, whatsapp) - server := newModelFake(t) - server.reply = func(w http.ResponseWriter, call ModelFakeCall) { - if call.number == 0 { - modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "whatsapp_find", `{"name":"ana"}`)}, 100, 0, 5)) - return - } - - modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Found Ana.")}, 100, 0, 5)) - } - - socket, _ := daemonStartTestWithWhatsApp(t, server, nil, whatsapp) - client := terminalStartTestClient(t, socket) - whatsappTestConnect(t, fake) - - client.Type("Find Ana on WhatsApp.") - client.WaitFor(t, "[result] 15550002222@s.whatsapp.net | Ana Souza | contact\nFound Ana.\n[done]") -} - func TestSubagentHasNoWhatsAppTools(t *testing.T) { box := &Toolbox{} require.Equal(t, `error: unknown tool "whatsapp_find"`, box.run(t.Context(), "whatsapp_find", `{"name":"ana"}`)) require.Equal(t, `error: unknown tool "whatsapp_send"`, box.run(t.Context(), "whatsapp_send", `{"id":"a","chat":"15550002222@s.whatsapp.net","text":"hi"}`)) } -// whatsappTestContacts puts the contacts of the tests into the session of -// whatsapp: Ana Souza, Joao Silva with an accent, a business, and the user. -func whatsappTestContacts(t *testing.T, whatsapp *WhatsApp) { - t.Helper() - - contacts := whatsapp.device.Contacts - require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza")) - require.NoError(t, contacts.PutContactName(t.Context(), types.NewJID("15550004444", types.DefaultUserServer), "João", "João Silva")) - _, _, err := contacts.PutBusinessName(t.Context(), types.NewJID("15550005555", types.DefaultUserServer), "Corner Bakery") - require.NoError(t, err) - require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestOwn, "Me", "Me Souza")) -} - func TestWhatsAppSendSendsOnceForEachID(t *testing.T) { whatsapp, fake := whatsappStartTestLinked(t) whatsappTestContacts(t, whatsapp) -- 2.53.0