]> git.giacomolli.uk - onechat/commitdiff
Send WhatsApp messages with an ID
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 15:26:50 +0000 (12:26 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sun, 8 Nov 2026 15:26:50 +0000 (12:26 -0300)
The agent gets the tool whatsapp_send, which sends a text message to a
private chat or a group by its ID, only when the user asks for it. Each
call has an ID that the agent gives. The state file keeps a record of
each send by its ID, so a repeated call gives the answer of the first
call and sends nothing, and the same ID with another chat or text is an
error. The record "sending" is durable before the send, so a stop of the
daemon during a send gives an answer that the result is unknown and that
the agent must ask the user, and never a second send. A disconnected
WhatsApp sends nothing and makes no record.

The state file keeps the last 1000 send records with the batches. Its
type is now WhatsAppState in whatsapp_state.go, because it holds more
than the batches, and whatsapp_batch.go keeps the batch, its text, and
its turn rule.

The send tool takes only the ID of a private chat or a group, and not
the chat of the user with itself, and the own number in an error shows
as OWN. Only the root agent has the tool, and its new schema makes the
prompt cache miss once.

Issue: 6f69ca1

README.rst
prompt.go
prompt_test.go
tools.go
whatsapp.go
whatsapp_batch.go
whatsapp_batch_test.go
whatsapp_fake_helper_test.go
whatsapp_state.go [new file with mode: 0644]
whatsapp_state_test.go [new file with mode: 0644]
whatsapp_test.go

index 457cb19a87f1d1f6d5f273a12607b394ec53993b..34d0d4966bfc487811f568e61ce9b7fcf3f9adf1 100644 (file)
@@ -217,8 +217,9 @@ Each batch goes to the chat of the agent as one work message that starts
 with ``[WhatsApp]``, with the name and the ID of the chat, and the time,
 the sender, and the text of each message. The messages of the user show
 as ``You``. ``whatsapp/state.json``, with mode 0600, keeps the batches
-that are not in the chat yet, so they survive a restart. A crash just
-after a batch goes to the chat can make the batch repeat once.
+that are not in the chat yet, so they survive a restart, and the records
+of the sends. A crash just after a batch goes to the chat can make the
+batch repeat once.
 
 A batch of a private chat with a message of another person starts a
 turn, and so does a batch of a group with a message that mentions the
@@ -251,9 +252,15 @@ 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.
+and until then the tool says that they are not ready. The agent sends a
+text message with the tool ``whatsapp_send``, only when the user asks
+for it. Each send has an ID that the agent gives: a repeated call with
+the same ID gives the answer of the first call and sends nothing. The
+state file keeps the last 1000 send records. When the daemon stops
+during a send, the result is not known, and the tool tells the agent to
+ask the user to look at the chat in WhatsApp. Without a link, both tools
+tell 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.
index cd70250b2b999370931b20d4f327f07b04d142e0..fdcee0fadac2700566f0054684dedd7ec708b0b2 100644 (file)
--- a/prompt.go
+++ b/prompt.go
@@ -217,11 +217,21 @@ func promptTools() []ModelTool {
        }, 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.",
+               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. Give the ID to whatsapp_send.",
                Parameters: promptObjectSchema(map[string]any{
                        "name": promptStringSchema("A part of the name of the contact or the group."),
                }),
                Strict: true,
+       }, ModelTool{
+               Type:        "function",
+               Name:        "whatsapp_send",
+               Description: "Send a text message to a WhatsApp chat. Send only a message that the user asked you to send, to the chat that the user named. Give a new id for each message: a call with an id that was used gives the answer of the first call and sends nothing.",
+               Parameters: promptObjectSchema(map[string]any{
+                       "id":   promptStringSchema("A new id for this message, such as a short random word."),
+                       "chat": promptStringSchema("The ID of the chat, from whatsapp_find or from a [WhatsApp] message."),
+                       "text": promptStringSchema("The text of the message."),
+               }),
+               Strict: true,
        })
 }
 
index 4aa985f6dcd49ed8bfa4de4f91be1240839e1fe8..f1fe8f8cda3c0a94ea5cda119b5b216e48bee7bb 100644 (file)
@@ -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", "whatsapp_find"}, promptToolNames(tools))
+       require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "tell", "whatsapp_find", "whatsapp_send"}, promptToolNames(tools))
        require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, promptToolNames(promptSubagentTools()))
 
        for _, tool := range tools {
index b20b3ace6f22bf38b40b4681488e584cdc62c04e..7640dee9f3ff18c7ecfa456967c3621239cd370c 100644 (file)
--- a/tools.go
+++ b/tools.go
@@ -162,6 +162,15 @@ func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
                case err == nil:
                        output, err = b.whatsapp.Find(ctx, args.Name)
                }
+       case "whatsapp_send":
+               var args ToolWhatsAppSendArguments
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.whatsapp == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output, err = b.whatsapp.Send(ctx, args.ID, args.Chat, args.Text)
+               }
        default:
                err = fmt.Errorf("unknown tool %q", name)
        }
@@ -408,6 +417,13 @@ type ToolWhatsAppFindArguments struct {
        Name string `json:"name"`
 }
 
+// ToolWhatsAppSendArguments are the arguments of whatsapp_send.
+type ToolWhatsAppSendArguments struct {
+       ID   string `json:"id"`
+       Chat string `json:"chat"`
+       Text string `json:"text"`
+}
+
 // ToolHeadTail keeps the first and the last toolClipBytes bytes written to it,
 // and counts the bytes between them that it drops.
 type ToolHeadTail struct {
index 0e71e98c2770c3113a20e58624c505be87897aa0..4268a168fa1d4069f79b61b46b60119f6dc8fb32 100644 (file)
@@ -3,6 +3,8 @@ package main
 import (
        "cmp"
        "context"
+       "crypto/sha256"
+       "encoding/hex"
        "errors"
        "fmt"
        "log"
@@ -17,6 +19,7 @@ import (
        "unicode"
 
        "go.mau.fi/whatsmeow"
+       "go.mau.fi/whatsmeow/proto/waE2E"
        "go.mau.fi/whatsmeow/store"
        "go.mau.fi/whatsmeow/store/sqlstore"
        "go.mau.fi/whatsmeow/types"
@@ -102,6 +105,20 @@ const (
        // 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."
 
+       // whatsappSendTime is the longest time of a send.
+       whatsappSendTime = 30 * time.Second
+
+       // whatsappSendBytes is the most bytes of the text of a send.
+       whatsappSendBytes = 65536
+
+       // whatsappSendIDBytes is the most bytes of the ID of a send.
+       whatsappSendIDBytes = 100
+
+       // The states of a send record.
+       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."
@@ -117,6 +134,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
@@ -140,7 +159,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
        }
 
        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
        }
@@ -185,9 +204,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.
+// The text messages go into batches, one chat at a time, as WhatsAppState 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
@@ -218,7 +237,7 @@ type WhatsApp struct {
        logouts      chan struct{}
        wake         chan struct{}
        failures     chan error
-       batches      *WhatsAppBatches
+       state        *WhatsAppState
 
        mutex          sync.Mutex
        container      *sqlstore.Container
@@ -275,7 +294,7 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err
                }
 
                var due <-chan time.Time
-               wait, open := w.batches.Wait(time.Now())
+               wait, open := w.state.Wait(time.Now())
                if open {
                        due = time.After(wait)
                }
@@ -551,11 +570,11 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error {
        var err error
        switch {
        case all || caughtUp:
-               err = w.batches.CloseAll()
+               err = w.state.CloseAll()
        case catchingUp:
                return nil
        default:
-               _, err = w.batches.CloseDue(time.Now())
+               _, err = w.state.CloseDue(time.Now())
        }
 
        if err != nil {
@@ -563,7 +582,7 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error {
        }
 
        var turns []Message
-       for _, batch := range w.batches.Closed() {
+       for _, batch := range w.state.Closed() {
                if batch.Chat.Group && batch.Chat.Name == "" {
                        batch.Chat.Name = w.groupName(ctx, batch.Chat.ID)
                }
@@ -585,7 +604,7 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error {
                        return fmt.Errorf("log a WhatsApp batch: %w", err)
                }
 
-               err = w.batches.Remove(batch.Number)
+               err = w.state.Remove(batch.Number)
                if err != nil {
                        return err
                }
@@ -999,7 +1018,7 @@ func (w *WhatsApp) receive(event *events.Message) bool {
                return true
        }
 
-       _, err := w.batches.Add(chat, message, time.Now())
+       _, err := w.state.Add(chat, message, time.Now())
        if err != nil {
                select {
                case w.failures <- err:
@@ -1183,6 +1202,88 @@ func (w *WhatsApp) redact(text string) string {
        return text
 }
 
+// Send sends text to chat, the ID of a private chat or a group, and returns the
+// answer of the tool. id names the send: a call with an id that has a record
+// gives the answer of the record and sends nothing, and the same id with
+// another chat or text is an error. The record "sending" is durable before the
+// send, so a stop of the daemon during the send gives an answer that the result
+// is unknown, and not a second send. A disconnected WhatsApp sends nothing and
+// makes no record.
+func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) {
+       w.mutex.Lock()
+       linked := w.linked
+       device := w.device
+       client := w.client
+       connected := w.connected
+       own := slices.Clone(w.own)
+       w.mutex.Unlock()
+
+       if !linked {
+               return whatsappNotLinked, nil
+       }
+
+       jid, err := whatsappSendCheck(id, chat, text, own)
+       if err != nil {
+               return "", err
+       }
+
+       digest := sha256.Sum256([]byte(text))
+       record := WhatsAppSend{
+               ID:     id,
+               Chat:   chat,
+               Digest: hex.EncodeToString(digest[:]),
+               State:  whatsappSending,
+               Time:   w.clock(),
+       }
+
+       held, found := w.state.Send(id)
+       switch {
+       case found && (held.Chat != record.Chat || held.Digest != record.Digest):
+               return "", fmt.Errorf("the id %s belongs to another message: give a new id for each message", id)
+       case found && held.State == whatsappSending:
+               return "The send with this id started, but its result is unknown, because the daemon stopped during it. Ask the user to look at the chat in WhatsApp before you send the message again with a new id.", nil
+       case found:
+               return held.Answer + " This is the answer of the first call with this id: nothing was sent again.", nil
+       case client == nil || !connected:
+               return "", errors.New("WhatsApp is disconnected, so nothing was sent: try again later")
+       }
+
+       record.MessageID = client.GenerateMessageID()
+       err = w.state.PutSend(record)
+       if err != nil {
+               return "", err
+       }
+
+       sendCtx, cancel := context.WithTimeout(ctx, whatsappSendTime)
+       defer cancel()
+
+       response, err := client.SendMessage(sendCtx, jid, &waE2E.Message{
+               Conversation: new(text),
+       }, whatsmeow.SendRequestExtra{
+               ID: record.MessageID,
+       })
+
+       record.State = whatsappSent
+       if err != nil {
+               record.State = whatsappFailed
+               record.Answer = "The send failed: " + w.redact(err.Error()) + "."
+       } else {
+               name := w.groupName(ctx, jid.String())
+               if jid.Server != types.GroupServer {
+                       name = whatsappContactName(ctx, device, jid, "")
+               }
+
+               record.Answer = "Sent to " + name + " at " + response.Timestamp.In(w.clock().Location()).Format("15:04") + "."
+       }
+
+       err = w.state.PutSend(record)
+       if err != nil {
+               return "", err
+       }
+
+       return record.Answer, nil
+}
+
 // WhatsAppMatch is one contact or group of Find.
 type WhatsAppMatch struct {
        id   string
@@ -1293,6 +1394,27 @@ func whatsappFindAnswer(name string, matches []WhatsAppMatch, groups bool) strin
        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.
+func whatsappSendCheck(id, chat, text string, own []string) (types.JID, error) {
+       jid, err := types.ParseJID(chat)
+       switch {
+       case strings.TrimSpace(id) == "" || len(id) > whatsappSendIDBytes:
+               return types.EmptyJID, fmt.Errorf("the id must have 1 to %d bytes", whatsappSendIDBytes)
+       case strings.TrimSpace(text) == "" || len(text) > whatsappSendBytes:
+               return types.EmptyJID, fmt.Errorf("the text must have 1 to %d bytes", whatsappSendBytes)
+       case err != nil || jid.User == "" || jid != jid.ToNonAD() || jid.String() != chat:
+               return types.EmptyJID, fmt.Errorf("%q is not the ID of a WhatsApp chat: find the ID with whatsapp_find", chat)
+       case jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer && jid.Server != types.GroupServer:
+               return types.EmptyJID, fmt.Errorf("%q is not a private chat or a group", chat)
+       case slices.Contains(own, jid.User):
+               return types.EmptyJID, errors.New("this is the chat of the user with itself: send to another chat")
+       }
+
+       return jid, nil
+}
+
 // whatsappOwnIDs returns the own number and the own LID that are not empty.
 func whatsappOwnIDs(ids ...string) []string {
        var own []string
index 37b78857129b9198bf8cbf69bdfaad1fe078977b..08dfb6aec76733faf0d58e8774836e9166cd4783 100644 (file)
@@ -1,32 +1,12 @@
 package main
 
 import (
-       "encoding/json"
-       "errors"
-       "fmt"
-       "os"
-       "path/filepath"
        "slices"
        "strings"
-       "sync"
        "time"
 )
 
 const (
-       // whatsappStateFileName is the state file in the WhatsApp directory:
-       // the batches that are not in the log yet.
-       whatsappStateFileName = "state.json"
-
-       // whatsappBatchQuiet is how long a chat is quiet before its batch
-       // closes.
-       whatsappBatchQuiet = 20 * time.Second
-
-       // whatsappBatchWindow is the longest time of a batch.
-       whatsappBatchWindow = 2 * time.Minute
-
-       // whatsappBatchMessages is the most messages of a batch.
-       whatsappBatchMessages = 50
-
        // whatsappBatchEmptyReply is the last line of a batch that starts a
        // turn.
        whatsappBatchEmptyReply = "If nothing in this batch needs the user, end your turn with an empty reply."
@@ -36,227 +16,6 @@ const (
        whatsappBatchDayLayout = "Monday 2006-01-02"
 )
 
-// openWhatsAppBatches reads the batches of the state file at path. A missing
-// file holds no batches. A batch closes after quiet with no message, or after
-// window at the latest.
-func openWhatsAppBatches(path string, quiet, window time.Duration) (*WhatsAppBatches, error) {
-       b := &WhatsAppBatches{
-               path:   path,
-               quiet:  quiet,
-               window: window,
-       }
-
-       data, err := os.ReadFile(path)
-       if errors.Is(err, os.ErrNotExist) {
-               return b, nil
-       }
-
-       if err != nil {
-               return nil, fmt.Errorf("read WhatsApp state: %w", err)
-       }
-
-       err = json.Unmarshal(data, &b.state)
-       if err != nil {
-               return nil, fmt.Errorf("%s: %w", path, err)
-       }
-
-       return b, nil
-}
-
-// WhatsAppBatches holds the batches of the WhatsApp messages that are not in
-// the log yet, open and closed, in the order of their first message. The
-// messages of one chat form one batch while the times of the messages are at
-// most quiet apart, at most window after the first, and the batch has fewer
-// than whatsappBatchMessages messages. The times are the WhatsApp times, so a
-// catch-up after a stop gives the same batches as live messages. A batch also
-// closes when quiet passes after the arrival of its last message, or window
-// after the arrival of its first message, in real time.
-//
-// Each change writes the state file whole, so the batches survive a restart.
-// The mutex protects the batches, because the event handler of the library adds
-// messages while Run closes and removes batches.
-type WhatsAppBatches struct {
-       path   string
-       quiet  time.Duration
-       window time.Duration
-
-       mutex sync.Mutex
-       state WhatsAppStateFile
-}
-
-// Add puts message of chat into the open batch of chat, or into a new batch,
-// with arrival as its time of arrival. A message that the batch holds already,
-// which the servers sent again, changes nothing. It returns true when a batch
-// closed.
-func (b *WhatsAppBatches) Add(chat WhatsAppChat, message WhatsAppMessage, arrival time.Time) (bool, error) {
-       b.mutex.Lock()
-       defer b.mutex.Unlock()
-
-       closed := false
-       index := b.open(chat.ID)
-       if index >= 0 {
-               batch := &b.state.Batches[index]
-               if slices.ContainsFunc(batch.Messages, func(held WhatsAppMessage) bool {
-                       return held.ID == message.ID
-               }) {
-                       return false, nil
-               }
-
-               if message.Time.Sub(batch.Messages[len(batch.Messages)-1].Time) > b.quiet || message.Time.Sub(batch.Messages[0].Time) > b.window {
-                       batch.Closed = true
-                       closed = true
-                       index = -1
-               }
-       }
-
-       if index < 0 {
-               b.state.Next++
-               b.state.Batches = append(b.state.Batches, WhatsAppBatch{
-                       Number: b.state.Next,
-                       Chat:   chat,
-                       First:  arrival,
-               })
-               index = len(b.state.Batches) - 1
-       }
-
-       batch := &b.state.Batches[index]
-       batch.Messages = append(batch.Messages, message)
-       batch.Last = arrival
-       if len(batch.Messages) >= whatsappBatchMessages {
-               batch.Closed = true
-               closed = true
-       }
-
-       return closed, b.save()
-}
-
-// CloseDue closes each open batch whose time is up at now and tells if one
-// closed.
-func (b *WhatsAppBatches) CloseDue(now time.Time) (bool, error) {
-       b.mutex.Lock()
-       defer b.mutex.Unlock()
-
-       closed := false
-       for index := range b.state.Batches {
-               batch := &b.state.Batches[index]
-               if !batch.Closed && !now.Before(b.deadline(*batch)) {
-                       batch.Closed = true
-                       closed = true
-               }
-       }
-
-       if !closed {
-               return false, nil
-       }
-
-       return true, b.save()
-}
-
-// CloseAll closes every open batch.
-func (b *WhatsAppBatches) CloseAll() error {
-       b.mutex.Lock()
-       defer b.mutex.Unlock()
-
-       for index := range b.state.Batches {
-               b.state.Batches[index].Closed = true
-       }
-
-       return b.save()
-}
-
-// Closed returns the closed batches in order.
-func (b *WhatsAppBatches) Closed() []WhatsAppBatch {
-       b.mutex.Lock()
-       defer b.mutex.Unlock()
-
-       var closed []WhatsAppBatch
-       for _, batch := range b.state.Batches {
-               if batch.Closed {
-                       closed = append(closed, batch)
-               }
-       }
-
-       return closed
-}
-
-// Remove removes the batch with number, which is in the log.
-func (b *WhatsAppBatches) Remove(number int) error {
-       b.mutex.Lock()
-       defer b.mutex.Unlock()
-
-       b.state.Batches = slices.DeleteFunc(b.state.Batches, func(batch WhatsAppBatch) bool {
-               return batch.Number == number
-       })
-
-       return b.save()
-}
-
-// Wait returns how long after now the first open batch closes, or false when no
-// batch is open.
-func (b *WhatsAppBatches) Wait(now time.Time) (time.Duration, bool) {
-       b.mutex.Lock()
-       defer b.mutex.Unlock()
-
-       var first time.Time
-       for _, batch := range b.state.Batches {
-               deadline := b.deadline(batch)
-               if !batch.Closed && (first.IsZero() || deadline.Before(first)) {
-                       first = deadline
-               }
-       }
-
-       if first.IsZero() {
-               return 0, false
-       }
-
-       return max(first.Sub(now), 0), true
-}
-
-// open returns the index of the open batch of chat, or -1. The caller holds the
-// mutex.
-func (b *WhatsAppBatches) open(chat string) int {
-       return slices.IndexFunc(b.state.Batches, func(batch WhatsAppBatch) bool {
-               return !batch.Closed && batch.Chat.ID == chat
-       })
-}
-
-// deadline returns when batch closes in real time.
-func (b *WhatsAppBatches) deadline(batch WhatsAppBatch) time.Time {
-       quiet := batch.Last.Add(b.quiet)
-       window := batch.First.Add(b.window)
-       if window.Before(quiet) {
-               return window
-       }
-
-       return quiet
-}
-
-// save writes the state file whole. The caller holds the mutex.
-func (b *WhatsAppBatches) save() error {
-       data, err := json.Marshal(b.state)
-       if err != nil {
-               return fmt.Errorf("encode WhatsApp state: %w", err)
-       }
-
-       err = os.MkdirAll(filepath.Dir(b.path), 0o700)
-       if err == nil {
-               err = writeFileAtomic(b.path, append(data, '\n'))
-       }
-
-       if err != nil {
-               return fmt.Errorf("save WhatsApp state: %w", err)
-       }
-
-       return nil
-}
-
-// WhatsAppStateFile is the content of the state file: the number of the last
-// batch and the batches that are not in the log yet.
-type WhatsAppStateFile struct {
-       Next    int             `json:"next"`
-       Batches []WhatsAppBatch `json:"batches"`
-}
-
 // WhatsAppBatch is one batch of the messages of one chat, with the arrival
 // times of its first and its last message.
 type WhatsAppBatch struct {
index 1a614406e795eee3459747889e57b1eaeb71fe65..1b413a5156f205d0489fd30425a13e12cd66171b 100644 (file)
 package main
 
 import (
-       "os"
-       "path/filepath"
        "testing"
        "time"
 
        "github.com/stretchr/testify/require"
 )
 
-func TestWhatsAppBatchesFollowTheTimesOfTheMessages(t *testing.T) {
-       testCases := []struct {
-               name  string
-               times []time.Duration
-               sizes []int
-       }{
-               {
-                       name:  "messages close together form one batch",
-                       times: []time.Duration{0, 10 * time.Second, 30 * time.Second},
-                       sizes: []int{3},
-               },
-               {
-                       name:  "a quiet chat starts a new batch",
-                       times: []time.Duration{0, 10 * time.Second, 31 * time.Second},
-                       sizes: []int{2, 1},
-               },
-               {
-                       name:  "a batch ends after two minutes",
-                       times: []time.Duration{0, 20 * time.Second, 40 * time.Second, 60 * time.Second, 80 * time.Second, 100 * time.Second, 120 * time.Second, 140 * time.Second},
-                       sizes: []int{7, 1},
-               },
-       }
-
-       for _, tc := range testCases {
-               t.Run(tc.name, func(t *testing.T) {
-                       batches := whatsappTestBatches(t, filepath.Join(t.TempDir(), whatsappStateFileName))
-                       start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
-                       for index, offset := range tc.times {
-                               _, err := batches.Add(whatsappTestChat, WhatsAppMessage{
-                                       ID:   string(rune('a' + index)),
-                                       Time: start.Add(offset),
-                                       Text: "hi",
-                               }, time.Now())
-                               require.NoError(t, err)
-                       }
-
-                       require.NoError(t, batches.CloseAll())
-                       var sizes []int
-                       for _, batch := range batches.Closed() {
-                               sizes = append(sizes, len(batch.Messages))
-                       }
-
-                       require.Equal(t, tc.sizes, sizes)
-               })
-       }
-}
-
-func TestWhatsAppBatchClosesAtFiftyMessages(t *testing.T) {
-       batches := whatsappTestBatches(t, filepath.Join(t.TempDir(), whatsappStateFileName))
-       start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
-       for index := range whatsappBatchMessages {
-               closed, err := batches.Add(whatsappTestChat, WhatsAppMessage{
-                       ID:   time.Duration(index).String(),
-                       Time: start,
-                       Text: "hi",
-               }, time.Now())
-               require.NoError(t, err)
-               require.Equal(t, index == whatsappBatchMessages-1, closed)
-       }
-
-       require.Len(t, batches.Closed(), 1)
-}
-
-func TestWhatsAppBatchClosesInRealTime(t *testing.T) {
-       batches := whatsappTestBatches(t, filepath.Join(t.TempDir(), whatsappStateFileName))
-       arrival := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
-       for index, offset := range []time.Duration{0, 15 * time.Second, 30 * time.Second} {
-               _, err := batches.Add(whatsappTestChat, WhatsAppMessage{
-                       ID:   string(rune('a' + index)),
-                       Time: arrival,
-                       Text: "hi",
-               }, arrival.Add(offset))
-               require.NoError(t, err)
-       }
-
-       wait, open := batches.Wait(arrival.Add(40 * time.Second))
-       require.True(t, open)
-       require.Equal(t, 10*time.Second, wait)
-
-       closed, err := batches.CloseDue(arrival.Add(49 * time.Second))
-       require.NoError(t, err)
-       require.False(t, closed)
-
-       closed, err = batches.CloseDue(arrival.Add(50 * time.Second))
-       require.NoError(t, err)
-       require.True(t, closed)
-
-       _, open = batches.Wait(arrival.Add(50 * time.Second))
-       require.False(t, open)
-}
-
-func TestWhatsAppBatchesSurviveARestart(t *testing.T) {
-       path := filepath.Join(t.TempDir(), whatsappDirName, whatsappStateFileName)
-       batches := whatsappTestBatches(t, path)
-       for _, id := range []string{"a", "a", "b"} {
-               _, err := batches.Add(whatsappTestChat, WhatsAppMessage{
-                       ID:   id,
-                       Time: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
-                       Text: "hi " + id,
-               }, time.Now())
-               require.NoError(t, err)
-       }
-
-       info, err := os.Stat(path)
-       require.NoError(t, err)
-       require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
-
-       again := whatsappTestBatches(t, path)
-       require.NoError(t, again.CloseAll())
-       closed := again.Closed()
-       require.Len(t, closed, 1)
-       require.Equal(t, []string{"hi a", "hi b"}, []string{closed[0].Messages[0].Text, closed[0].Messages[1].Text})
-
-       require.NoError(t, again.Remove(closed[0].Number))
-       require.Empty(t, whatsappTestBatches(t, path).Closed())
-}
-
-func TestWhatsAppBatchesRefuseABrokenStateFile(t *testing.T) {
-       path := filepath.Join(t.TempDir(), whatsappStateFileName)
-       require.NoError(t, os.WriteFile(path, []byte("{"), 0o600))
-
-       _, err := openWhatsAppBatches(path, whatsappBatchQuiet, whatsappBatchWindow)
-       require.ErrorContains(t, err, path)
-}
-
 func TestWhatsAppBatchText(t *testing.T) {
        at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
        testCases := []struct {
@@ -226,14 +99,3 @@ func TestWhatsAppBatchText(t *testing.T) {
                })
        }
 }
-
-// whatsappTestBatches opens the batches of the state file at path with the
-// times of the plan.
-func whatsappTestBatches(t *testing.T, path string) *WhatsAppBatches {
-       t.Helper()
-
-       batches, err := openWhatsAppBatches(path, whatsappBatchQuiet, whatsappBatchWindow)
-       require.NoError(t, err)
-
-       return batches
-}
index 7cb61b3495e0cb65c6e7763adc7852744dd0ce53..e463cd094605600ec6a11497baced15929f03a38 100644 (file)
@@ -2,10 +2,12 @@ package main
 
 import (
        "context"
+       "fmt"
        "io"
        "log"
        "os"
        "path/filepath"
+       "slices"
        "sync"
        "testing"
        "time"
@@ -52,6 +54,9 @@ var (
                Name: "Ana Souza",
        }
 
+       // whatsappTestSendTime is the time of the sends of the fake.
+       whatsappTestSendTime = time.Date(2026, 1, 1, 12, 5, 0, 0, time.UTC)
+
        // whatsappTestGroup is the chat of whatsappTestFamily.
        whatsappTestGroup = WhatsAppChat{
                ID:    whatsappTestFamily.String(),
@@ -64,8 +69,9 @@ var (
 // daemon gives it, and Emit gives events to the handler, as the library does.
 // Each connection sends to connects, and each pairing sends its channel to
 // pairings, so that the test gives the items of the pairing. A connection fails
-// with connectErr, and a logout with logoutErr. groups are the groups of the
-// account.
+// with connectErr, a logout with logoutErr, and a send with sendErr. groups are
+// the groups of the account. A send that does not fail gets the time
+// whatsappTestSendTime.
 type WhatsAppFake struct {
        connects chan struct{}
        pairings chan chan whatsmeow.QRChannelItem
@@ -75,8 +81,10 @@ type WhatsAppFake struct {
        connectErr error
        logoutErr  error
        groups     []*types.GroupInfo
+       sendErr    error
        phones     []string
        logouts    int
+       sent       []WhatsAppFakeSend
 }
 
 // ConnectContext tells the test that the daemon connected.
@@ -129,6 +137,42 @@ func (f *WhatsAppFake) GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo,
        return f.groups, nil
 }
 
+// GenerateMessageID returns a new message ID.
+func (f *WhatsAppFake) GenerateMessageID() types.MessageID {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return fmt.Sprintf("MSG%d", len(f.sent)+1)
+}
+
+// SendMessage keeps the send and returns sendErr.
+func (f *WhatsAppFake) SendMessage(ctx context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error) {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       f.sent = append(f.sent, WhatsAppFakeSend{
+               to:   to.String(),
+               text: message.GetConversation(),
+               id:   extra[0].ID,
+       })
+
+       if f.sendErr != nil {
+               return whatsmeow.SendResponse{}, f.sendErr
+       }
+
+       return whatsmeow.SendResponse{
+               Timestamp: whatsappTestSendTime,
+       }, nil
+}
+
+// Sent returns the sends.
+func (f *WhatsAppFake) Sent() []WhatsAppFakeSend {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return slices.Clone(f.sent)
+}
+
 // Phones returns the phone numbers of the pairings.
 func (f *WhatsAppFake) Phones() []string {
        f.mutex.Lock()
@@ -164,6 +208,14 @@ func (f *WhatsAppFake) newClient(device *store.Device, handle func(event any) bo
        return f
 }
 
+// WhatsAppFakeSend is one send of the fake: the chat, the text, and the message
+// ID.
+type WhatsAppFakeSend struct {
+       to   string
+       text string
+       id   string
+}
+
 // whatsappStartTest opens the WhatsApp of the data directory dir with a fake
 // client and the test clock, and closes it at the end of the test. The daemon
 // log goes to the returned buffer.
diff --git a/whatsapp_state.go b/whatsapp_state.go
new file mode 100644 (file)
index 0000000..b8929f0
--- /dev/null
@@ -0,0 +1,310 @@
+package main
+
+import (
+       "encoding/json"
+       "errors"
+       "fmt"
+       "os"
+       "path/filepath"
+       "slices"
+       "sync"
+       "time"
+)
+
+const (
+       // whatsappStateFileName is the state file in the WhatsApp directory:
+       // the batches that are not in the log yet and the records of the sends.
+       whatsappStateFileName = "state.json"
+
+       // whatsappBatchQuiet is how long a chat is quiet before its batch
+       // closes.
+       whatsappBatchQuiet = 20 * time.Second
+
+       // whatsappBatchWindow is the longest time of a batch.
+       whatsappBatchWindow = 2 * time.Minute
+
+       // whatsappBatchMessages is the most messages of a batch.
+       whatsappBatchMessages = 50
+
+       // whatsappSendRecords is the most send records that the state file
+       // keeps.
+       whatsappSendRecords = 1000
+)
+
+// openWhatsAppState reads the batches and the send records of the state file at
+// path. A missing file holds none. A batch closes after quiet with no message,
+// or after window at the latest.
+func openWhatsAppState(path string, quiet, window time.Duration) (*WhatsAppState, error) {
+       s := &WhatsAppState{
+               path:   path,
+               quiet:  quiet,
+               window: window,
+       }
+
+       data, err := os.ReadFile(path)
+       if errors.Is(err, os.ErrNotExist) {
+               return s, nil
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("read WhatsApp state: %w", err)
+       }
+
+       err = json.Unmarshal(data, &s.file)
+       if err != nil {
+               return nil, fmt.Errorf("%s: %w", path, err)
+       }
+
+       return s, nil
+}
+
+// WhatsAppState holds the state file: the send records, and the batches of the
+// WhatsApp messages that are not in the log yet, open and closed, in the order
+// of their first message. The messages of one chat form one batch while the
+// times of the messages are at most quiet apart, at most window after the
+// first, and the batch has fewer than whatsappBatchMessages messages. The times
+// are the WhatsApp times, so a catch-up after a stop gives the same batches as
+// live messages. A batch also closes when quiet passes after the arrival of its
+// last message, or window after the arrival of its first message, in real time.
+//
+// A send record keeps the result of a send by its ID, so that a repeated call
+// of the send tool does not send again. The state file keeps the last
+// whatsappSendRecords records.
+//
+// Each change writes the state file whole, so the batches and the records
+// survive a restart. The mutex protects them, because the event handler of the
+// library adds messages while Run closes and removes batches, and the turns
+// send.
+type WhatsAppState struct {
+       path   string
+       quiet  time.Duration
+       window time.Duration
+
+       mutex sync.Mutex
+       file  WhatsAppStateFile
+}
+
+// Add puts message of chat into the open batch of chat, or into a new batch,
+// with arrival as its time of arrival. A message that the batch holds already,
+// which the servers sent again, changes nothing. It returns true when a batch
+// closed.
+func (s *WhatsAppState) Add(chat WhatsAppChat, message WhatsAppMessage, arrival time.Time) (bool, error) {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       closed := false
+       index := s.open(chat.ID)
+       if index >= 0 {
+               batch := &s.file.Batches[index]
+               if slices.ContainsFunc(batch.Messages, func(held WhatsAppMessage) bool {
+                       return held.ID == message.ID
+               }) {
+                       return false, nil
+               }
+
+               if message.Time.Sub(batch.Messages[len(batch.Messages)-1].Time) > s.quiet || message.Time.Sub(batch.Messages[0].Time) > s.window {
+                       batch.Closed = true
+                       closed = true
+                       index = -1
+               }
+       }
+
+       if index < 0 {
+               s.file.Next++
+               s.file.Batches = append(s.file.Batches, WhatsAppBatch{
+                       Number: s.file.Next,
+                       Chat:   chat,
+                       First:  arrival,
+               })
+               index = len(s.file.Batches) - 1
+       }
+
+       batch := &s.file.Batches[index]
+       batch.Messages = append(batch.Messages, message)
+       batch.Last = arrival
+       if len(batch.Messages) >= whatsappBatchMessages {
+               batch.Closed = true
+               closed = true
+       }
+
+       return closed, s.save()
+}
+
+// CloseDue closes each open batch whose time is up at now and tells if one
+// closed.
+func (s *WhatsAppState) CloseDue(now time.Time) (bool, error) {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       closed := false
+       for index := range s.file.Batches {
+               batch := &s.file.Batches[index]
+               if !batch.Closed && !now.Before(s.deadline(*batch)) {
+                       batch.Closed = true
+                       closed = true
+               }
+       }
+
+       if !closed {
+               return false, nil
+       }
+
+       return true, s.save()
+}
+
+// CloseAll closes every open batch.
+func (s *WhatsAppState) CloseAll() error {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       for index := range s.file.Batches {
+               s.file.Batches[index].Closed = true
+       }
+
+       return s.save()
+}
+
+// Closed returns the closed batches in order.
+func (s *WhatsAppState) Closed() []WhatsAppBatch {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       var closed []WhatsAppBatch
+       for _, batch := range s.file.Batches {
+               if batch.Closed {
+                       closed = append(closed, batch)
+               }
+       }
+
+       return closed
+}
+
+// Remove removes the batch with number, which is in the log.
+func (s *WhatsAppState) Remove(number int) error {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       s.file.Batches = slices.DeleteFunc(s.file.Batches, func(batch WhatsAppBatch) bool {
+               return batch.Number == number
+       })
+
+       return s.save()
+}
+
+// Wait returns how long after now the first open batch closes, or false when no
+// batch is open.
+func (s *WhatsAppState) Wait(now time.Time) (time.Duration, bool) {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       var first time.Time
+       for _, batch := range s.file.Batches {
+               deadline := s.deadline(batch)
+               if !batch.Closed && (first.IsZero() || deadline.Before(first)) {
+                       first = deadline
+               }
+       }
+
+       if first.IsZero() {
+               return 0, false
+       }
+
+       return max(first.Sub(now), 0), true
+}
+
+// Send returns the send record id, or false when there is none.
+func (s *WhatsAppState) Send(id string) (WhatsAppSend, bool) {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       index := slices.IndexFunc(s.file.Sends, func(record WhatsAppSend) bool {
+               return record.ID == id
+       })
+
+       if index < 0 {
+               return WhatsAppSend{}, false
+       }
+
+       return s.file.Sends[index], true
+}
+
+// PutSend writes record in the place of the record with its ID, or after the
+// last record, and drops the oldest records past whatsappSendRecords.
+func (s *WhatsAppState) PutSend(record WhatsAppSend) error {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       index := slices.IndexFunc(s.file.Sends, func(held WhatsAppSend) bool {
+               return held.ID == record.ID
+       })
+
+       if index >= 0 {
+               s.file.Sends[index] = record
+       } else {
+               s.file.Sends = append(s.file.Sends, record)
+       }
+
+       s.file.Sends = s.file.Sends[max(len(s.file.Sends)-whatsappSendRecords, 0):]
+
+       return s.save()
+}
+
+// open returns the index of the open batch of chat, or -1. The caller holds the
+// mutex.
+func (s *WhatsAppState) open(chat string) int {
+       return slices.IndexFunc(s.file.Batches, func(batch WhatsAppBatch) bool {
+               return !batch.Closed && batch.Chat.ID == chat
+       })
+}
+
+// deadline returns when batch closes in real time.
+func (s *WhatsAppState) deadline(batch WhatsAppBatch) time.Time {
+       quiet := batch.Last.Add(s.quiet)
+       window := batch.First.Add(s.window)
+       if window.Before(quiet) {
+               return window
+       }
+
+       return quiet
+}
+
+// save writes the state file whole. The caller holds the mutex.
+func (s *WhatsAppState) save() error {
+       data, err := json.Marshal(s.file)
+       if err != nil {
+               return fmt.Errorf("encode WhatsApp state: %w", err)
+       }
+
+       err = os.MkdirAll(filepath.Dir(s.path), 0o700)
+       if err == nil {
+               err = writeFileAtomic(s.path, append(data, '\n'))
+       }
+
+       if err != nil {
+               return fmt.Errorf("save WhatsApp state: %w", err)
+       }
+
+       return nil
+}
+
+// WhatsAppStateFile is the content of the state file: the number of the last
+// batch, the batches that are not in the log yet, and the send records, oldest
+// first.
+type WhatsAppStateFile struct {
+       Next    int             `json:"next"`
+       Batches []WhatsAppBatch `json:"batches"`
+       Sends   []WhatsAppSend  `json:"sends"`
+}
+
+// WhatsAppSend is the record of one send: the ID that the agent gave, the chat,
+// the SHA-256 of the text, the WhatsApp message ID, the state, which is
+// sending, sent, or failed, the answer of the tool, and the time of its start.
+type WhatsAppSend struct {
+       ID        string    `json:"id"`
+       Chat      string    `json:"chat"`
+       Digest    string    `json:"digest"`
+       MessageID string    `json:"message_id"`
+       State     string    `json:"state"`
+       Answer    string    `json:"answer,omitempty"`
+       Time      time.Time `json:"time"`
+}
diff --git a/whatsapp_state_test.go b/whatsapp_state_test.go
new file mode 100644 (file)
index 0000000..8f3d44a
--- /dev/null
@@ -0,0 +1,163 @@
+package main
+
+import (
+       "fmt"
+       "os"
+       "path/filepath"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestWhatsAppBatchesFollowTheTimesOfTheMessages(t *testing.T) {
+       testCases := []struct {
+               name  string
+               times []time.Duration
+               sizes []int
+       }{
+               {
+                       name:  "messages close together form one batch",
+                       times: []time.Duration{0, 10 * time.Second, 30 * time.Second},
+                       sizes: []int{3},
+               },
+               {
+                       name:  "a quiet chat starts a new batch",
+                       times: []time.Duration{0, 10 * time.Second, 31 * time.Second},
+                       sizes: []int{2, 1},
+               },
+               {
+                       name:  "a batch ends after two minutes",
+                       times: []time.Duration{0, 20 * time.Second, 40 * time.Second, 60 * time.Second, 80 * time.Second, 100 * time.Second, 120 * time.Second, 140 * time.Second},
+                       sizes: []int{7, 1},
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       batches := whatsappTestState(t, filepath.Join(t.TempDir(), whatsappStateFileName))
+                       start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+                       for index, offset := range tc.times {
+                               _, err := batches.Add(whatsappTestChat, WhatsAppMessage{
+                                       ID:   string(rune('a' + index)),
+                                       Time: start.Add(offset),
+                                       Text: "hi",
+                               }, time.Now())
+                               require.NoError(t, err)
+                       }
+
+                       require.NoError(t, batches.CloseAll())
+                       var sizes []int
+                       for _, batch := range batches.Closed() {
+                               sizes = append(sizes, len(batch.Messages))
+                       }
+
+                       require.Equal(t, tc.sizes, sizes)
+               })
+       }
+}
+
+func TestWhatsAppBatchClosesAtFiftyMessages(t *testing.T) {
+       batches := whatsappTestState(t, filepath.Join(t.TempDir(), whatsappStateFileName))
+       start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+       for index := range whatsappBatchMessages {
+               closed, err := batches.Add(whatsappTestChat, WhatsAppMessage{
+                       ID:   time.Duration(index).String(),
+                       Time: start,
+                       Text: "hi",
+               }, time.Now())
+               require.NoError(t, err)
+               require.Equal(t, index == whatsappBatchMessages-1, closed)
+       }
+
+       require.Len(t, batches.Closed(), 1)
+}
+
+func TestWhatsAppBatchClosesInRealTime(t *testing.T) {
+       batches := whatsappTestState(t, filepath.Join(t.TempDir(), whatsappStateFileName))
+       arrival := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+       for index, offset := range []time.Duration{0, 15 * time.Second, 30 * time.Second} {
+               _, err := batches.Add(whatsappTestChat, WhatsAppMessage{
+                       ID:   string(rune('a' + index)),
+                       Time: arrival,
+                       Text: "hi",
+               }, arrival.Add(offset))
+               require.NoError(t, err)
+       }
+
+       wait, open := batches.Wait(arrival.Add(40 * time.Second))
+       require.True(t, open)
+       require.Equal(t, 10*time.Second, wait)
+
+       closed, err := batches.CloseDue(arrival.Add(49 * time.Second))
+       require.NoError(t, err)
+       require.False(t, closed)
+
+       closed, err = batches.CloseDue(arrival.Add(50 * time.Second))
+       require.NoError(t, err)
+       require.True(t, closed)
+
+       _, open = batches.Wait(arrival.Add(50 * time.Second))
+       require.False(t, open)
+}
+
+func TestWhatsAppBatchesSurviveARestart(t *testing.T) {
+       path := filepath.Join(t.TempDir(), whatsappDirName, whatsappStateFileName)
+       batches := whatsappTestState(t, path)
+       for _, id := range []string{"a", "a", "b"} {
+               _, err := batches.Add(whatsappTestChat, WhatsAppMessage{
+                       ID:   id,
+                       Time: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
+                       Text: "hi " + id,
+               }, time.Now())
+               require.NoError(t, err)
+       }
+
+       info, err := os.Stat(path)
+       require.NoError(t, err)
+       require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+
+       again := whatsappTestState(t, path)
+       require.NoError(t, again.CloseAll())
+       closed := again.Closed()
+       require.Len(t, closed, 1)
+       require.Equal(t, []string{"hi a", "hi b"}, []string{closed[0].Messages[0].Text, closed[0].Messages[1].Text})
+
+       require.NoError(t, again.Remove(closed[0].Number))
+       require.Empty(t, whatsappTestState(t, path).Closed())
+}
+
+func TestWhatsAppStateRefusesABrokenFile(t *testing.T) {
+       path := filepath.Join(t.TempDir(), whatsappStateFileName)
+       require.NoError(t, os.WriteFile(path, []byte("{"), 0o600))
+
+       _, err := openWhatsAppState(path, whatsappBatchQuiet, whatsappBatchWindow)
+       require.ErrorContains(t, err, path)
+}
+
+func TestWhatsAppStateKeepsTheLastSendRecords(t *testing.T) {
+       state := whatsappTestState(t, filepath.Join(t.TempDir(), whatsappStateFileName))
+       for index := range whatsappSendRecords + 1 {
+               require.NoError(t, state.PutSend(WhatsAppSend{
+                       ID:    fmt.Sprint(index),
+                       State: whatsappSent,
+               }))
+       }
+
+       _, found := state.Send("0")
+       require.False(t, found)
+
+       _, found = state.Send(fmt.Sprint(whatsappSendRecords))
+       require.True(t, found)
+}
+
+// whatsappTestState opens the batches of the state file at path with the
+// times of the plan.
+func whatsappTestState(t *testing.T, path string) *WhatsAppState {
+       t.Helper()
+
+       batches, err := openWhatsAppState(path, whatsappBatchQuiet, whatsappBatchWindow)
+       require.NoError(t, err)
+
+       return batches
+}
index 437a8debec492becf5fdd9130328745f3a2bde62..0ba27146bfb62d35c1991c33c58a82cc6dbef6de 100644 (file)
@@ -1,6 +1,8 @@
 package main
 
 import (
+       "crypto/sha256"
+       "encoding/hex"
        "errors"
        "fmt"
        "net/http"
@@ -503,7 +505,7 @@ func TestWhatsAppPrivateBatchStartsATurnWithAnEmptyReply(t *testing.T) {
        require.Equal(t, []string{text}, whatsappTestLogTexts(t, memory))
        require.Empty(t, telegram.Sent())
        require.Eventually(t, func() bool {
-               return len(whatsapp.batches.Closed()) == 0
+               return len(whatsapp.state.Closed()) == 0
        }, 10*time.Second, time.Millisecond)
 }
 
@@ -657,8 +659,8 @@ func TestWhatsAppTakesOnlyText(t *testing.T) {
                        event := whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "hi", at)
                        tc.change(event)
                        require.True(t, whatsapp.handle(event))
-                       require.NoError(t, whatsapp.batches.CloseAll())
-                       require.Empty(t, whatsapp.batches.Closed())
+                       require.NoError(t, whatsapp.state.CloseAll())
+                       require.Empty(t, whatsapp.state.Closed())
                })
        }
 }
@@ -671,7 +673,7 @@ func whatsappStartTestLinked(t *testing.T) (*WhatsApp, *WhatsAppFake) {
        dir := t.TempDir()
        whatsappTestSession(t, dir, true)
        whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
-       whatsapp.batches.quiet = 50 * time.Millisecond
+       whatsapp.state.quiet = 50 * time.Millisecond
 
        return whatsapp, fake
 }
@@ -727,11 +729,11 @@ func TestWhatsAppCatchupEndsAfterItsLimit(t *testing.T) {
        require.Len(t, whatsappTestLogTexts(t, memory), 1)
 }
 
-func TestWhatsAppBatchesOfTheStateFileWaitForTheCatchup(t *testing.T) {
+func TestWhatsAppStateOfTheStateFileWaitForTheCatchup(t *testing.T) {
        dir := t.TempDir()
        whatsappTestSession(t, dir, true)
        before, _, _ := whatsappStartTest(t, dir, newTestClock())
-       _, err := before.batches.Add(whatsappTestChat, WhatsAppMessage{
+       _, err := before.state.Add(whatsappTestChat, WhatsAppMessage{
                ID:     "m1",
                Sender: "Ana Souza",
                Time:   time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
@@ -874,6 +876,7 @@ func TestAgentFindsAWhatsAppContact(t *testing.T) {
 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
@@ -888,3 +891,194 @@ func whatsappTestContacts(t *testing.T, whatsapp *WhatsApp) {
        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)
+       daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
+       whatsappTestConnect(t, fake)
+
+       answer, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "The keys are under the mat.")
+       require.NoError(t, err)
+       require.Equal(t, "Sent to Ana Souza at 12:05.", answer)
+
+       answer, err = whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "The keys are under the mat.")
+       require.NoError(t, err)
+       require.Equal(t, "Sent to Ana Souza at 12:05. This is the answer of the first call with this id: nothing was sent again.", answer)
+
+       _, err = whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "Another text.")
+       require.EqualError(t, err, "the id keys belongs to another message: give a new id for each message")
+
+       require.Equal(t, []WhatsAppFakeSend{
+               {
+                       to:   whatsappTestAna.String(),
+                       text: "The keys are under the mat.",
+                       id:   "MSG1",
+               },
+       }, fake.Sent())
+
+       again, err := openWhatsAppState(filepath.Join(whatsapp.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow)
+       require.NoError(t, err)
+
+       record, found := again.Send("keys")
+       require.True(t, found)
+       require.Equal(t, whatsappSent, record.State)
+       require.Equal(t, "MSG1", record.MessageID)
+}
+
+func TestWhatsAppSendToAGroup(t *testing.T) {
+       whatsapp, fake := whatsappStartTestLinked(t)
+       fake.groups = []*types.GroupInfo{
+               {
+                       JID: whatsappTestFamily,
+                       GroupName: types.GroupName{
+                               Name: "Family",
+                       },
+               },
+       }
+
+       daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
+       whatsappTestConnect(t, fake)
+
+       answer, err := whatsapp.Send(t.Context(), "cake", whatsappTestFamily.String(), "I bring the cake.")
+       require.NoError(t, err)
+       require.Equal(t, "Sent to Family at 12:05.", answer)
+}
+
+func TestWhatsAppSendThatFails(t *testing.T) {
+       whatsapp, fake := whatsappStartTestLinked(t)
+       fake.sendErr = errors.New("server error for " + whatsappTestNumber)
+       daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
+       whatsappTestConnect(t, fake)
+
+       for range 2 {
+               answer, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "hi")
+               require.NoError(t, err)
+               require.True(t, strings.HasPrefix(answer, "The send failed: server error for OWN."), answer)
+       }
+
+       require.Len(t, fake.Sent(), 1)
+}
+
+func TestWhatsAppSendCutByAStop(t *testing.T) {
+       whatsapp, fake := whatsappStartTestLinked(t)
+       daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
+       whatsappTestConnect(t, fake)
+
+       digest := sha256.Sum256([]byte("hi"))
+       require.NoError(t, whatsapp.state.PutSend(WhatsAppSend{
+               ID:     "keys",
+               Chat:   whatsappTestAna.String(),
+               Digest: hex.EncodeToString(digest[:]),
+               State:  whatsappSending,
+       }))
+
+       answer, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "hi")
+       require.NoError(t, err)
+       require.Contains(t, answer, "its result is unknown")
+       require.Empty(t, fake.Sent())
+}
+
+func TestWhatsAppSendRefuses(t *testing.T) {
+       testCases := []struct {
+               name string
+               id   string
+               chat string
+               text string
+               err  string
+       }{
+               {
+                       name: "no id",
+                       id:   " ",
+                       chat: whatsappTestAna.String(),
+                       text: "hi",
+                       err:  "the id must have 1 to 100 bytes",
+               },
+               {
+                       name: "no text",
+                       id:   "a",
+                       chat: whatsappTestAna.String(),
+                       text: "",
+                       err:  "the text must have 1 to 65536 bytes",
+               },
+               {
+                       name: "a name for a chat",
+                       id:   "a",
+                       chat: "Ana Souza",
+                       text: "hi",
+                       err:  `"Ana Souza" is not the ID of a WhatsApp chat: find the ID with whatsapp_find`,
+               },
+               {
+                       name: "a status broadcast",
+                       id:   "a",
+                       chat: types.StatusBroadcastJID.String(),
+                       text: "hi",
+                       err:  `"status@broadcast" is not a private chat or a group`,
+               },
+               {
+                       name: "the chat of the user",
+                       id:   "a",
+                       chat: whatsappTestOwn.String(),
+                       text: "hi",
+                       err:  "this is the chat of the user with itself: send to another chat",
+               },
+       }
+
+       whatsapp, fake := whatsappStartTestLinked(t)
+       daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
+       whatsappTestConnect(t, fake)
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       _, err := whatsapp.Send(t.Context(), tc.id, tc.chat, tc.text)
+                       require.EqualError(t, err, tc.err)
+               })
+       }
+
+       require.Empty(t, fake.Sent())
+}
+
+func TestWhatsAppSendWhileDisconnected(t *testing.T) {
+       whatsapp, fake := whatsappStartTestLinked(t)
+       daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
+       whatsappTestConnect(t, fake)
+       require.True(t, fake.Emit(&events.Disconnected{}))
+
+       _, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "hi")
+       require.EqualError(t, err, "WhatsApp is disconnected, so nothing was sent: try again later")
+
+       require.True(t, fake.Emit(&events.Connected{}))
+       answer, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "hi")
+       require.NoError(t, err)
+       require.Equal(t, "Sent to +15550002222 at 12:05.", answer)
+}
+
+func TestWhatsAppSendWithoutALink(t *testing.T) {
+       whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
+
+       answer, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "hi")
+       require.NoError(t, err)
+       require.Equal(t, whatsappNotLinked, answer)
+}
+
+func TestAgentSendsAWhatsAppMessage(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_send", `{"id":"keys","chat":"15550002222@s.whatsapp.net","text":"The keys are under the mat."}`)}, 100, 0, 5))
+                       return
+               }
+
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Sent.")}, 100, 0, 5))
+       }
+
+       socket, _ := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
+       client := terminalStartTestClient(t, socket)
+       whatsappTestConnect(t, fake)
+
+       client.Type("Tell Ana that the keys are under the mat.")
+       client.WaitFor(t, "[result] Sent to Ana Souza at 12:05.\nSent.\n[done]")
+       require.Len(t, fake.Sent(), 1)
+}