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." // whatsappBatchDayLayout is the layout of the day in the first line of // a batch. 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 { Number int `json:"number"` Chat WhatsAppChat `json:"chat"` Closed bool `json:"closed"` First time.Time `json:"first"` Last time.Time `json:"last"` Messages []WhatsAppMessage `json:"messages"` } // Turn tells if the batch starts a turn: a private batch with a message of // another person, or a group batch with a message of another person that // mentions the user or replies to a message of the user. func (b WhatsAppBatch) Turn() bool { return slices.ContainsFunc(b.Messages, func(message WhatsAppMessage) bool { return !message.Mine && (!b.Chat.Group || message.Mention || message.Reply) }) } // Text returns the text of the batch for the log, with the times in location. // The first line starts with [WhatsApp] and names the chat, its ID, and the day // of the first message. Each message has a line with its time and its sender, // and the messages of the user have the sender You. A batch that starts a turn // ends with the line about the empty reply. func (b WhatsAppBatch) Text(location *time.Location) string { kind := "private chat" if b.Chat.Group { kind = "group" } lines := []string{"[WhatsApp] " + b.Chat.Name + ", " + kind + " " + b.Chat.ID + ", " + b.Messages[0].Time.In(location).Format(whatsappBatchDayLayout)} for _, message := range b.Messages { sender := message.Sender switch { case message.Mine: sender = "You" case message.Mention: sender += " (mentions you)" case message.Reply: sender += " (replies to you)" } lines = append(lines, message.Time.In(location).Format("15:04")+" "+sender+": "+message.Text) } if b.Turn() { lines = append(lines, whatsappBatchEmptyReply) } return strings.Join(lines, "\n") } // WhatsAppChat is a chat: its ID, its name, and whether it is a group. type WhatsAppChat struct { ID string `json:"id"` Name string `json:"name"` Group bool `json:"group,omitempty"` } // WhatsAppMessage is one text message of a batch: its ID, the name of its // sender, its WhatsApp time, and its text. Mine marks a message of the user. // Mention and Reply mark a message that mentions the user or replies to a // message of the user. type WhatsAppMessage struct { ID string `json:"id"` Sender string `json:"sender"` Time time.Time `json:"time"` Text string `json:"text"` Mine bool `json:"mine,omitempty"` Mention bool `json:"mention,omitempty"` Reply bool `json:"reply,omitempty"` }