From: Anderson A. Giacomolli Date: Sun, 8 Nov 2026 15:09:02 +0000 (-0300) Subject: Find WhatsApp contacts and groups X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/38a9b0502a1ccefc6ccb3472c4a21805b222caa6?hp=0da1ef837bbd190cc5787214187d1b29d62b9410 Find WhatsApp contacts and groups The agent gets the tool whatsapp_find, which gives the contacts and groups of the user whose name holds a text, with no regard to case and accents, as lines "ID | name | contact or group", at most 20 and the number of the others. The send tool of the next step takes these IDs. The contacts come from the session database of the library, with the full, first, business, and push names, and with no copy. The groups come from WhatsApp and stay in memory for one hour; the batches use the same names. When the groups cannot be read and none are known, the answer says so. Before the names arrive from the phone, the tool says that they are not ready, and without a link it tells the agent to ask the user to run /whatsapp pair PHONE. Only the root agent has the tool: the toolbox of a subagent has no WhatsApp, so it is an unknown tool there. The new tool changes the tool list, so the prompt cache misses once. The match uses golang.org/x/text, which the library needs already, so the vendor hash changes only for its packages. Issue: 6f69ca1 --- diff --git a/README.rst b/README.rst index b3787d2..457cb19 100644 --- a/README.rst +++ b/README.rst @@ -246,6 +246,15 @@ minute at the most, and then gives all its batches to the agent in one turn, not one turn for each chat. The batches of ``whatsapp/state.json`` at the start wait for the first catch-up in the same way. +The agent finds a chat with the tool ``whatsapp_find``: it gives the +contacts and the groups whose name holds a text, with no regard to case +and accents, with their IDs. The names of the contacts come from the +session, and the names of the groups from WhatsApp, kept in memory for +one hour. The names arrive from the phone some minutes after the link, +and until then the tool says that they are not ready. Without a link, +the tool tells the agent to ask the user to run ``/whatsapp pair +PHONE``. Only the agent has the WhatsApp tools, not its subagents. + Each batch of a group goes to the chat, also with no turn, and the memory tree summarizes it with model calls, which use the quota. diff --git a/daemon.go b/daemon.go index 991edfa..1366870 100644 --- a/daemon.go +++ b/daemon.go @@ -117,7 +117,7 @@ func daemon(ctx context.Context, out io.Writer) (err error) { } return daemonServe(ctx, listener, telegram, whatsapp, func(notify func(Event)) (*TurnLoop, error) { - return newTurnLoop(agent, memory, subagents, client, time.Now, notify) + return newTurnLoop(agent, memory, subagents, whatsapp, client, time.Now, notify) }) } diff --git a/daemon_start_helper_test.go b/daemon_start_helper_test.go index f90d2d7..baa527b 100644 --- a/daemon_start_helper_test.go +++ b/daemon_start_helper_test.go @@ -42,7 +42,7 @@ func daemonStartTestWithWhatsApp(t *testing.T, server *ModelFake, bot *TelegramB done := make(chan error) go func() { done <- daemonServe(ctx, listener, bot, whatsapp, func(notify func(Event)) (*TurnLoop, error) { - return newTurnLoop(agent, memory, newTestSubagents(t), client, clock.Now, notify) + return newTurnLoop(agent, memory, newTestSubagents(t), whatsapp, client, clock.Now, notify) }) }() diff --git a/go.mod b/go.mod index 2fdae06..fcf4681 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/stretchr/testify v1.12.0 go.mau.fi/whatsmeow v0.0.0-20261107160454-79dc2a8c33df golang.org/x/sys v0.49.0 + golang.org/x/text v0.43.0 modernc.org/sqlite v1.62.0 ) @@ -29,7 +30,6 @@ require ( golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect golang.org/x/net v0.61.0 // indirect golang.org/x/sync v0.24.0 // indirect - golang.org/x/text v0.43.0 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.80.1 // indirect diff --git a/nix/package.nix b/nix/package.nix index 1a7e8d0..67a11a6 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -9,7 +9,7 @@ buildGoModule { inherit src; subPackages = [ "." ]; - vendorHash = "sha256-RxkfJaQ+YDa7C7EecVo0XBh26Kg0qQ+1QvSnnjjEKwk="; + vendorHash = "sha256-35kAjo+7gbOOzNbKJyMYUcAF11HK7WzO3m9ZcrOsqY0="; # The flake check runs the full replay. checkFlags = [ "-short" ]; diff --git a/prompt.go b/prompt.go index b9d9284..cd70250 100644 --- a/prompt.go +++ b/prompt.go @@ -185,7 +185,7 @@ func promptSubagent(name, instructions string) string { } // promptTools returns the function tools of every call of the agent: the tools -// of promptSubagentTools, then spawn and tell. A compaction sends them too and +// of promptSubagentTools, then spawn, tell, and the WhatsApp tools. A compaction sends them too and // never calls them, because a change of the tools ends the shared prompt cache. func promptTools() []ModelTool { return append(promptSubagentTools(), ModelTool{ @@ -214,6 +214,14 @@ func promptTools() []ModelTool { "message": promptStringSchema("The message."), }), Strict: true, + }, ModelTool{ + Type: "function", + Name: "whatsapp_find", + Description: "Find the WhatsApp contacts and groups of the user whose name contains the text, with no regard to case and accents. Gives up to 20 matches, each as ID | name | contact or group.", + Parameters: promptObjectSchema(map[string]any{ + "name": promptStringSchema("A part of the name of the contact or the group."), + }), + Strict: true, }) } diff --git a/prompt_test.go b/prompt_test.go index 6adb0ba..4aa985f 100644 --- a/prompt_test.go +++ b/prompt_test.go @@ -77,7 +77,7 @@ func TestPromptWithoutInstructionsHasNoClause(t *testing.T) { func TestAgentToolsAreStrictAndStable(t *testing.T) { tools := promptTools() - require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "tell"}, promptToolNames(tools)) + require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "tell", "whatsapp_find"}, promptToolNames(tools)) require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, promptToolNames(promptSubagentTools())) for _, tool := range tools { diff --git a/subagent_run_test.go b/subagent_run_test.go index 647b7ad..9b8b8c5 100644 --- a/subagent_run_test.go +++ b/subagent_run_test.go @@ -516,7 +516,8 @@ func subagentTestStart(t *testing.T, agent *Agent, server *ModelFake, events *Tu subagents := newSubagents(filepath.Join(agent.dir, subagentDirName), clock.Now, subagentNameRandom) client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl")) - loop, err := newTurnLoop(agent, memory, subagents, client, clock.Now, events.Add) + whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), clock) + loop, err := newTurnLoop(agent, memory, subagents, whatsapp, client, clock.Now, events.Add) require.NoError(t, err) ctx, cancel := context.WithCancel(t.Context()) diff --git a/tools.go b/tools.go index dd28ae0..b20b3ac 100644 --- a/tools.go +++ b/tools.go @@ -50,14 +50,15 @@ type ToolChat interface { // message. The call is durable before its tool runs, so the log never misses an // action outside the agent. Commands and files run as the user in the working // directory, with no sandbox. Zoom with a name reads the log of a subagent. -// The toolbox of the agent has the runs of its subagents for spawn and tell. -// The toolbox of a subagent has none, so spawn and tell are unknown tools -// there. +// The toolbox of the agent has the runs of its subagents for spawn and tell, +// and the WhatsApp link for the WhatsApp tools. The toolbox of a subagent has +// neither, so these are unknown tools there. type Toolbox struct { log ToolLog chat ToolChat subagents *Subagents runs *SubagentRuns + whatsapp *WhatsApp workdir string } @@ -152,6 +153,15 @@ func (b *Toolbox) run(ctx context.Context, name, arguments string) string { case err == nil: output, err = b.runs.Tell(ctx, args.Name, args.Message) } + case "whatsapp_find": + var args ToolWhatsAppFindArguments + err = toolDecodeArguments(arguments, &args) + switch { + case b.whatsapp == nil: + err = fmt.Errorf("unknown tool %q", name) + case err == nil: + output, err = b.whatsapp.Find(ctx, args.Name) + } default: err = fmt.Errorf("unknown tool %q", name) } @@ -393,6 +403,11 @@ type ToolTellArguments struct { Message string `json:"message"` } +// ToolWhatsAppFindArguments are the arguments of whatsapp_find. +type ToolWhatsAppFindArguments struct { + Name string `json:"name"` +} + // ToolHeadTail keeps the first and the last toolClipBytes bytes written to it, // and counts the bytes between them that it drops. type ToolHeadTail struct { diff --git a/turn.go b/turn.go index e304714..c6ab5e9 100644 --- a/turn.go +++ b/turn.go @@ -9,10 +9,10 @@ import ( "time" ) -// newTurnLoop returns the loop of agent with its memory and its subagents. Its -// calls go through client, and notify gets each event. The clock gives the date -// of a turn. -func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, client ChainClient, clock func() time.Time, notify func(Event)) (*TurnLoop, error) { +// newTurnLoop returns the loop of agent with its memory, its subagents, and the +// WhatsApp link of its tools. Its calls go through client, and notify gets each +// event. The clock gives the date of a turn. +func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, whatsapp *WhatsApp, client ChainClient, clock func() time.Time, notify func(Event)) (*TurnLoop, error) { layout, err := loadViewLayout(filepath.Join(agent.dir, viewLayoutFileName)) if err != nil { return nil, err @@ -47,6 +47,7 @@ func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, client Chai chat: memory, subagents: subagents, runs: l.runs, + whatsapp: whatsapp, workdir: agent.config.Workdir, } diff --git a/turn_test.go b/turn_test.go index b0417cf..319dcf3 100644 --- a/turn_test.go +++ b/turn_test.go @@ -391,7 +391,8 @@ func newTestTurnLoopWith(t *testing.T, server *ModelFake, summarizer Summarizer) } client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), test.usagePath) - test.loop, err = newTurnLoop(agent, memory, newTestSubagents(t), client, clock.Now, test.events.Add) + whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), clock) + test.loop, err = newTurnLoop(agent, memory, newTestSubagents(t), whatsapp, client, clock.Now, test.events.Add) require.NoError(t, err) ctx, cancel := context.WithCancel(t.Context()) done := make(chan error) diff --git a/whatsapp.go b/whatsapp.go index 8306734..0e71e98 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -1,10 +1,12 @@ package main import ( + "cmp" "context" "errors" "fmt" "log" + "maps" "net/url" "os" "path/filepath" @@ -12,6 +14,7 @@ import ( "strings" "sync" "time" + "unicode" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/store" @@ -19,6 +22,9 @@ 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. @@ -85,6 +91,20 @@ const ( // 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 @@ -216,6 +236,7 @@ type WhatsApp struct { closed bool groupNames map[string]string groupsAt time.Time + groupsRead time.Time catchingUp bool caughtUp bool catchups int @@ -585,18 +606,35 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error { func (w *WhatsApp) groupName(ctx context.Context, id string) string { w.mutex.Lock() name, found := w.groupNames[id] - client := w.client fresh := time.Since(w.groupsAt) < whatsappGroupsRefresh w.mutex.Unlock() - if found { - return name + if !found && !fresh { + w.readGroups(ctx) + + w.mutex.Lock() + name, found = w.groupNames[id] + w.mutex.Unlock() } - if client == nil || fresh { + 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() @@ -609,16 +647,91 @@ func (w *WhatsApp) groupName(ctx context.Context, id string) string { 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 } - name, found = w.groupNames[id] - if !found || name == "" { - return whatsappUnknownGroup + 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 } - return name + 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 @@ -1070,6 +1183,13 @@ func (w *WhatsApp) redact(text string) string { return text } +// WhatsAppMatch is one contact or group of Find. +type WhatsAppMatch struct { + id string + name string + kind 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 { @@ -1135,6 +1255,44 @@ 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 diff --git a/whatsapp_test.go b/whatsapp_test.go index 6785bff..7c893cc 100644 --- a/whatsapp_test.go +++ b/whatsapp_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "net/http" "os" "path/filepath" @@ -758,3 +759,128 @@ func whatsappTestConnect(t *testing.T, fake *WhatsAppFake) { require.True(t, fake.Emit(&events.Connected{})) require.True(t, fake.Emit(&events.OfflineSyncCompleted{})) } + +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"}`)) +} + +// 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")) +}