]> git.giacomolli.uk - onechat/blob - whatsapp.go
Move whatsapp_send to its own file
[onechat] / whatsapp.go
1 package main
2
3 import (
4         "context"
5         "errors"
6         "fmt"
7         "log"
8         "net/url"
9         "os"
10         "path/filepath"
11         "slices"
12         "strings"
13         "sync"
14         "time"
15
16         "go.mau.fi/whatsmeow"
17         "go.mau.fi/whatsmeow/proto/waE2E"
18         "go.mau.fi/whatsmeow/store"
19         "go.mau.fi/whatsmeow/store/sqlstore"
20         "go.mau.fi/whatsmeow/types"
21         "go.mau.fi/whatsmeow/types/events"
22         waLog "go.mau.fi/whatsmeow/util/log"
23         // The session store of the library opens SQLite with this pure Go
24         // driver.
25         _ "modernc.org/sqlite"
26 )
27
28 const (
29         // whatsappDirName is the directory of the WhatsApp files in the data
30         // directory.
31         whatsappDirName = "whatsapp"
32
33         // whatsappSessionFileName is the session database of the library in
34         // whatsappDirName.
35         whatsappSessionFileName = "session.db"
36
37         // whatsappCommand starts the input lines of the WhatsApp commands, such
38         // as "/whatsapp status".
39         whatsappCommand = "/whatsapp"
40
41         // whatsappUsage is the answer to a WhatsApp command that does not
42         // exist.
43         whatsappUsage = "Use /whatsapp pair PHONE, /whatsapp status, or /whatsapp unlink. PHONE is the number of your WhatsApp account with its country code, such as +15550001111."
44
45         // whatsappPairSteps tells the user what to do with the pairing code,
46         // which comes in the next message.
47         whatsappPairSteps = "In WhatsApp on your phone, open Linked devices, tap Link a device, then tap Link with phone number instead, and enter the code of the next message. The code expires in about 2 minutes."
48
49         // whatsappPairExpired is the answer when the pairing code expires.
50         whatsappPairExpired = "The pairing code expired. Run /whatsapp pair PHONE again for a new code."
51
52         // whatsappTimeLayout is the layout of the times in the answers.
53         whatsappTimeLayout = "2006-01-02 15:04"
54
55         // whatsappPairTime is the longest time of a pairing. The library closes
56         // the pairing connection after about 160 seconds.
57         whatsappPairTime = 3 * time.Minute
58
59         // whatsappLogoutTime is the longest time of the logout call of an
60         // unlink.
61         whatsappLogoutTime = 30 * time.Second
62
63         // whatsappNoticeDelay is how long a disconnection lasts before the user
64         // gets a notice.
65         whatsappNoticeDelay = 10 * time.Minute
66
67         // whatsappRequests is the number of pair and unlink commands that can
68         // wait for Run.
69         whatsappRequests = 8
70
71         // whatsappLookupTime is the longest time of a read of the names in the
72         // session database or of the groups from WhatsApp.
73         whatsappLookupTime = 10 * time.Second
74
75         // whatsappNotLinked is the answer of a WhatsApp tool without a link.
76         whatsappNotLinked = "WhatsApp is not linked. Tell the user to run /whatsapp pair PHONE in the terminal or in Telegram."
77 )
78
79 // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
80 // real one is a *whatsmeow.Client from whatsappNewClient, and the tests use a
81 // fake.
82 type WhatsAppClient interface {
83         ConnectContext(ctx context.Context) error
84         Disconnect()
85         GetQRChannel(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error)
86         PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error)
87         Logout(ctx context.Context) error
88         GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error)
89         GenerateMessageID() types.MessageID
90         SendMessage(ctx context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error)
91 }
92
93 // openWhatsApp opens the WhatsApp session in the data directory dir. Without
94 // the session file, WhatsApp is not linked. The session gives full access to
95 // the account, so a file that another user can read is an error. The clock
96 // gives the times of the status, logger gets the failures, and newClient makes
97 // the client of a device with its event handler.
98 func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *log.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
99         w := &WhatsApp{
100                 dir:         filepath.Join(dir, whatsappDirName),
101                 clock:       clock,
102                 logger:      logger,
103                 newClient:   newClient,
104                 noticeDelay: whatsappNoticeDelay,
105                 requests:    make(chan WhatsAppRequest, whatsappRequests),
106                 logouts:     make(chan struct{}, 1),
107                 groups:      newWhatsAppGroups(logger),
108         }
109
110         var err error
111         w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow)
112         if err != nil {
113                 return nil, err
114         }
115
116         w.inbox = newWhatsAppInbox(w.state, clock, w.groups)
117
118         info, err := os.Stat(w.sessionPath())
119         if errors.Is(err, os.ErrNotExist) {
120                 return w, nil
121         }
122
123         if err != nil {
124                 return nil, fmt.Errorf("read WhatsApp session: %w", err)
125         }
126
127         if info.Mode().Perm()&0o077 != 0 {
128                 return nil, fmt.Errorf("%s holds the WhatsApp session, so its mode must be 0600", filepath.Join(whatsappDirName, whatsappSessionFileName))
129         }
130
131         err = w.openSession(ctx)
132         if err != nil {
133                 return nil, err
134         }
135
136         if w.device.ID != nil {
137                 w.linked = true
138                 w.inbox.waitForCatchup()
139                 w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
140         }
141
142         return w, nil
143 }
144
145 // WhatsApp is the link of the daemon to the WhatsApp account of the user, as a
146 // linked device. The session database of the library in dir holds the keys of
147 // the device. Run keeps a linked device connected, and the library connects
148 // again by itself after a network failure.
149 //
150 // The commands pair and unlink go to Run as requests, and Run runs one at a
151 // time. A pairing runs in its own goroutine until its code expires, and a new
152 // pairing ends the one before. Its phone number and code go only to the answer
153 // of the client that asked. A logout from the phone comes as an event of the
154 // library, and Run deletes the session. The mutex protects the session, the
155 // client, and the state of the connection, which the event handler of the
156 // library changes from its own goroutines.
157 //
158 // The inbox puts the text messages into batches and logs them, and groups
159 // holds the names of the groups. Both get a WhatsAppLink from link for each
160 // call, so they use the current device and client.
161 //
162 // The library logs nothing, because its log lines hold the ID of the own
163 // account, which is the phone number of the user. The own number and the own
164 // LID never go to the log of the agent or to the log of the daemon: redact
165 // removes them from the errors, and a batch shows the messages of the user as
166 // You and a mention of the user as @you. The IDs of the other chats go to the
167 // log, and those of private chats hold phone numbers, because the agent needs
168 // them to send.
169 type WhatsApp struct {
170         dir         string
171         clock       func() time.Time
172         logger      *log.Logger
173         newClient   func(device *store.Device, handle func(event any) bool) WhatsAppClient
174         noticeDelay time.Duration
175         requests    chan WhatsAppRequest
176         logouts     chan struct{}
177         state       *WhatsAppState
178         groups      *WhatsAppGroups
179         inbox       *WhatsAppInbox
180
181         mutex          sync.Mutex
182         container      *sqlstore.Container
183         device         *store.Device
184         client         WhatsAppClient
185         linked         bool
186         own            []string
187         notify         func(Event)
188         connected      bool
189         connectedAt    time.Time
190         disconnectedAt time.Time
191         disconnections int
192         noticed        bool
193         timer          *time.Timer
194         closed         bool
195 }
196
197 // Run connects a linked device, logs the closed batches in l, runs the pair and
198 // unlink commands, and handles a logout from the phone, until ctx ends. Its
199 // notes go to the log of l, and notify gets the notices for all clients. A
200 // failed first connection is logged, and the library tries again when the
201 // failure is one of the network. Run returns the error of a write of the log or
202 // of the state file, which stops the daemon, because then messages would be
203 // lost.
204 func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error {
205         w.mutex.Lock()
206         w.notify = notify
207         linked := w.linked
208         w.mutex.Unlock()
209
210         if linked {
211                 w.connect(ctx)
212         }
213
214         var pairing sync.WaitGroup
215         cancel := func() {}
216         defer func() {
217                 cancel()
218                 pairing.Wait()
219                 w.close()
220         }()
221
222         for {
223                 err := w.inbox.deliver(ctx, l, w.link(), false)
224                 if err != nil {
225                         return err
226                 }
227
228                 select {
229                 case <-ctx.Done():
230                         return nil
231                 case err = <-w.inbox.failures:
232                         return err
233                 case <-w.inbox.wake:
234                 case <-w.inbox.due():
235                 case <-w.logouts:
236                         err = w.loggedOut(ctx, l)
237                         if err != nil {
238                                 return err
239                         }
240                 case request := <-w.requests:
241                         cancel()
242                         pairing.Wait()
243                         cancel = func() {}
244
245                         switch {
246                         case request.unlink:
247                                 err = w.unlink(ctx, l, request.answer)
248                                 if err != nil {
249                                         return err
250                                 }
251                         case w.isLinked():
252                                 request.answer("WhatsApp is linked already. Run /whatsapp unlink first.")
253                         default:
254                                 pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTime)
255                                 cancel = cancelPair
256                                 pairing.Go(func() {
257                                         defer cancelPair()
258
259                                         w.pair(pairCtx, ctx, l, request.phone, request.answer)
260                                 })
261                         }
262                 }
263         }
264 }
265
266 // Command runs the WhatsApp command args, the text after /whatsapp, and gives
267 // each answer to answer. The answers go only to the client that sent the
268 // command.
269 func (w *WhatsApp) Command(args string, answer func(text string)) {
270         fields := strings.Fields(args)
271         request := WhatsAppRequest{
272                 answer: answer,
273         }
274
275         switch {
276         case len(fields) == 1 && fields[0] == "status":
277                 answer(w.Status())
278                 return
279         case len(fields) == 1 && fields[0] == "unlink":
280                 request.unlink = true
281         case len(fields) >= 2 && fields[0] == "pair":
282                 phone, valid := whatsappPhone(strings.Join(fields[1:], ""))
283                 if !valid {
284                         answer("PHONE must be the number of your WhatsApp account with its country code: 7 to 15 digits, such as +15550001111.")
285                         return
286                 }
287
288                 request.phone = phone
289         default:
290                 answer(whatsappUsage)
291                 return
292         }
293
294         select {
295         case w.requests <- request:
296         default:
297                 answer("Too many WhatsApp commands wait. Try again in a minute.")
298         }
299 }
300
301 // Status returns the state of the link: not linked, linked and connected, or
302 // linked and disconnected, with the times of the connection. It never holds the
303 // phone number.
304 func (w *WhatsApp) Status() string {
305         w.mutex.Lock()
306         defer w.mutex.Unlock()
307
308         switch {
309         case !w.linked:
310                 return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it."
311         case w.connected:
312                 return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "."
313         case w.disconnectedAt.IsZero():
314                 return "WhatsApp is linked and not connected yet."
315         case w.connectedAt.IsZero():
316                 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "."
317         default:
318                 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "."
319         }
320 }
321
322 // Close closes the session database.
323 func (w *WhatsApp) Close() error {
324         w.mutex.Lock()
325         defer w.mutex.Unlock()
326
327         if w.container == nil {
328                 return nil
329         }
330
331         err := w.container.Close()
332         w.container = nil
333
334         return err
335 }
336
337 // Find returns the answer of whatsapp_find for name, as whatsappFind says.
338 func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) {
339         return whatsappFind(ctx, w.link(), w.groups, name)
340 }
341
342 // Send returns the answer of whatsapp_send for the send id of text to chat, as
343 // whatsappSend says.
344 func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) {
345         return whatsappSend(ctx, w.link(), w.groups, w.state, w.clock, id, chat, text)
346 }
347
348 func (w *WhatsApp) sessionPath() string {
349         return filepath.Join(w.dir, whatsappSessionFileName)
350 }
351
352 // openSession opens the session database and its first device. The caller holds
353 // the mutex, or no other goroutine uses w yet.
354 func (w *WhatsApp) openSession(ctx context.Context) error {
355         address, err := whatsappSessionAddress(w.sessionPath())
356         if err != nil {
357                 return err
358         }
359
360         container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
361         if err != nil {
362                 return fmt.Errorf("open WhatsApp session: %w", err)
363         }
364
365         device, err := container.GetFirstDevice(ctx)
366         if err != nil {
367                 return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close())
368         }
369
370         w.container = container
371         w.device = device
372
373         return nil
374 }
375
376 func (w *WhatsApp) isLinked() bool {
377         w.mutex.Lock()
378         defer w.mutex.Unlock()
379
380         return w.linked
381 }
382
383 // link returns a copy of the link of the device: whether it is linked, the
384 // device, the client, whether it is connected, and the own IDs.
385 func (w *WhatsApp) link() WhatsAppLink {
386         w.mutex.Lock()
387         defer w.mutex.Unlock()
388
389         return WhatsAppLink{
390                 linked:    w.linked,
391                 device:    w.device,
392                 client:    w.client,
393                 connected: w.connected,
394                 own:       slices.Clone(w.own),
395         }
396 }
397
398 // connect makes the client of the linked device and connects it.
399 func (w *WhatsApp) connect(ctx context.Context) {
400         w.mutex.Lock()
401         w.client = w.newClient(w.device, w.handle)
402         client := w.client
403         w.mutex.Unlock()
404
405         err := client.ConnectContext(ctx)
406         if err != nil {
407                 w.logger.Printf("connect: %s", w.redact(err.Error()))
408         }
409 }
410
411 // close ends the connection and the timer of the notice, and stops all notices.
412 func (w *WhatsApp) close() {
413         w.mutex.Lock()
414         w.closed = true
415         if w.timer != nil {
416                 w.timer.Stop()
417         }
418
419         client := w.client
420         w.client = nil
421         w.mutex.Unlock()
422
423         w.inbox.close()
424
425         if client != nil {
426                 client.Disconnect()
427         }
428 }
429
430 // loggedOut logs the open batches, deletes the session after a logout from the
431 // phone, notes it in the log of l, and tells all clients one time.
432 func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
433         if !w.isLinked() {
434                 return nil
435         }
436
437         err := w.inbox.deliver(ctx, l, w.link(), true)
438         if err != nil {
439                 return err
440         }
441
442         err = w.removeSession()
443         if err != nil {
444                 w.logger.Printf("delete the session after the logout: %v", err)
445         }
446
447         err = l.note("WhatsApp was unlinked from the phone.")
448         if err != nil {
449                 w.logger.Printf("note the logout: %v", err)
450         }
451
452         w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
453
454         return nil
455 }
456
457 // unlink logs the open batches, logs out the linked device, and deletes the
458 // session. When the logout call fails, as it does while WhatsApp is
459 // disconnected, the session is deleted all the same, and answer tells the user
460 // to remove the device in the phone. Its error is one of the log or the state
461 // file.
462 func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) error {
463         w.mutex.Lock()
464         linked := w.linked
465         client := w.client
466         w.mutex.Unlock()
467
468         if !linked {
469                 answer("WhatsApp is not linked.")
470                 return nil
471         }
472
473         err := w.inbox.deliver(ctx, l, w.link(), true)
474         if err != nil {
475                 return err
476         }
477
478         var logoutErr error
479         if client != nil {
480                 ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTime)
481                 defer cancel()
482
483                 logoutErr = client.Logout(ctx)
484         }
485
486         err = w.removeSession()
487         if err != nil {
488                 answer("The unlink failed: " + w.redact(err.Error()))
489                 return nil
490         }
491
492         err = l.note("WhatsApp is unlinked.")
493         if err != nil {
494                 w.logger.Printf("note the unlink: %v", err)
495         }
496
497         if client == nil || logoutErr != nil {
498                 answer("WhatsApp is unlinked here, but the phone did not get the logout. In WhatsApp on your phone, open Linked devices and remove the device.")
499                 return nil
500         }
501
502         answer("WhatsApp is unlinked.")
503
504         return nil
505 }
506
507 // removeSession ends the connection, closes the session database, and deletes
508 // its files.
509 func (w *WhatsApp) removeSession() error {
510         w.mutex.Lock()
511         client := w.client
512         w.client = nil
513         w.linked = false
514         w.own = nil
515         w.connected = false
516         if w.timer != nil {
517                 w.timer.Stop()
518         }
519
520         w.noticed = false
521         container := w.container
522         w.container = nil
523         w.device = nil
524         w.mutex.Unlock()
525
526         w.inbox.stopCatchup()
527
528         if client != nil {
529                 client.Disconnect()
530         }
531
532         var err error
533         if container != nil {
534                 err = container.Close()
535         }
536
537         for _, suffix := range []string{"", "-wal", "-shm"} {
538                 removeErr := os.Remove(w.sessionPath() + suffix)
539                 if !errors.Is(removeErr, os.ErrNotExist) {
540                         err = errors.Join(err, removeErr)
541                 }
542         }
543
544         return err
545 }
546
547 // pair links a new device to the account of phone with a pairing code. It gives
548 // the steps and the code to answer, and then the end of the pairing: the linked
549 // number, an expired code, or a failure. A pairing that a new one ends gives no
550 // answer. A link notes "WhatsApp is linked." in the log of l, with no number.
551 // The client of a pairing that does not link is disconnected.
552 //
553 // ctx limits the pairing, but the connection uses runCtx, the context of Run.
554 // The library keeps the context of the connection for its keep-alive, its event
555 // handler, and its reconnections, and after the link the server ends the stream
556 // and the library connects again. With ctx, the end of the pairing would stop
557 // that reconnection, and the linked device would never connect.
558 func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string, answer func(text string)) {
559         device, err := w.newDevice(ctx)
560         if err != nil {
561                 answer("The pairing failed: " + err.Error())
562                 return
563         }
564
565         client := w.newClient(device, w.handle)
566         linked := false
567         defer func() {
568                 if !linked {
569                         client.Disconnect()
570                 }
571         }()
572
573         qr, err := client.GetQRChannel(ctx)
574         if err == nil {
575                 err = client.ConnectContext(runCtx)
576         }
577
578         if err != nil {
579                 answer("The connection to WhatsApp failed: " + err.Error())
580                 return
581         }
582
583         select {
584         case item, open := <-qr:
585                 if !open || item.Event != whatsmeow.QRChannelEventCode {
586                         answer("WhatsApp did not start the pairing. Try again later.")
587                         return
588                 }
589         case <-ctx.Done():
590                 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
591                         answer("The connection to WhatsApp did not start the pairing in time. Try again later.")
592                 }
593
594                 return
595         }
596
597         code, err := client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
598         if err != nil {
599                 answer("WhatsApp refused the pairing: " + err.Error() + ". Check the phone number and run /whatsapp pair PHONE again.")
600                 return
601         }
602
603         answer(whatsappPairSteps)
604         answer(code)
605
606         for {
607                 var item whatsmeow.QRChannelItem
608                 open := true
609                 select {
610                 case item, open = <-qr:
611                 case <-ctx.Done():
612                         if errors.Is(ctx.Err(), context.DeadlineExceeded) {
613                                 answer(whatsappPairExpired)
614                         }
615
616                         return
617                 }
618
619                 switch {
620                 case !open || item.Event == whatsmeow.QRChannelTimeout.Event:
621                         answer(whatsappPairExpired)
622                         return
623                 case item.Event == whatsmeow.QRChannelEventCode:
624                         continue
625                 case item.Event == whatsmeow.QRChannelSuccess.Event:
626                         linked = true
627                         w.linkedBy(client)
628
629                         err = l.note("WhatsApp is linked.")
630                         if err != nil {
631                                 w.logger.Printf("note the link: %v", err)
632                         }
633
634                         answer("WhatsApp is linked to +" + phone + ".")
635
636                         return
637                 default:
638                         answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.")
639                         return
640                 }
641         }
642 }
643
644 // newDevice returns a new device in the session database. It makes the
645 // directory and the file of the session first, with their modes, when they do
646 // not exist.
647 func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) {
648         w.mutex.Lock()
649         defer w.mutex.Unlock()
650
651         if w.container == nil {
652                 err := os.MkdirAll(w.dir, 0o700)
653                 if err != nil {
654                         return nil, fmt.Errorf("create WhatsApp directory: %w", err)
655                 }
656
657                 file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600)
658                 if err != nil {
659                         return nil, fmt.Errorf("create WhatsApp session: %w", err)
660                 }
661
662                 err = file.Close()
663                 if err != nil {
664                         return nil, fmt.Errorf("create WhatsApp session: %w", err)
665                 }
666
667                 err = w.openSession(ctx)
668                 if err != nil {
669                         return nil, err
670                 }
671         }
672
673         w.device = w.container.NewDevice()
674
675         return w.device, nil
676 }
677
678 // linkedBy makes client the client of the linked device.
679 func (w *WhatsApp) linkedBy(client WhatsAppClient) {
680         w.mutex.Lock()
681         defer w.mutex.Unlock()
682
683         w.client = client
684         w.linked = true
685 }
686
687 // handle takes one event of the library and tells if it was handled. A message
688 // goes to the inbox. The names of the groups follow the events of the groups. A
689 // connection starts a catch-up of the inbox, which ends at OfflineSyncCompleted
690 // or after its catchupLimit. A disconnection keeps the time of its start while
691 // the library tries again. After noticeDelay of a disconnection, all clients
692 // get one notice, and they get one more when the connection comes back. A
693 // disconnection that the library does not try again gives a notice at once.
694 func (w *WhatsApp) handle(event any) bool {
695         switch event := event.(type) {
696         case *events.Message:
697                 return w.inbox.receive(w.link(), event)
698         case *events.GroupInfo:
699                 if event.Name != nil {
700                         w.groups.set(event.JID.String(), event.Name.Name)
701                 }
702
703                 return true
704         case *events.JoinedGroup:
705                 w.groups.set(event.JID.String(), event.Name)
706                 return true
707         case *events.OfflineSyncCompleted:
708                 w.inbox.endCatchup()
709                 return true
710         case *events.Connected:
711                 w.inbox.startCatchup()
712         }
713
714         var notice string
715         w.mutex.Lock()
716         switch event := event.(type) {
717         case *events.PairSuccess:
718                 w.own = whatsappOwnIDs(event.ID.User, event.LID.User)
719         case *events.Connected:
720                 w.connected = true
721                 w.connectedAt = w.clock()
722                 if w.timer != nil {
723                         w.timer.Stop()
724                 }
725
726                 if w.noticed {
727                         w.noticed = false
728                         notice = "WhatsApp is connected again."
729                 }
730         case *events.Disconnected:
731                 if w.linked && (w.connected || w.disconnectedAt.IsZero()) {
732                         w.disconnectedAt = w.clock()
733                         w.disconnections++
734                         disconnections := w.disconnections
735                         w.timer = time.AfterFunc(w.noticeDelay, func() {
736                                 w.disconnectedLong(disconnections)
737                         })
738                 }
739
740                 w.connected = false
741         case *events.LoggedOut:
742                 select {
743                 case w.logouts <- struct{}{}:
744                 default:
745                 }
746         case events.PermanentDisconnect:
747                 if w.linked {
748                         w.connected = false
749                         w.noticed = true
750                         notice = "WhatsApp is disconnected and does not connect again by itself: " + event.PermanentDisconnectDescription() + ". Restart the daemon to try again."
751                 }
752         }
753         w.mutex.Unlock()
754
755         if notice != "" {
756                 w.broadcast(notice)
757         }
758
759         return true
760 }
761
762 // disconnectedLong gives the notice of a long disconnection, unless the
763 // disconnection with the number disconnections ended.
764 func (w *WhatsApp) disconnectedLong(disconnections int) {
765         w.mutex.Lock()
766         long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections
767         if long {
768                 w.noticed = true
769         }
770
771         since := w.disconnectedAt
772         w.mutex.Unlock()
773
774         if long {
775                 w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.")
776         }
777 }
778
779 // broadcast gives text as a notice to all clients.
780 func (w *WhatsApp) broadcast(text string) {
781         w.mutex.Lock()
782         notify := w.notify
783         closed := w.closed
784         w.mutex.Unlock()
785
786         if notify != nil && !closed {
787                 notify(Event{
788                         Kind: "notice",
789                         Text: text,
790                 })
791         }
792 }
793
794 // redact replaces the own number and the own LID in text with OWN.
795 func (w *WhatsApp) redact(text string) string {
796         w.mutex.Lock()
797         defer w.mutex.Unlock()
798
799         return whatsappRedact(w.own, text)
800 }
801
802 // WhatsAppLink is a copy of the link of the device at one time, for the parts
803 // of WhatsApp that do not own it: the groups, the batches, find, and send.
804 type WhatsAppLink struct {
805         linked    bool
806         device    *store.Device
807         client    WhatsAppClient
808         connected bool
809         own       []string
810 }
811
812 // WhatsAppRequest is a pair or unlink command for Run, with the function that
813 // gives its answers to the client that asked. A pairing has the phone number.
814 type WhatsAppRequest struct {
815         phone  string
816         unlink bool
817         answer func(text string)
818 }
819
820 // whatsappNewClient returns the client of the library for device, with handle
821 // as its event handler. The client tries again after a failed first connection,
822 // acknowledges a message only after handle returns true, and downloads no old
823 // history. The phone shows the device with the name OneChat.
824 func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
825         store.SetOSInfo("OneChat", [3]uint32{0, 1, 0})
826         client := whatsmeow.NewClient(device, waLog.Noop)
827         client.InitialAutoReconnect = true
828         client.SynchronousAck = true
829         client.ManualHistorySyncDownload = true
830         client.AddEventHandlerWithSuccessStatus(handle)
831
832         return client
833 }
834
835 // whatsappContactName returns the name of the person jid: the full name, the
836 // first name, the business name, or the push name of the contact in the session
837 // of device, then push, the push name of a message, then the phone number. A
838 // LID gets its phone number from the LID map of the session.
839 func whatsappContactName(ctx context.Context, device *store.Device, jid types.JID, push string) string {
840         jid = jid.ToNonAD()
841         candidates := []types.JID{jid}
842         phone := types.EmptyJID
843         switch jid.Server {
844         case types.DefaultUserServer:
845                 phone = jid
846         case types.HiddenUserServer:
847                 mapped, err := device.LIDs.GetPNForLID(ctx, jid)
848                 if err == nil && !mapped.IsEmpty() {
849                         phone = mapped
850                         candidates = append(candidates, mapped)
851                 }
852         }
853
854         for _, candidate := range candidates {
855                 contact, err := device.Contacts.GetContact(ctx, candidate)
856                 if err != nil || !contact.Found {
857                         continue
858                 }
859
860                 for _, name := range []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName} {
861                         if name != "" {
862                                 return name
863                         }
864                 }
865         }
866
867         switch {
868         case push != "":
869                 return push
870         case !phone.IsEmpty():
871                 return "+" + phone.User
872         default:
873                 return jid.User
874         }
875 }
876
877 // whatsappOwnIDs returns the own number and the own LID that are not empty.
878 func whatsappOwnIDs(ids ...string) []string {
879         var own []string
880         for _, id := range ids {
881                 if id != "" {
882                         own = append(own, id)
883                 }
884         }
885
886         return own
887 }
888
889 // whatsappRedact replaces each of the own IDs own in text with OWN.
890 func whatsappRedact(own []string, text string) string {
891         for _, id := range own {
892                 text = strings.ReplaceAll(text, id, "OWN")
893         }
894
895         return text
896 }
897
898 // whatsappSessionAddress returns the SQLite address of the session database at
899 // path, with foreign keys on, as the library needs, and a wait for a busy
900 // database.
901 func whatsappSessionAddress(path string) (string, error) {
902         path, err := filepath.Abs(path)
903         if err != nil {
904                 return "", fmt.Errorf("find WhatsApp session: %w", err)
905         }
906
907         address := url.URL{
908                 Scheme:   "file",
909                 Path:     path,
910                 RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
911         }
912
913         return address.String(), nil
914 }
915
916 // whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
917 // command, or false for any other line.
918 func whatsappCommandArgs(line string) (string, bool) {
919         line = strings.TrimSpace(line)
920         if line == whatsappCommand {
921                 return "", true
922         }
923
924         args, found := strings.CutPrefix(line, whatsappCommand+" ")
925         if !found {
926                 return "", false
927         }
928
929         return strings.TrimSpace(args), true
930 }
931
932 // whatsappPhone returns the digits of phone, a number with its country code and
933 // an optional + and dashes, and tells if it has the form of a number: 7 to 15
934 // digits, with no 0 first.
935 func whatsappPhone(phone string) (string, bool) {
936         phone = strings.ReplaceAll(strings.TrimPrefix(phone, "+"), "-", "")
937         valid := len(phone) >= 7 && len(phone) <= 15 && phone[0] != '0' && strings.Trim(phone, "0123456789") == ""
938
939         return phone, valid
940 }
941
942 // whatsappItemError returns the error of a pairing item that is not a code, a
943 // success, or a timeout.
944 func whatsappItemError(item whatsmeow.QRChannelItem) string {
945         if item.Error != nil {
946                 return item.Error.Error()
947         }
948
949         return item.Event
950 }