]> git.giacomolli.uk - onechat/commitdiff
Find WhatsApp contacts and groups
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 15:09:02 +0000 (12:09 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 15:09:02 +0000 (12:09 -0300)
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

13 files changed:
README.rst
daemon.go
daemon_start_helper_test.go
go.mod
nix/package.nix
prompt.go
prompt_test.go
subagent_run_test.go
tools.go
turn.go
turn_test.go
whatsapp.go
whatsapp_test.go

index b3787d28455cb209e055f91127949ef4af6a36e8..457cb19a87f1d1f6d5f273a12607b394ec53993b 100644 (file)
@@ -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.
 
 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.
 
 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.
 
index 991edfaa2a444fd6802f90d4ae859bd75f611f20..1366870a6322273239e959cd40747301af2ab891 100644 (file)
--- 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 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)
        })
 }
 
        })
 }
 
index f90d2d7464aeb00b931a9c8689b74b41e984f949..baa527b9d7637851d0db2e0ebc67698a678bd6d1 100644 (file)
@@ -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) {
        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 2fdae0630030d3e54cdc7595733a31d37e3a9b03..fcf4681fcec9638ab666f4514ab9ee716133ea63 100644 (file)
--- 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
        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
 )
 
        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/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
        google.golang.org/protobuf v1.36.12 // indirect
        gopkg.in/yaml.v3 v3.0.1 // indirect
        modernc.org/libc v1.80.1 // indirect
index 1a7e8d09c655763eeacad1d1796ca17ff9037007..67a11a603805f23f39725e71eecf25b7334142a8 100644 (file)
@@ -9,7 +9,7 @@ buildGoModule {
   inherit src;
 
   subPackages = [ "." ];
   inherit src;
 
   subPackages = [ "." ];
-  vendorHash = "sha256-RxkfJaQ+YDa7C7EecVo0XBh26Kg0qQ+1QvSnnjjEKwk=";
+  vendorHash = "sha256-35kAjo+7gbOOzNbKJyMYUcAF11HK7WzO3m9ZcrOsqY0=";
 
   # The flake check runs the full replay.
   checkFlags = [ "-short" ];
 
   # The flake check runs the full replay.
   checkFlags = [ "-short" ];
index b9d928467c2d0ebb78f9a56ab17aa96f262c776a..cd70250b2b999370931b20d4f327f07b04d142e0 100644 (file)
--- 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
 }
 
 // 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{
 // 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,
                        "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,
        })
 }
 
        })
 }
 
index 6adb0ba92ad42ea55b174b94390c94472169a45f..4aa985f6dcd49ed8bfa4de4f91be1240839e1fe8 100644 (file)
@@ -77,7 +77,7 @@ func TestPromptWithoutInstructionsHasNoClause(t *testing.T) {
 
 func TestAgentToolsAreStrictAndStable(t *testing.T) {
        tools := promptTools()
 
 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 {
        require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, promptToolNames(promptSubagentTools()))
 
        for _, tool := range tools {
index 647b7adbefdb03a53b5fd44cfcd6e2712b1916f5..9b8b8c5b8279b43c8369a1b26d2856c45e9e7ffa 100644 (file)
@@ -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"))
 
        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())
        require.NoError(t, err)
 
        ctx, cancel := context.WithCancel(t.Context())
index dd28ae03af98ed306f6fbe9b86bf2489c3c43ac2..b20b3ace6f22bf38b40b4681488e584cdc62c04e 100644 (file)
--- 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.
 // 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
 type Toolbox struct {
        log       ToolLog
        chat      ToolChat
        subagents *Subagents
        runs      *SubagentRuns
+       whatsapp  *WhatsApp
        workdir   string
 }
 
        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 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)
        }
        default:
                err = fmt.Errorf("unknown tool %q", name)
        }
@@ -393,6 +403,11 @@ type ToolTellArguments struct {
        Message string `json:"message"`
 }
 
        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 {
 // 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 e304714e5068b01acb0f4d545b9dedd4de2ac5fb..c6ab5e9b2dcedf7182fdf3e9d7c0685052f585d7 100644 (file)
--- a/turn.go
+++ b/turn.go
@@ -9,10 +9,10 @@ import (
        "time"
 )
 
        "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
        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,
                chat:      memory,
                subagents: subagents,
                runs:      l.runs,
+               whatsapp:  whatsapp,
                workdir:   agent.config.Workdir,
        }
 
                workdir:   agent.config.Workdir,
        }
 
index b0417cfc7469781f3460fd021aeec49b4ad8f6e7..319dcf39fe2341b75dc2e7485fcba0be0e7a2907 100644 (file)
@@ -391,7 +391,8 @@ func newTestTurnLoopWith(t *testing.T, server *ModelFake, summarizer Summarizer)
        }
 
        client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), test.usagePath)
        }
 
        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)
        require.NoError(t, err)
        ctx, cancel := context.WithCancel(t.Context())
        done := make(chan error)
index 830673478ade69fd154b734a54f6929d300e595b..0e71e98c2770c3113a20e58624c505be87897aa0 100644 (file)
@@ -1,10 +1,12 @@
 package main
 
 import (
 package main
 
 import (
+       "cmp"
        "context"
        "errors"
        "fmt"
        "log"
        "context"
        "errors"
        "fmt"
        "log"
+       "maps"
        "net/url"
        "os"
        "path/filepath"
        "net/url"
        "os"
        "path/filepath"
@@ -12,6 +14,7 @@ import (
        "strings"
        "sync"
        "time"
        "strings"
        "sync"
        "time"
+       "unicode"
 
        "go.mau.fi/whatsmeow"
        "go.mau.fi/whatsmeow/store"
 
        "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"
        "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.
 
        // 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
        // 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
 )
 
 // 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
        closed         bool
        groupNames     map[string]string
        groupsAt       time.Time
+       groupsRead     time.Time
        catchingUp     bool
        caughtUp       bool
        catchups       int
        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]
 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()
 
        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 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()
 
        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()
        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
        }
 
        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
 }
 
 // removeSession ends the connection, closes the session database, and deletes
@@ -1070,6 +1183,13 @@ func (w *WhatsApp) redact(text string) string {
        return text
 }
 
        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 {
 // 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
 // whatsappOwnIDs returns the own number and the own LID that are not empty.
 func whatsappOwnIDs(ids ...string) []string {
        var own []string
index 6785bff7ea9faaf3589ac86fe25583740cf01816..7c893cc85ecbdbdb9bcb32c4b1f65a859266dad1 100644 (file)
@@ -2,6 +2,7 @@ package main
 
 import (
        "errors"
 
 import (
        "errors"
+       "fmt"
        "net/http"
        "os"
        "path/filepath"
        "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{}))
 }
        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"))
+}