13 "go.mau.fi/whatsmeow/store"
14 "go.mau.fi/whatsmeow/types"
15 "go.mau.fi/whatsmeow/types/events"
19 // whatsappContactLookupTimeout is the longest time of a read of names
21 whatsappContactLookupTimeout = 10 * time.Second
23 // whatsappImageDownloadTimeout is the longest time of an image
25 whatsappImageDownloadTimeout = 2 * time.Minute
27 // whatsappSource is the source of the work events of the batches.
28 whatsappSource = "whatsapp"
30 // whatsappCatchupTimeout is the longest wait for the end of the
31 // catch-up after a connection.
32 whatsappCatchupTimeout = time.Minute
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{
44 voices: &VoiceStore{dir: filepath.Join(filepath.Dir(filepath.Dir(state.path)), voiceDirName)},
45 voice: &VoiceTranscriber{},
47 wake: make(chan struct{}, 1),
48 failures: make(chan error, 1),
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.
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.
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 {
76 clock func() time.Time
77 groups *WhatsAppGroups
80 voice *VoiceTranscriber
89 catchupTimer *time.Timer
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())
100 return time.After(wait)
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 {
114 catchingUp := i.catchingUp
115 caughtUp := i.caughtUp
122 case all || caughtUp:
123 err = i.state.CloseAll()
127 _, err = i.state.CloseDue(time.Now())
134 batches := i.state.Closed()
135 if l.messages != nil {
136 err = l.messages.PruneWhatsApp(batches)
138 return fmt.Errorf("prune WhatsApp receipts: %w", err)
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)
149 text := batch.Text(i.clock().Location())
150 if l.messages != nil {
151 routed, err := l.messages.RouteWhatsApp(batch, text)
153 return fmt.Errorf("route a WhatsApp batch: %w", err)
157 err = i.state.Remove(batch.Number)
159 return fmt.Errorf("remove routed WhatsApp batch: %w", err)
166 message, err := l.Work(whatsappSource, text, "")
168 return fmt.Errorf("log a WhatsApp batch: %w", err)
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
175 if ctx.Err() != nil {
180 return fmt.Errorf("log a WhatsApp batch: %w", err)
183 err = i.state.Remove(batch.Number)
189 turns = append(turns, message)
195 if l.messages != nil {
196 err = l.messages.PruneWhatsApp(i.state.Closed())
198 return fmt.Errorf("prune WhatsApp receipts: %w", err)
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() {
209 defer i.mutex.Unlock()
214 // startCatchup starts the catch-up of a new connection, which ends at
215 // endCatchup or after whatsappCatchupTimeout.
216 func (i *WhatsAppInbox) startCatchup() {
218 defer i.mutex.Unlock()
222 catchups := i.catchups
223 if i.catchupTimer != nil {
224 i.catchupTimer.Stop()
227 i.catchupTimer = time.AfterFunc(whatsappCatchupTimeout, func() {
228 i.caughtUpBy(catchups)
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)
241 chat, message, found := whatsappMessage(lookupCtx, link.device, link.own, event)
246 if event.Message.GetImageMessage() != nil {
247 ctx, cancel := context.WithTimeout(ctx, whatsappImageDownloadTimeout)
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()))
255 _, err := i.state.Add(chat, message, time.Now())
258 case i.failures <- err:
266 case i.wake <- struct{}{}:
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())
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()))
283 text := "(image not downloaded)"
285 text += "\n" + caption
291 return imageText([]string{name}, caption)
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) {
298 defer i.mutex.Unlock()
300 if i.catchups == catchups {
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() {
314 if i.catchupTimer != nil {
315 i.catchupTimer.Stop()
319 case i.wake <- struct{}{}:
324 // endCatchup ends a running catch-up at OfflineSyncCompleted.
325 func (i *WhatsAppInbox) endCatchup() {
327 defer i.mutex.Unlock()
332 // stopCatchup ends a running catch-up with no wake, after the session is
334 func (i *WhatsAppInbox) stopCatchup() {
336 defer i.mutex.Unlock()
341 // close stops the timer of the catch-up.
342 func (i *WhatsAppInbox) close() {
344 defer i.mutex.Unlock()
346 if i.catchupTimer != nil {
347 i.catchupTimer.Stop()
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.
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
361 func whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
363 chatJID := info.Chat.ToNonAD()
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):
370 return whatsappText(ctx, device, own, event)
373 return WhatsAppChat{}, WhatsAppMessage{}, false
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) {
380 text := event.Message.GetConversation()
381 extended := event.Message.GetExtendedTextMessage()
383 text = extended.GetText()
386 contextInfo := extended.GetContextInfo()
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:
397 contextInfo = event.Message.GetStickerMessage().GetContextInfo()
398 case event.Message.GetVideoMessage() != nil:
399 video := event.Message.GetVideoMessage()
401 if video.GetGifPlayback() {
405 if video.GetCaption() != "" {
406 text += "\n" + video.GetCaption()
409 contextInfo = video.GetContextInfo()
411 if strings.TrimSpace(text) == "" {
412 return WhatsAppChat{}, WhatsAppMessage{}, false
416 message := WhatsAppMessage{
418 Time: info.Timestamp,
423 message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
426 for _, mentioned := range contextInfo.GetMentionedJID() {
427 jid, err := types.ParseJID(mentioned)
430 case slices.Contains(own, jid.User):
431 message.Mention = true
432 text = strings.ReplaceAll(text, "@"+jid.User, "@you")
434 text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, ""))
438 participant, err := types.ParseJID(contextInfo.GetParticipant())
439 message.Reply = err == nil && slices.Contains(own, participant.User)
441 message.Text = whatsappRedact(own, text)
442 chatJID := info.Chat.ToNonAD()
443 chat := WhatsAppChat{
444 ID: chatJID.String(),
445 Group: chatJID.Server == types.GroupServer,
449 push := info.PushName
454 chat.Name = whatsappContactName(ctx, device, chatJID, push)
457 return chat, message, true