From: Anderson A. Giacomolli Date: Sun, 8 Nov 2026 15:26:50 +0000 (-0300) Subject: Send WhatsApp messages with an ID X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/6e7c7f3d47f086b9d59fe40b91fb071e1528684e?hp=f0604a9ebbee5783d5213d6176191535f4ed335b Send WhatsApp messages with an ID 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 --- diff --git a/README.rst b/README.rst index 457cb19..34d0d49 100644 --- a/README.rst +++ b/README.rst @@ -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. diff --git a/prompt.go b/prompt.go index cd70250..fdcee0f 100644 --- 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, }) } diff --git a/prompt_test.go b/prompt_test.go index 4aa985f..f1fe8f8 100644 --- a/prompt_test.go +++ b/prompt_test.go @@ -77,7 +77,7 @@ func TestPromptWithoutInstructionsHasNoClause(t *testing.T) { func TestAgentToolsAreStrictAndStable(t *testing.T) { tools := promptTools() - require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "tell", "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 { diff --git a/tools.go b/tools.go index b20b3ac..7640dee 100644 --- 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 { diff --git a/whatsapp.go b/whatsapp.go index 0e71e98..4268a16 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -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 diff --git a/whatsapp_batch.go b/whatsapp_batch.go index 37b7885..08dfb6a 100644 --- a/whatsapp_batch.go +++ b/whatsapp_batch.go @@ -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 { diff --git a/whatsapp_batch_test.go b/whatsapp_batch_test.go index 1a61440..1b413a5 100644 --- a/whatsapp_batch_test.go +++ b/whatsapp_batch_test.go @@ -1,139 +1,12 @@ 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 -} diff --git a/whatsapp_fake_helper_test.go b/whatsapp_fake_helper_test.go index 7cb61b3..e463cd0 100644 --- a/whatsapp_fake_helper_test.go +++ b/whatsapp_fake_helper_test.go @@ -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 index 0000000..b8929f0 --- /dev/null +++ b/whatsapp_state.go @@ -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 index 0000000..8f3d44a --- /dev/null +++ b/whatsapp_state_test.go @@ -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 +} diff --git a/whatsapp_test.go b/whatsapp_test.go index 437a8de..0ba2714 100644 --- a/whatsapp_test.go +++ b/whatsapp_test.go @@ -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) +}