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