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 = "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 {
// 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.
+// ends with the line about the empty reply. Image markers start on their own
+// lines so that the shared image reader can find them.
func (b WhatsAppBatch) Text(location *time.Location) string {
kind := "private chat"
if b.Chat.Group {
sender += " (replies to you)"
}
- lines = append(lines, message.Time.In(location).Format("15:04")+" "+sender+": "+message.Text)
+ prefix := message.Time.In(location).Format("15:04") + " " + sender + ":"
+ if len(imageFiles(message.Text)) > 0 {
+ lines = append(lines, prefix, message.Text)
+ } else {
+ lines = append(lines, prefix+" "+message.Text)
+ }
}
if b.Turn() {
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.
+// WhatsAppMessage is one 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"`