16 // whatsappStateFileName is the state file in the WhatsApp directory:
17 // the batches that are not in the log yet.
18 whatsappStateFileName = "state.json"
20 // whatsappBatchQuiet is how long a chat is quiet before its batch
22 whatsappBatchQuiet = 20 * time.Second
24 // whatsappBatchWindow is the longest time of a batch.
25 whatsappBatchWindow = 2 * time.Minute
27 // whatsappBatchMessages is the most messages of a batch.
28 whatsappBatchMessages = 50
30 // whatsappBatchEmptyReply is the last line of a batch that starts a
32 whatsappBatchEmptyReply = "If nothing in this batch needs the user, end your turn with an empty reply."
34 // whatsappBatchDayLayout is the layout of the day in the first line of
36 whatsappBatchDayLayout = "Monday 2006-01-02"
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{
49 data, err := os.ReadFile(path)
50 if errors.Is(err, os.ErrNotExist) {
55 return nil, fmt.Errorf("read WhatsApp state: %w", err)
58 err = json.Unmarshal(data, &b.state)
60 return nil, fmt.Errorf("%s: %w", path, err)
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.
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 {
84 state WhatsAppStateFile
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
91 func (b *WhatsAppBatches) Add(chat WhatsAppChat, message WhatsAppMessage, arrival time.Time) (bool, error) {
93 defer b.mutex.Unlock()
96 index := b.open(chat.ID)
98 batch := &b.state.Batches[index]
99 if slices.ContainsFunc(batch.Messages, func(held WhatsAppMessage) bool {
100 return held.ID == message.ID
105 if message.Time.Sub(batch.Messages[len(batch.Messages)-1].Time) > b.quiet || message.Time.Sub(batch.Messages[0].Time) > b.window {
114 b.state.Batches = append(b.state.Batches, WhatsAppBatch{
115 Number: b.state.Next,
119 index = len(b.state.Batches) - 1
122 batch := &b.state.Batches[index]
123 batch.Messages = append(batch.Messages, message)
125 if len(batch.Messages) >= whatsappBatchMessages {
130 return closed, b.save()
133 // CloseDue closes each open batch whose time is up at now and tells if one
135 func (b *WhatsAppBatches) CloseDue(now time.Time) (bool, error) {
137 defer b.mutex.Unlock()
140 for index := range b.state.Batches {
141 batch := &b.state.Batches[index]
142 if !batch.Closed && !now.Before(b.deadline(*batch)) {
152 return true, b.save()
155 // CloseAll closes every open batch.
156 func (b *WhatsAppBatches) CloseAll() error {
158 defer b.mutex.Unlock()
160 for index := range b.state.Batches {
161 b.state.Batches[index].Closed = true
167 // Closed returns the closed batches in order.
168 func (b *WhatsAppBatches) Closed() []WhatsAppBatch {
170 defer b.mutex.Unlock()
172 var closed []WhatsAppBatch
173 for _, batch := range b.state.Batches {
175 closed = append(closed, batch)
182 // Remove removes the batch with number, which is in the log.
183 func (b *WhatsAppBatches) Remove(number int) error {
185 defer b.mutex.Unlock()
187 b.state.Batches = slices.DeleteFunc(b.state.Batches, func(batch WhatsAppBatch) bool {
188 return batch.Number == number
194 // Wait returns how long after now the first open batch closes, or false when no
196 func (b *WhatsAppBatches) Wait(now time.Time) (time.Duration, bool) {
198 defer b.mutex.Unlock()
201 for _, batch := range b.state.Batches {
202 deadline := b.deadline(batch)
203 if !batch.Closed && (first.IsZero() || deadline.Before(first)) {
212 return max(first.Sub(now), 0), true
215 // open returns the index of the open batch of chat, or -1. The caller holds the
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
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) {
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)
238 return fmt.Errorf("encode WhatsApp state: %w", err)
241 err = os.MkdirAll(filepath.Dir(b.path), 0o700)
243 err = writeFileAtomic(b.path, append(data, '\n'))
247 return fmt.Errorf("save WhatsApp state: %w", err)
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"`
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"`
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)
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"
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
297 case message.Mention:
298 sender += " (mentions you)"
300 sender += " (replies to you)"
303 lines = append(lines, message.Time.In(location).Format("15:04")+" "+sender+": "+message.Text)
307 lines = append(lines, whatsappBatchEmptyReply)
310 return strings.Join(lines, "\n")
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"`
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"`