]> git.giacomolli.uk - onechat/blob - whatsapp_inbox.go
Name timeouts for the operations they limit
[onechat] / whatsapp_inbox.go
1 package main
2
3 import (
4 "context"
5 "crypto/sha256"
6 "fmt"
7 "log/slog"
8 "slices"
9 "strings"
10 "sync"
11 "time"
12
13 "go.mau.fi/whatsmeow/store"
14 "go.mau.fi/whatsmeow/types"
15 "go.mau.fi/whatsmeow/types/events"
16 )
17
18 const (
19 // whatsappContactLookupTimeout is the longest time of a read of names
20 // in the session.
21 whatsappContactLookupTimeout = 10 * time.Second
22
23 // whatsappImageDownloadTimeout is the longest time of an image
24 // download.
25 whatsappImageDownloadTimeout = 2 * time.Minute
26
27 // whatsappSource is the source of the work events of the batches.
28 whatsappSource = "whatsapp"
29
30 // whatsappCatchupTimeout is the longest wait for the end of the
31 // catch-up after a connection.
32 whatsappCatchupTimeout = time.Minute
33 )
34
35 // newWhatsAppInbox returns the inbox of the batches in state, with clock for
36 // the times of the batch texts and groups for the names of the groups. images
37 // holds shared images, and logger gets image failures.
38 func newWhatsAppInbox(state *WhatsAppState, clock func() time.Time, groups *WhatsAppGroups, images *ImageStore, logger *slog.Logger) *WhatsAppInbox {
39 return &WhatsAppInbox{
40 state: state,
41 clock: clock,
42 groups: groups,
43 images: images,
44 logger: logger,
45 wake: make(chan struct{}, 1),
46 failures: make(chan error, 1),
47 }
48 }
49
50 // WhatsAppInbox puts messages into batches, one chat at a time, as
51 // WhatsAppState says. deliver logs each closed batch as a work message that
52 // starts with [WhatsApp], and a batch that needs the user wakes the turn loop.
53 //
54 // After each connection, the servers send the messages that came while the
55 // daemon was away, and then OfflineSyncCompleted. Until then, or until
56 // whatsappCatchupTimeout passes, the closed batches wait in the state file. At
57 // the end of the catch-up, deliver closes all batches and logs them. It wakes
58 // the root once for its batches, so their catch-up gives one turn, not one for
59 // each chat. The batches of the state file at the start wait for the first
60 // catch-up in the same way. The event handler acknowledges a message only after
61 // the state file holds it, so the servers send again a message that a crash
62 // lost. A batch leaves the state file only after its work message is durable in
63 // the log or agent queue. A crash between the two can repeat an unrouted batch
64 // once. A routed batch enters the durable agent queue, which prevents a second
65 // log entry and wakes only the child. A missing child uses the root.
66 //
67 // The state file is the one of WhatsApp, which keeps the send records in it
68 // too. groups gives the names of the groups. The mutex protects the state of
69 // the catch-up, which the event handler of the library changes from its own
70 // goroutines. wake tells Run that a batch can be due or that a catch-up ended,
71 // and failures gives Run the error of a write of the state file.
72 type WhatsAppInbox struct {
73 state *WhatsAppState
74 clock func() time.Time
75 groups *WhatsAppGroups
76 images *ImageStore
77 logger *slog.Logger
78 wake chan struct{}
79 failures chan error
80
81 mutex sync.Mutex
82 catchingUp bool
83 caughtUp bool
84 catchups int
85 catchupTimer *time.Timer
86 }
87
88 // due returns a channel that gets a value when the next open batch is due, or
89 // nil when no batch is open.
90 func (i *WhatsAppInbox) due() <-chan time.Time {
91 wait, open := i.state.Wait(time.Now())
92 if !open {
93 return nil
94 }
95
96 return time.After(wait)
97 }
98
99 // deliver logs the closed batches in l in order, each as a work message of
100 // whatsappSource, and wakes the loop once with the batches that start a turn. A
101 // batch leaves the state file after its message is durable in the log. A group
102 // with no name gets its name from WhatsApp first, with the client of link.
103 // During a catch-up it logs nothing, and at its end it closes all batches
104 // first. With all, it closes and logs all batches also during a catch-up.
105 // Routed batches enter the durable child queue instead of the root log and wake
106 // their child even when the batch does not need a root turn.
107 func (i *WhatsAppInbox) deliver(ctx context.Context, l *TurnLoop, link WhatsAppLink, all bool) error {
108 i.mutex.Lock()
109
110 catchingUp := i.catchingUp
111 caughtUp := i.caughtUp
112 i.caughtUp = false
113 i.mutex.Unlock()
114
115 var err error
116
117 switch {
118 case all || caughtUp:
119 err = i.state.CloseAll()
120 case catchingUp:
121 return nil
122 default:
123 _, err = i.state.CloseDue(time.Now())
124 }
125
126 if err != nil {
127 return err
128 }
129
130 batches := i.state.Closed()
131 if l.messages != nil {
132 err = l.messages.PruneWhatsApp(batches)
133 if err != nil {
134 return fmt.Errorf("prune WhatsApp receipts: %w", err)
135 }
136 }
137
138 var turns []Message
139
140 for _, batch := range batches {
141 if batch.Chat.Group && batch.Chat.Name == "" {
142 batch.Chat.Name = i.groups.name(ctx, link, batch.Chat.ID)
143 }
144
145 text := batch.Text(i.clock().Location())
146 if l.messages != nil {
147 routed, err := l.messages.RouteWhatsApp(batch, text)
148 if err != nil {
149 return fmt.Errorf("route a WhatsApp batch: %w", err)
150 }
151
152 if routed {
153 err = i.state.Remove(batch.Number)
154 if err != nil {
155 return fmt.Errorf("remove routed WhatsApp batch: %w", err)
156 }
157
158 continue
159 }
160 }
161
162 message, err := l.Work(whatsappSource, text, "")
163 if err != nil {
164 return fmt.Errorf("log a WhatsApp batch: %w", err)
165 }
166
167 // A stop of the daemon during the flush is no failure: the
168 // batch stays in the state file and repeats once at the next
169 // start.
170 err = l.Flush(ctx)
171 if ctx.Err() != nil {
172 return nil
173 }
174
175 if err != nil {
176 return fmt.Errorf("log a WhatsApp batch: %w", err)
177 }
178
179 err = i.state.Remove(batch.Number)
180 if err != nil {
181 return err
182 }
183
184 if batch.Turn() {
185 turns = append(turns, message)
186 }
187 }
188
189 l.Wake(turns...)
190
191 if l.messages != nil {
192 err = l.messages.PruneWhatsApp(i.state.Closed())
193 if err != nil {
194 return fmt.Errorf("prune WhatsApp receipts: %w", err)
195 }
196 }
197
198 return nil
199 }
200
201 // waitForCatchup makes the batches wait for a catch-up, as for the batches of
202 // the state file at the start, before the first connection.
203 func (i *WhatsAppInbox) waitForCatchup() {
204 i.mutex.Lock()
205 defer i.mutex.Unlock()
206
207 i.catchingUp = true
208 }
209
210 // startCatchup starts the catch-up of a new connection, which ends at
211 // endCatchup or after whatsappCatchupTimeout.
212 func (i *WhatsAppInbox) startCatchup() {
213 i.mutex.Lock()
214 defer i.mutex.Unlock()
215
216 i.catchingUp = true
217 i.catchups++
218 catchups := i.catchups
219 if i.catchupTimer != nil {
220 i.catchupTimer.Stop()
221 }
222
223 i.catchupTimer = time.AfterFunc(whatsappCatchupTimeout, func() {
224 i.caughtUpBy(catchups)
225 })
226 }
227
228 // receive stores an image before its batch and wakes Run. link gives the device
229 // and the own IDs. It returns false when the state file cannot hold the
230 // message, so that the library does not acknowledge it, and Run stops the
231 // daemon. An image that cannot be downloaded or stored gets a text marker. A
232 // message that the batches do not take is handled with nothing to do.
233 func (i *WhatsAppInbox) receive(ctx context.Context, link WhatsAppLink, event *events.Message) bool {
234 lookupCtx, cancel := context.WithTimeout(ctx, whatsappContactLookupTimeout)
235 defer cancel()
236
237 chat, message, found := whatsappMessage(lookupCtx, link.device, link.own, event)
238 if !found {
239 return true
240 }
241
242 if event.Message.GetImageMessage() != nil {
243 ctx, cancel := context.WithTimeout(ctx, whatsappImageDownloadTimeout)
244 defer cancel()
245
246 message.Text = i.imageText(ctx, link, event, message.Text)
247 }
248
249 _, err := i.state.Add(chat, message, time.Now())
250 if err != nil {
251 select {
252 case i.failures <- err:
253 default:
254 }
255
256 return false
257 }
258
259 select {
260 case i.wake <- struct{}{}:
261 default:
262 }
263
264 return true
265 }
266
267 // imageText stores the image of event before returning its marker and caption.
268 // Failures keep the caption with a text marker. The daemon log identifies the
269 // chat by a short hash, not its phone number.
270 func (i *WhatsAppInbox) imageText(ctx context.Context, link WhatsAppLink, event *events.Message, caption string) string {
271 name, err := whatsappImage(ctx, link.client, i.images, event.Message.GetImageMessage())
272 if err != nil {
273 chat := event.Info.Chat.ToNonAD()
274 digest := sha256.Sum256([]byte(chat.String()))
275 redact := append(slices.Clone(link.own), chat.User)
276 i.logger.Error("image receive failed", "chat", fmt.Sprintf("%x", digest[:6]), "err", whatsappRedact(redact, err.Error()))
277
278 text := "(image not downloaded)"
279 if caption != "" {
280 text += "\n" + caption
281 }
282
283 return text
284 }
285
286 return imageText([]string{name}, caption)
287 }
288
289 // caughtUpBy ends the catch-up of the connection with the number catchups, when
290 // no later connection started a new one.
291 func (i *WhatsAppInbox) caughtUpBy(catchups int) {
292 i.mutex.Lock()
293 defer i.mutex.Unlock()
294
295 if i.catchups == catchups {
296 i.finishCatchup()
297 }
298 }
299
300 // finishCatchup ends a running catch-up and wakes Run, which logs all batches.
301 // The caller holds the mutex.
302 func (i *WhatsAppInbox) finishCatchup() {
303 if !i.catchingUp {
304 return
305 }
306
307 i.catchingUp = false
308 i.caughtUp = true
309 if i.catchupTimer != nil {
310 i.catchupTimer.Stop()
311 }
312
313 select {
314 case i.wake <- struct{}{}:
315 default:
316 }
317 }
318
319 // endCatchup ends a running catch-up at OfflineSyncCompleted.
320 func (i *WhatsAppInbox) endCatchup() {
321 i.mutex.Lock()
322 defer i.mutex.Unlock()
323
324 i.finishCatchup()
325 }
326
327 // stopCatchup ends a running catch-up with no wake, after the session is
328 // removed.
329 func (i *WhatsAppInbox) stopCatchup() {
330 i.mutex.Lock()
331 defer i.mutex.Unlock()
332
333 i.catchingUp = false
334 }
335
336 // close stops the timer of the catch-up.
337 func (i *WhatsAppInbox) close() {
338 i.mutex.Lock()
339 defer i.mutex.Unlock()
340
341 if i.catchupTimer != nil {
342 i.catchupTimer.Stop()
343 }
344 }
345
346 // whatsappMessage returns the chat and the batch message of event, or false
347 // when the batches do not take it. device is the linked device, and own holds
348 // the own IDs. The batches take text, images, and media markers from a private
349 // chat or a group, from another person or from the user. They do not take
350 // reactions, edits, deletions, view-once and disappearing messages, old
351 // history, status broadcasts, channels, and the chat of the user with itself.
352 //
353 // A mention of the user becomes @you, another mention @NAME when the name is
354 // known, and the own number and LID become OWN, so that they never go to the
355 // log.
356 func whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
357 info := event.Info
358 chatJID := info.Chat.ToNonAD()
359 switch {
360 case device == nil:
361 case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.Message.GetImageMessage().GetViewOnce(), event.Message.GetVideoMessage().GetViewOnce(), event.SourceWebMsg != nil:
362 case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
363 case slices.Contains(own, chatJID.User):
364 default:
365 return whatsappText(ctx, device, own, event)
366 }
367
368 return WhatsAppChat{}, WhatsAppMessage{}, false
369 }
370
371 // whatsappText returns the chat and the batch message of event, a message that
372 // the batches take. An image returns its caption until receive stores it.
373 func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
374 info := event.Info
375 text := event.Message.GetConversation()
376 extended := event.Message.GetExtendedTextMessage()
377 if text == "" {
378 text = extended.GetText()
379 }
380
381 contextInfo := extended.GetContextInfo()
382 switch {
383 case event.Message.GetImageMessage() != nil:
384 image := event.Message.GetImageMessage()
385 text = image.GetCaption()
386 contextInfo = image.GetContextInfo()
387 case event.Message.GetStickerMessage() != nil:
388 text = "(sticker)"
389 contextInfo = event.Message.GetStickerMessage().GetContextInfo()
390 case event.Message.GetVideoMessage() != nil:
391 video := event.Message.GetVideoMessage()
392 text = "(video)"
393 if video.GetGifPlayback() {
394 text = "(GIF)"
395 }
396
397 if video.GetCaption() != "" {
398 text += "\n" + video.GetCaption()
399 }
400
401 contextInfo = video.GetContextInfo()
402 default:
403 if strings.TrimSpace(text) == "" {
404 return WhatsAppChat{}, WhatsAppMessage{}, false
405 }
406 }
407
408 message := WhatsAppMessage{
409 ID: info.ID,
410 Time: info.Timestamp,
411 Mine: info.IsFromMe,
412 }
413
414 if !message.Mine {
415 message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
416 }
417
418 for _, mentioned := range contextInfo.GetMentionedJID() {
419 jid, err := types.ParseJID(mentioned)
420 switch {
421 case err != nil:
422 case slices.Contains(own, jid.User):
423 message.Mention = true
424 text = strings.ReplaceAll(text, "@"+jid.User, "@you")
425 default:
426 text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, ""))
427 }
428 }
429
430 participant, err := types.ParseJID(contextInfo.GetParticipant())
431 message.Reply = err == nil && slices.Contains(own, participant.User)
432
433 message.Text = whatsappRedact(own, text)
434 chatJID := info.Chat.ToNonAD()
435 chat := WhatsAppChat{
436 ID: chatJID.String(),
437 Group: chatJID.Server == types.GroupServer,
438 }
439
440 if !chat.Group {
441 push := info.PushName
442 if message.Mine {
443 push = ""
444 }
445
446 chat.Name = whatsappContactName(ctx, device, chatJID, push)
447 }
448
449 return chat, message, true
450 }