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