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" // whatsappBatchIdleTimeout is how long a chat is quiet before its batch // closes. whatsappBatchIdleTimeout = 20 * time.Second // whatsappBatchTimeout is the longest time of a batch. whatsappBatchTimeout = 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 // whatsappBatchIdleTimeout with no message, or after whatsappBatchTimeout at // the latest. func openWhatsAppState(path string) (*WhatsAppState, error) { s := &WhatsAppState{ path: path, } 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 whatsappBatchIdleTimeout apart, at most // whatsappBatchTimeout 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 whatsappBatchIdleTimeout passes after the arrival of its last // message, or whatsappBatchTimeout 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 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) > whatsappBatchIdleTimeout || message.Time.Sub(batch.Messages[0].Time) > whatsappBatchTimeout { 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(whatsappBatchIdleTimeout) window := batch.First.Add(whatsappBatchTimeout) 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"` }