X-Git-Url: http://git.giacomolli.uk/onechat/blobdiff_plain/56d959f2d573eef1be5c04d0bf1659fa30d36e7c..ae8532ff0e9d3d676fec5f48780a62e58aa6882d:/whatsapp_batch.go 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 {