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