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