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