]> git.giacomolli.uk - onechat/blob - whatsapp_batch.go
Give the WhatsApp batches to the agent
[onechat] / whatsapp_batch.go
1 package main
2
3 import (
4         "encoding/json"
5         "errors"
6         "fmt"
7         "os"
8         "path/filepath"
9         "slices"
10         "strings"
11         "sync"
12         "time"
13 )
14
15 const (
16         // whatsappStateFileName is the state file in the WhatsApp directory:
17         // the batches that are not in the log yet.
18         whatsappStateFileName = "state.json"
19
20         // whatsappBatchQuiet is how long a chat is quiet before its batch
21         // closes.
22         whatsappBatchQuiet = 20 * time.Second
23
24         // whatsappBatchWindow is the longest time of a batch.
25         whatsappBatchWindow = 2 * time.Minute
26
27         // whatsappBatchMessages is the most messages of a batch.
28         whatsappBatchMessages = 50
29
30         // whatsappBatchEmptyReply is the last line of a batch that starts a
31         // turn.
32         whatsappBatchEmptyReply = "If nothing in this batch needs the user, end your turn with an empty reply."
33
34         // whatsappBatchDayLayout is the layout of the day in the first line of
35         // a batch.
36         whatsappBatchDayLayout = "Monday 2006-01-02"
37 )
38
39 // openWhatsAppBatches reads the batches of the state file at path. A missing
40 // file holds no batches. A batch closes after quiet with no message, or after
41 // window at the latest.
42 func openWhatsAppBatches(path string, quiet, window time.Duration) (*WhatsAppBatches, error) {
43         b := &WhatsAppBatches{
44                 path:   path,
45                 quiet:  quiet,
46                 window: window,
47         }
48
49         data, err := os.ReadFile(path)
50         if errors.Is(err, os.ErrNotExist) {
51                 return b, nil
52         }
53
54         if err != nil {
55                 return nil, fmt.Errorf("read WhatsApp state: %w", err)
56         }
57
58         err = json.Unmarshal(data, &b.state)
59         if err != nil {
60                 return nil, fmt.Errorf("%s: %w", path, err)
61         }
62
63         return b, nil
64 }
65
66 // WhatsAppBatches holds the batches of the WhatsApp messages that are not in
67 // the log yet, open and closed, in the order of their first message. The
68 // messages of one chat form one batch while the times of the messages are at
69 // most quiet apart, at most window after the first, and the batch has fewer
70 // than whatsappBatchMessages messages. The times are the WhatsApp times, so a
71 // catch-up after a stop gives the same batches as live messages. A batch also
72 // closes when quiet passes after the arrival of its last message, or window
73 // after the arrival of its first message, in real time.
74 //
75 // Each change writes the state file whole, so the batches survive a restart.
76 // The mutex protects the batches, because the event handler of the library adds
77 // messages while Run closes and removes batches.
78 type WhatsAppBatches struct {
79         path   string
80         quiet  time.Duration
81         window time.Duration
82
83         mutex sync.Mutex
84         state WhatsAppStateFile
85 }
86
87 // Add puts message of chat into the open batch of chat, or into a new batch,
88 // with arrival as its time of arrival. A message that the batch holds already,
89 // which the servers sent again, changes nothing. It returns true when a batch
90 // closed.
91 func (b *WhatsAppBatches) Add(chat WhatsAppChat, message WhatsAppMessage, arrival time.Time) (bool, error) {
92         b.mutex.Lock()
93         defer b.mutex.Unlock()
94
95         closed := false
96         index := b.open(chat.ID)
97         if index >= 0 {
98                 batch := &b.state.Batches[index]
99                 if slices.ContainsFunc(batch.Messages, func(held WhatsAppMessage) bool {
100                         return held.ID == message.ID
101                 }) {
102                         return false, nil
103                 }
104
105                 if message.Time.Sub(batch.Messages[len(batch.Messages)-1].Time) > b.quiet || message.Time.Sub(batch.Messages[0].Time) > b.window {
106                         batch.Closed = true
107                         closed = true
108                         index = -1
109                 }
110         }
111
112         if index < 0 {
113                 b.state.Next++
114                 b.state.Batches = append(b.state.Batches, WhatsAppBatch{
115                         Number: b.state.Next,
116                         Chat:   chat,
117                         First:  arrival,
118                 })
119                 index = len(b.state.Batches) - 1
120         }
121
122         batch := &b.state.Batches[index]
123         batch.Messages = append(batch.Messages, message)
124         batch.Last = arrival
125         if len(batch.Messages) >= whatsappBatchMessages {
126                 batch.Closed = true
127                 closed = true
128         }
129
130         return closed, b.save()
131 }
132
133 // CloseDue closes each open batch whose time is up at now and tells if one
134 // closed.
135 func (b *WhatsAppBatches) CloseDue(now time.Time) (bool, error) {
136         b.mutex.Lock()
137         defer b.mutex.Unlock()
138
139         closed := false
140         for index := range b.state.Batches {
141                 batch := &b.state.Batches[index]
142                 if !batch.Closed && !now.Before(b.deadline(*batch)) {
143                         batch.Closed = true
144                         closed = true
145                 }
146         }
147
148         if !closed {
149                 return false, nil
150         }
151
152         return true, b.save()
153 }
154
155 // CloseAll closes every open batch.
156 func (b *WhatsAppBatches) CloseAll() error {
157         b.mutex.Lock()
158         defer b.mutex.Unlock()
159
160         for index := range b.state.Batches {
161                 b.state.Batches[index].Closed = true
162         }
163
164         return b.save()
165 }
166
167 // Closed returns the closed batches in order.
168 func (b *WhatsAppBatches) Closed() []WhatsAppBatch {
169         b.mutex.Lock()
170         defer b.mutex.Unlock()
171
172         var closed []WhatsAppBatch
173         for _, batch := range b.state.Batches {
174                 if batch.Closed {
175                         closed = append(closed, batch)
176                 }
177         }
178
179         return closed
180 }
181
182 // Remove removes the batch with number, which is in the log.
183 func (b *WhatsAppBatches) Remove(number int) error {
184         b.mutex.Lock()
185         defer b.mutex.Unlock()
186
187         b.state.Batches = slices.DeleteFunc(b.state.Batches, func(batch WhatsAppBatch) bool {
188                 return batch.Number == number
189         })
190
191         return b.save()
192 }
193
194 // Wait returns how long after now the first open batch closes, or false when no
195 // batch is open.
196 func (b *WhatsAppBatches) Wait(now time.Time) (time.Duration, bool) {
197         b.mutex.Lock()
198         defer b.mutex.Unlock()
199
200         var first time.Time
201         for _, batch := range b.state.Batches {
202                 deadline := b.deadline(batch)
203                 if !batch.Closed && (first.IsZero() || deadline.Before(first)) {
204                         first = deadline
205                 }
206         }
207
208         if first.IsZero() {
209                 return 0, false
210         }
211
212         return max(first.Sub(now), 0), true
213 }
214
215 // open returns the index of the open batch of chat, or -1. The caller holds the
216 // mutex.
217 func (b *WhatsAppBatches) open(chat string) int {
218         return slices.IndexFunc(b.state.Batches, func(batch WhatsAppBatch) bool {
219                 return !batch.Closed && batch.Chat.ID == chat
220         })
221 }
222
223 // deadline returns when batch closes in real time.
224 func (b *WhatsAppBatches) deadline(batch WhatsAppBatch) time.Time {
225         quiet := batch.Last.Add(b.quiet)
226         window := batch.First.Add(b.window)
227         if window.Before(quiet) {
228                 return window
229         }
230
231         return quiet
232 }
233
234 // save writes the state file whole. The caller holds the mutex.
235 func (b *WhatsAppBatches) save() error {
236         data, err := json.Marshal(b.state)
237         if err != nil {
238                 return fmt.Errorf("encode WhatsApp state: %w", err)
239         }
240
241         err = os.MkdirAll(filepath.Dir(b.path), 0o700)
242         if err == nil {
243                 err = writeFileAtomic(b.path, append(data, '\n'))
244         }
245
246         if err != nil {
247                 return fmt.Errorf("save WhatsApp state: %w", err)
248         }
249
250         return nil
251 }
252
253 // WhatsAppStateFile is the content of the state file: the number of the last
254 // batch and the batches that are not in the log yet.
255 type WhatsAppStateFile struct {
256         Next    int             `json:"next"`
257         Batches []WhatsAppBatch `json:"batches"`
258 }
259
260 // WhatsAppBatch is one batch of the messages of one chat, with the arrival
261 // times of its first and its last message.
262 type WhatsAppBatch struct {
263         Number   int               `json:"number"`
264         Chat     WhatsAppChat      `json:"chat"`
265         Closed   bool              `json:"closed"`
266         First    time.Time         `json:"first"`
267         Last     time.Time         `json:"last"`
268         Messages []WhatsAppMessage `json:"messages"`
269 }
270
271 // Turn tells if the batch starts a turn: a private batch with a message of
272 // another person, or a group batch with a message of another person that
273 // mentions the user or replies to a message of the user.
274 func (b WhatsAppBatch) Turn() bool {
275         return slices.ContainsFunc(b.Messages, func(message WhatsAppMessage) bool {
276                 return !message.Mine && (!b.Chat.Group || message.Mention || message.Reply)
277         })
278 }
279
280 // Text returns the text of the batch for the log, with the times in location.
281 // The first line starts with [WhatsApp] and names the chat, its ID, and the day
282 // of the first message. Each message has a line with its time and its sender,
283 // and the messages of the user have the sender You. A batch that starts a turn
284 // ends with the line about the empty reply.
285 func (b WhatsAppBatch) Text(location *time.Location) string {
286         kind := "private chat"
287         if b.Chat.Group {
288                 kind = "group"
289         }
290
291         lines := []string{"[WhatsApp] " + b.Chat.Name + ", " + kind + " " + b.Chat.ID + ", " + b.Messages[0].Time.In(location).Format(whatsappBatchDayLayout)}
292         for _, message := range b.Messages {
293                 sender := message.Sender
294                 switch {
295                 case message.Mine:
296                         sender = "You"
297                 case message.Mention:
298                         sender += " (mentions you)"
299                 case message.Reply:
300                         sender += " (replies to you)"
301                 }
302
303                 lines = append(lines, message.Time.In(location).Format("15:04")+" "+sender+": "+message.Text)
304         }
305
306         if b.Turn() {
307                 lines = append(lines, whatsappBatchEmptyReply)
308         }
309
310         return strings.Join(lines, "\n")
311 }
312
313 // WhatsAppChat is a chat: its ID, its name, and whether it is a group.
314 type WhatsAppChat struct {
315         ID    string `json:"id"`
316         Name  string `json:"name"`
317         Group bool   `json:"group,omitempty"`
318 }
319
320 // WhatsAppMessage is one text message of a batch: its ID, the name of its
321 // sender, its WhatsApp time, and its text. Mine marks a message of the user.
322 // Mention and Reply mark a message that mentions the user or replies to a
323 // message of the user.
324 type WhatsAppMessage struct {
325         ID      string    `json:"id"`
326         Sender  string    `json:"sender"`
327         Time    time.Time `json:"time"`
328         Text    string    `json:"text"`
329         Mine    bool      `json:"mine,omitempty"`
330         Mention bool      `json:"mention,omitempty"`
331         Reply   bool      `json:"reply,omitempty"`
332 }