]> git.giacomolli.uk - onechat/blob - whatsapp.go
Give the WhatsApp group names their own type
[onechat] / whatsapp.go
1 package main
2
3 import (
4 "cmp"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "log"
11 "net/url"
12 "os"
13 "path/filepath"
14 "slices"
15 "strings"
16 "sync"
17 "time"
18 "unicode"
19
20 "go.mau.fi/whatsmeow"
21 "go.mau.fi/whatsmeow/proto/waE2E"
22 "go.mau.fi/whatsmeow/store"
23 "go.mau.fi/whatsmeow/store/sqlstore"
24 "go.mau.fi/whatsmeow/types"
25 "go.mau.fi/whatsmeow/types/events"
26 waLog "go.mau.fi/whatsmeow/util/log"
27 "golang.org/x/text/runes"
28 "golang.org/x/text/transform"
29 "golang.org/x/text/unicode/norm"
30
31 // The session store of the library opens SQLite with this pure Go
32 // driver.
33 _ "modernc.org/sqlite"
34 )
35
36 const (
37 // whatsappDirName is the directory of the WhatsApp files in the data
38 // directory.
39 whatsappDirName = "whatsapp"
40
41 // whatsappSessionFileName is the session database of the library in
42 // whatsappDirName.
43 whatsappSessionFileName = "session.db"
44
45 // whatsappCommand starts the input lines of the WhatsApp commands, such
46 // as "/whatsapp status".
47 whatsappCommand = "/whatsapp"
48
49 // whatsappUsage is the answer to a WhatsApp command that does not
50 // exist.
51 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."
52
53 // whatsappPairSteps tells the user what to do with the pairing code,
54 // which comes in the next message.
55 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."
56
57 // whatsappPairExpired is the answer when the pairing code expires.
58 whatsappPairExpired = "The pairing code expired. Run /whatsapp pair PHONE again for a new code."
59
60 // whatsappTimeLayout is the layout of the times in the answers.
61 whatsappTimeLayout = "2006-01-02 15:04"
62
63 // whatsappPairTime is the longest time of a pairing. The library closes
64 // the pairing connection after about 160 seconds.
65 whatsappPairTime = 3 * time.Minute
66
67 // whatsappLogoutTime is the longest time of the logout call of an
68 // unlink.
69 whatsappLogoutTime = 30 * time.Second
70
71 // whatsappNoticeDelay is how long a disconnection lasts before the user
72 // gets a notice.
73 whatsappNoticeDelay = 10 * time.Minute
74
75 // whatsappRequests is the number of pair and unlink commands that can
76 // wait for Run.
77 whatsappRequests = 8
78
79 // whatsappSource is the source of the work events of the batches.
80 whatsappSource = "whatsapp"
81
82 // whatsappLookupTime is the longest time of a read of the names in the
83 // session database or of the groups from WhatsApp.
84 whatsappLookupTime = 10 * time.Second
85
86 // whatsappCatchupLimit is the longest wait for the end of the catch-up
87 // after a connection.
88 whatsappCatchupLimit = time.Minute
89
90 // whatsappFindMatches is the most matches of whatsapp_find.
91 whatsappFindMatches = 20
92
93 // whatsappNotLinked is the answer of a WhatsApp tool without a link.
94 whatsappNotLinked = "WhatsApp is not linked. Tell the user to run /whatsapp pair PHONE in the terminal or in Telegram."
95
96 // whatsappSendTime is the longest time of a send.
97 whatsappSendTime = 30 * time.Second
98
99 // whatsappSendBytes is the most bytes of the text of a send.
100 whatsappSendBytes = 65536
101
102 // whatsappSendIDBytes is the most bytes of the ID of a send.
103 whatsappSendIDBytes = 100
104
105 // The states of a send record.
106 whatsappSending = "sending"
107 whatsappSent = "sent"
108 whatsappFailed = "failed"
109
110 // whatsappNamesNotReady is the answer of whatsapp_find before the names
111 // arrive from the phone.
112 whatsappNamesNotReady = "The names of the contacts and groups have not arrived from the phone yet. They come some minutes after the link. Try again later."
113 )
114
115 // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
116 // real one is a *whatsmeow.Client from whatsappNewClient, and the tests use a
117 // fake.
118 type WhatsAppClient interface {
119 ConnectContext(ctx context.Context) error
120 Disconnect()
121 GetQRChannel(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error)
122 PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error)
123 Logout(ctx context.Context) error
124 GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error)
125 GenerateMessageID() types.MessageID
126 SendMessage(ctx context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error)
127 }
128
129 // openWhatsApp opens the WhatsApp session in the data directory dir. Without
130 // the session file, WhatsApp is not linked. The session gives full access to
131 // the account, so a file that another user can read is an error. The clock
132 // gives the times of the status, logger gets the failures, and newClient makes
133 // the client of a device with its event handler.
134 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) {
135 w := &WhatsApp{
136 dir: filepath.Join(dir, whatsappDirName),
137 clock: clock,
138 logger: logger,
139 newClient: newClient,
140 noticeDelay: whatsappNoticeDelay,
141 catchupLimit: whatsappCatchupLimit,
142 requests: make(chan WhatsAppRequest, whatsappRequests),
143 logouts: make(chan struct{}, 1),
144 wake: make(chan struct{}, 1),
145 failures: make(chan error, 1),
146 groups: newWhatsAppGroups(logger),
147 }
148
149 var err error
150 w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow)
151 if err != nil {
152 return nil, err
153 }
154
155 info, err := os.Stat(w.sessionPath())
156 if errors.Is(err, os.ErrNotExist) {
157 return w, nil
158 }
159
160 if err != nil {
161 return nil, fmt.Errorf("read WhatsApp session: %w", err)
162 }
163
164 if info.Mode().Perm()&0o077 != 0 {
165 return nil, fmt.Errorf("%s holds the WhatsApp session, so its mode must be 0600", filepath.Join(whatsappDirName, whatsappSessionFileName))
166 }
167
168 err = w.openSession(ctx)
169 if err != nil {
170 return nil, err
171 }
172
173 if w.device.ID != nil {
174 w.linked = true
175 w.catchingUp = true
176 w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
177 }
178
179 return w, nil
180 }
181
182 // WhatsApp is the link of the daemon to the WhatsApp account of the user, as a
183 // linked device. The session database of the library in dir holds the keys of
184 // the device. Run keeps a linked device connected, and the library connects
185 // again by itself after a network failure.
186 //
187 // The commands pair and unlink go to Run as requests, and Run runs one at a
188 // time. A pairing runs in its own goroutine until its code expires, and a new
189 // pairing ends the one before. Its phone number and code go only to the answer
190 // of the client that asked. A logout from the phone comes as an event of the
191 // library, and Run deletes the session. The mutex protects the session, the
192 // client, and the state of the connection, which the event handler of the
193 // library changes from its own goroutines.
194 //
195 // The text messages go into batches, one chat at a time, as WhatsAppState says.
196 // Run logs each closed batch as a work message that starts with [WhatsApp], and
197 // a batch that needs the user wakes the turn loop.
198 //
199 // After each connection, the servers send the messages that came while the
200 // daemon was away, and then OfflineSyncCompleted. Until then, or until
201 // catchupLimit passes, the closed batches wait in the state file. At the end of
202 // the catch-up, Run closes all batches, logs them, and wakes the turn loop
203 // once, so the whole catch-up gives one turn and not one turn for each chat.
204 // The batches of the state file at the start wait for the first catch-up in the
205 // same way. The event handler acknowledges a message only after the state file
206 // holds it, so the servers send again a message that a crash lost. A batch
207 // leaves the state file only after its work message is durable in the log. A
208 // crash between the two makes the batch repeat once.
209 //
210 // The library logs nothing, because its log lines hold the ID of the own
211 // account, which is the phone number of the user. The own number and the own
212 // LID never go to the log of the agent or to the log of the daemon: redact
213 // removes them from the errors, and a batch shows the messages of the user as
214 // You and a mention of the user as @you. The IDs of the other chats go to the
215 // log, and those of private chats hold phone numbers, because the agent needs
216 // them to send.
217 type WhatsApp struct {
218 dir string
219 clock func() time.Time
220 logger *log.Logger
221 newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient
222 noticeDelay time.Duration
223 catchupLimit time.Duration
224 requests chan WhatsAppRequest
225 logouts chan struct{}
226 wake chan struct{}
227 failures chan error
228 state *WhatsAppState
229 groups *WhatsAppGroups
230
231 mutex sync.Mutex
232 container *sqlstore.Container
233 device *store.Device
234 client WhatsAppClient
235 linked bool
236 own []string
237 notify func(Event)
238 connected bool
239 connectedAt time.Time
240 disconnectedAt time.Time
241 disconnections int
242 noticed bool
243 timer *time.Timer
244 closed bool
245 catchingUp bool
246 caughtUp bool
247 catchups int
248 catchupTimer *time.Timer
249 }
250
251 // Run connects a linked device, logs the closed batches in l, runs the pair and
252 // unlink commands, and handles a logout from the phone, until ctx ends. Its
253 // notes go to the log of l, and notify gets the notices for all clients. A
254 // failed first connection is logged, and the library tries again when the
255 // failure is one of the network. Run returns the error of a write of the log or
256 // of the state file, which stops the daemon, because then messages would be
257 // lost.
258 func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error {
259 w.mutex.Lock()
260 w.notify = notify
261 linked := w.linked
262 w.mutex.Unlock()
263
264 if linked {
265 w.connect(ctx)
266 }
267
268 var pairing sync.WaitGroup
269 cancel := func() {}
270 defer func() {
271 cancel()
272 pairing.Wait()
273 w.close()
274 }()
275
276 for {
277 err := w.deliver(ctx, l, false)
278 if err != nil {
279 return err
280 }
281
282 var due <-chan time.Time
283 wait, open := w.state.Wait(time.Now())
284 if open {
285 due = time.After(wait)
286 }
287
288 select {
289 case <-ctx.Done():
290 return nil
291 case err = <-w.failures:
292 return err
293 case <-w.wake:
294 case <-due:
295 case <-w.logouts:
296 err = w.loggedOut(ctx, l)
297 if err != nil {
298 return err
299 }
300 case request := <-w.requests:
301 cancel()
302 pairing.Wait()
303 cancel = func() {}
304
305 switch {
306 case request.unlink:
307 err = w.unlink(ctx, l, request.answer)
308 if err != nil {
309 return err
310 }
311 case w.isLinked():
312 request.answer("WhatsApp is linked already. Run /whatsapp unlink first.")
313 default:
314 pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTime)
315 cancel = cancelPair
316 pairing.Go(func() {
317 defer cancelPair()
318
319 w.pair(pairCtx, ctx, l, request.phone, request.answer)
320 })
321 }
322 }
323 }
324 }
325
326 // Command runs the WhatsApp command args, the text after /whatsapp, and gives
327 // each answer to answer. The answers go only to the client that sent the
328 // command.
329 func (w *WhatsApp) Command(args string, answer func(text string)) {
330 fields := strings.Fields(args)
331 request := WhatsAppRequest{
332 answer: answer,
333 }
334
335 switch {
336 case len(fields) == 1 && fields[0] == "status":
337 answer(w.Status())
338 return
339 case len(fields) == 1 && fields[0] == "unlink":
340 request.unlink = true
341 case len(fields) >= 2 && fields[0] == "pair":
342 phone, valid := whatsappPhone(strings.Join(fields[1:], ""))
343 if !valid {
344 answer("PHONE must be the number of your WhatsApp account with its country code: 7 to 15 digits, such as +15550001111.")
345 return
346 }
347
348 request.phone = phone
349 default:
350 answer(whatsappUsage)
351 return
352 }
353
354 select {
355 case w.requests <- request:
356 default:
357 answer("Too many WhatsApp commands wait. Try again in a minute.")
358 }
359 }
360
361 // Status returns the state of the link: not linked, linked and connected, or
362 // linked and disconnected, with the times of the connection. It never holds the
363 // phone number.
364 func (w *WhatsApp) Status() string {
365 w.mutex.Lock()
366 defer w.mutex.Unlock()
367
368 switch {
369 case !w.linked:
370 return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it."
371 case w.connected:
372 return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "."
373 case w.disconnectedAt.IsZero():
374 return "WhatsApp is linked and not connected yet."
375 case w.connectedAt.IsZero():
376 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "."
377 default:
378 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "."
379 }
380 }
381
382 // Close closes the session database.
383 func (w *WhatsApp) Close() error {
384 w.mutex.Lock()
385 defer w.mutex.Unlock()
386
387 if w.container == nil {
388 return nil
389 }
390
391 err := w.container.Close()
392 w.container = nil
393
394 return err
395 }
396
397 func (w *WhatsApp) sessionPath() string {
398 return filepath.Join(w.dir, whatsappSessionFileName)
399 }
400
401 // openSession opens the session database and its first device. The caller holds
402 // the mutex, or no other goroutine uses w yet.
403 func (w *WhatsApp) openSession(ctx context.Context) error {
404 address, err := whatsappSessionAddress(w.sessionPath())
405 if err != nil {
406 return err
407 }
408
409 container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
410 if err != nil {
411 return fmt.Errorf("open WhatsApp session: %w", err)
412 }
413
414 device, err := container.GetFirstDevice(ctx)
415 if err != nil {
416 return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close())
417 }
418
419 w.container = container
420 w.device = device
421
422 return nil
423 }
424
425 func (w *WhatsApp) isLinked() bool {
426 w.mutex.Lock()
427 defer w.mutex.Unlock()
428
429 return w.linked
430 }
431
432 // link returns a copy of the link of the device: whether it is linked, the
433 // device, the client, whether it is connected, and the own IDs.
434 func (w *WhatsApp) link() WhatsAppLink {
435 w.mutex.Lock()
436 defer w.mutex.Unlock()
437
438 return WhatsAppLink{
439 linked: w.linked,
440 device: w.device,
441 client: w.client,
442 connected: w.connected,
443 own: slices.Clone(w.own),
444 }
445 }
446
447 // connect makes the client of the linked device and connects it.
448 func (w *WhatsApp) connect(ctx context.Context) {
449 w.mutex.Lock()
450 w.client = w.newClient(w.device, w.handle)
451 client := w.client
452 w.mutex.Unlock()
453
454 err := client.ConnectContext(ctx)
455 if err != nil {
456 w.logger.Printf("connect: %s", w.redact(err.Error()))
457 }
458 }
459
460 // close ends the connection and the timer of the notice, and stops all notices.
461 func (w *WhatsApp) close() {
462 w.mutex.Lock()
463 w.closed = true
464 if w.timer != nil {
465 w.timer.Stop()
466 }
467
468 if w.catchupTimer != nil {
469 w.catchupTimer.Stop()
470 }
471
472 client := w.client
473 w.client = nil
474 w.mutex.Unlock()
475
476 if client != nil {
477 client.Disconnect()
478 }
479 }
480
481 // loggedOut logs the open batches, deletes the session after a logout from the
482 // phone, notes it in the log of l, and tells all clients one time.
483 func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
484 if !w.isLinked() {
485 return nil
486 }
487
488 err := w.deliver(ctx, l, true)
489 if err != nil {
490 return err
491 }
492
493 err = w.removeSession()
494 if err != nil {
495 w.logger.Printf("delete the session after the logout: %v", err)
496 }
497
498 err = l.note("WhatsApp was unlinked from the phone.")
499 if err != nil {
500 w.logger.Printf("note the logout: %v", err)
501 }
502
503 w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
504
505 return nil
506 }
507
508 // unlink logs the open batches, logs out the linked device, and deletes the
509 // session. When the logout call fails, as it does while WhatsApp is
510 // disconnected, the session is deleted all the same, and answer tells the user
511 // to remove the device in the phone. Its error is one of the log or the state
512 // file.
513 func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) error {
514 w.mutex.Lock()
515 linked := w.linked
516 client := w.client
517 w.mutex.Unlock()
518
519 if !linked {
520 answer("WhatsApp is not linked.")
521 return nil
522 }
523
524 err := w.deliver(ctx, l, true)
525 if err != nil {
526 return err
527 }
528
529 var logoutErr error
530 if client != nil {
531 ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTime)
532 defer cancel()
533
534 logoutErr = client.Logout(ctx)
535 }
536
537 err = w.removeSession()
538 if err != nil {
539 answer("The unlink failed: " + w.redact(err.Error()))
540 return nil
541 }
542
543 err = l.note("WhatsApp is unlinked.")
544 if err != nil {
545 w.logger.Printf("note the unlink: %v", err)
546 }
547
548 if client == nil || logoutErr != nil {
549 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.")
550 return nil
551 }
552
553 answer("WhatsApp is unlinked.")
554
555 return nil
556 }
557
558 // deliver logs the closed batches in l in order, each as a work message of
559 // whatsappSource, and wakes the loop once with the batches that start a turn. A
560 // batch leaves the state file after its message is durable in the log. A group
561 // with no name gets its name from WhatsApp first. During a catch-up it logs
562 // nothing, and at its end it closes all batches first. With all, it closes and
563 // logs all batches also during a catch-up.
564 func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error {
565 w.mutex.Lock()
566 catchingUp := w.catchingUp
567 caughtUp := w.caughtUp
568 w.caughtUp = false
569 w.mutex.Unlock()
570
571 var err error
572 switch {
573 case all || caughtUp:
574 err = w.state.CloseAll()
575 case catchingUp:
576 return nil
577 default:
578 _, err = w.state.CloseDue(time.Now())
579 }
580
581 if err != nil {
582 return err
583 }
584
585 var turns []Message
586 for _, batch := range w.state.Closed() {
587 if batch.Chat.Group && batch.Chat.Name == "" {
588 batch.Chat.Name = w.groups.name(ctx, w.link(), batch.Chat.ID)
589 }
590
591 message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "")
592 if err != nil {
593 return fmt.Errorf("log a WhatsApp batch: %w", err)
594 }
595
596 // A stop of the daemon during the flush is no failure: the
597 // batch stays in the state file and repeats once at the next
598 // start.
599 err = l.Flush(ctx)
600 if ctx.Err() != nil {
601 return nil
602 }
603
604 if err != nil {
605 return fmt.Errorf("log a WhatsApp batch: %w", err)
606 }
607
608 err = w.state.Remove(batch.Number)
609 if err != nil {
610 return err
611 }
612
613 if batch.Turn() {
614 turns = append(turns, message)
615 }
616 }
617
618 l.Wake(turns...)
619
620 return nil
621 }
622
623 // Find returns the contacts and the groups whose name contains name, with no
624 // regard to case and accents, as lines "ID | name | contact" or "ID | name |
625 // group", sorted by name. It gives at most whatsappFindMatches lines and the
626 // number of the others. The contacts come from the session database, and the
627 // groups from WhatsApp, read again after whatsappGroupsAge. When the groups
628 // cannot be read and none are known, the answer says so. Before the names
629 // arrive from the phone, it says that they are not ready.
630 func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) {
631 w.mutex.Lock()
632 linked := w.linked
633 device := w.device
634 own := slices.Clone(w.own)
635 w.mutex.Unlock()
636
637 stale := w.groups.stale()
638
639 if !linked {
640 return whatsappNotLinked, nil
641 }
642
643 query := whatsappFold(name)
644 if query == "" {
645 return "", errors.New("name is empty: give a part of the name of the contact or the group")
646 }
647
648 contacts, err := device.Contacts.GetAllContacts(ctx)
649 if err != nil {
650 return "", fmt.Errorf("read the contacts: %s", w.redact(err.Error()))
651 }
652
653 read := !stale || w.groups.read(ctx, w.link())
654 groups := w.groups.all()
655
656 if len(contacts) == 0 && len(groups) == 0 {
657 if !read {
658 return "The names are not known yet, and WhatsApp is disconnected, so the groups cannot be read. Try again later.", nil
659 }
660
661 return whatsappNamesNotReady, nil
662 }
663
664 var matches []WhatsAppMatch
665 for jid, contact := range contacts {
666 names := []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName}
667 shown := slices.IndexFunc(names, func(name string) bool {
668 return name != ""
669 })
670
671 if slices.Contains(own, jid.User) || shown < 0 || !slices.ContainsFunc(names, func(name string) bool {
672 return strings.Contains(whatsappFold(name), query)
673 }) {
674 continue
675 }
676
677 matches = append(matches, WhatsAppMatch{
678 id: jid.ToNonAD().String(),
679 name: names[shown],
680 kind: "contact",
681 })
682 }
683
684 for id, group := range groups {
685 if strings.Contains(whatsappFold(group), query) {
686 matches = append(matches, WhatsAppMatch{
687 id: id,
688 name: group,
689 kind: "group",
690 })
691 }
692 }
693
694 return whatsappFindAnswer(name, matches, read || len(groups) > 0), nil
695 }
696
697 // removeSession ends the connection, closes the session database, and deletes
698 // its files.
699 func (w *WhatsApp) removeSession() error {
700 w.mutex.Lock()
701 client := w.client
702 w.client = nil
703 w.linked = false
704 w.own = nil
705 w.connected = false
706 w.catchingUp = false
707 if w.timer != nil {
708 w.timer.Stop()
709 }
710
711 w.noticed = false
712 container := w.container
713 w.container = nil
714 w.device = nil
715 w.mutex.Unlock()
716
717 if client != nil {
718 client.Disconnect()
719 }
720
721 var err error
722 if container != nil {
723 err = container.Close()
724 }
725
726 for _, suffix := range []string{"", "-wal", "-shm"} {
727 removeErr := os.Remove(w.sessionPath() + suffix)
728 if !errors.Is(removeErr, os.ErrNotExist) {
729 err = errors.Join(err, removeErr)
730 }
731 }
732
733 return err
734 }
735
736 // pair links a new device to the account of phone with a pairing code. It gives
737 // the steps and the code to answer, and then the end of the pairing: the linked
738 // number, an expired code, or a failure. A pairing that a new one ends gives no
739 // answer. A link notes "WhatsApp is linked." in the log of l, with no number.
740 // The client of a pairing that does not link is disconnected.
741 //
742 // ctx limits the pairing, but the connection uses runCtx, the context of Run.
743 // The library keeps the context of the connection for its keep-alive, its event
744 // handler, and its reconnections, and after the link the server ends the stream
745 // and the library connects again. With ctx, the end of the pairing would stop
746 // that reconnection, and the linked device would never connect.
747 func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string, answer func(text string)) {
748 device, err := w.newDevice(ctx)
749 if err != nil {
750 answer("The pairing failed: " + err.Error())
751 return
752 }
753
754 client := w.newClient(device, w.handle)
755 linked := false
756 defer func() {
757 if !linked {
758 client.Disconnect()
759 }
760 }()
761
762 qr, err := client.GetQRChannel(ctx)
763 if err == nil {
764 err = client.ConnectContext(runCtx)
765 }
766
767 if err != nil {
768 answer("The connection to WhatsApp failed: " + err.Error())
769 return
770 }
771
772 select {
773 case item, open := <-qr:
774 if !open || item.Event != whatsmeow.QRChannelEventCode {
775 answer("WhatsApp did not start the pairing. Try again later.")
776 return
777 }
778 case <-ctx.Done():
779 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
780 answer("The connection to WhatsApp did not start the pairing in time. Try again later.")
781 }
782
783 return
784 }
785
786 code, err := client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
787 if err != nil {
788 answer("WhatsApp refused the pairing: " + err.Error() + ". Check the phone number and run /whatsapp pair PHONE again.")
789 return
790 }
791
792 answer(whatsappPairSteps)
793 answer(code)
794
795 for {
796 var item whatsmeow.QRChannelItem
797 open := true
798 select {
799 case item, open = <-qr:
800 case <-ctx.Done():
801 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
802 answer(whatsappPairExpired)
803 }
804
805 return
806 }
807
808 switch {
809 case !open || item.Event == whatsmeow.QRChannelTimeout.Event:
810 answer(whatsappPairExpired)
811 return
812 case item.Event == whatsmeow.QRChannelEventCode:
813 continue
814 case item.Event == whatsmeow.QRChannelSuccess.Event:
815 linked = true
816 w.linkedBy(client)
817
818 err = l.note("WhatsApp is linked.")
819 if err != nil {
820 w.logger.Printf("note the link: %v", err)
821 }
822
823 answer("WhatsApp is linked to +" + phone + ".")
824
825 return
826 default:
827 answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.")
828 return
829 }
830 }
831 }
832
833 // newDevice returns a new device in the session database. It makes the
834 // directory and the file of the session first, with their modes, when they do
835 // not exist.
836 func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) {
837 w.mutex.Lock()
838 defer w.mutex.Unlock()
839
840 if w.container == nil {
841 err := os.MkdirAll(w.dir, 0o700)
842 if err != nil {
843 return nil, fmt.Errorf("create WhatsApp directory: %w", err)
844 }
845
846 file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600)
847 if err != nil {
848 return nil, fmt.Errorf("create WhatsApp session: %w", err)
849 }
850
851 err = file.Close()
852 if err != nil {
853 return nil, fmt.Errorf("create WhatsApp session: %w", err)
854 }
855
856 err = w.openSession(ctx)
857 if err != nil {
858 return nil, err
859 }
860 }
861
862 w.device = w.container.NewDevice()
863
864 return w.device, nil
865 }
866
867 // linkedBy makes client the client of the linked device.
868 func (w *WhatsApp) linkedBy(client WhatsAppClient) {
869 w.mutex.Lock()
870 defer w.mutex.Unlock()
871
872 w.client = client
873 w.linked = true
874 }
875
876 // handle takes one event of the library and tells if it was handled. A message
877 // goes to receive. The names of the groups follow the events of the groups. A
878 // connection starts a catch-up, which ends at OfflineSyncCompleted or after
879 // catchupLimit. A disconnection keeps the time of its start while the library
880 // tries again. After noticeDelay of a disconnection, all clients get one
881 // notice, and they get one more when the connection comes back. A disconnection
882 // that the library does not try again gives a notice at once.
883 func (w *WhatsApp) handle(event any) bool {
884 switch event := event.(type) {
885 case *events.Message:
886 return w.receive(event)
887 case *events.GroupInfo:
888 if event.Name != nil {
889 w.groups.set(event.JID.String(), event.Name.Name)
890 }
891
892 return true
893 case *events.JoinedGroup:
894 w.groups.set(event.JID.String(), event.Name)
895 return true
896 }
897
898 var notice string
899 w.mutex.Lock()
900 switch event := event.(type) {
901 case *events.PairSuccess:
902 w.own = whatsappOwnIDs(event.ID.User, event.LID.User)
903 case *events.Connected:
904 w.connected = true
905 w.connectedAt = w.clock()
906 w.catchingUp = true
907 w.catchups++
908 catchups := w.catchups
909 if w.catchupTimer != nil {
910 w.catchupTimer.Stop()
911 }
912
913 w.catchupTimer = time.AfterFunc(w.catchupLimit, func() {
914 w.caughtUpBy(catchups)
915 })
916 if w.timer != nil {
917 w.timer.Stop()
918 }
919
920 if w.noticed {
921 w.noticed = false
922 notice = "WhatsApp is connected again."
923 }
924 case *events.Disconnected:
925 if w.linked && (w.connected || w.disconnectedAt.IsZero()) {
926 w.disconnectedAt = w.clock()
927 w.disconnections++
928 disconnections := w.disconnections
929 w.timer = time.AfterFunc(w.noticeDelay, func() {
930 w.disconnectedLong(disconnections)
931 })
932 }
933
934 w.connected = false
935 case *events.OfflineSyncCompleted:
936 w.endCatchup()
937 case *events.LoggedOut:
938 select {
939 case w.logouts <- struct{}{}:
940 default:
941 }
942 case events.PermanentDisconnect:
943 if w.linked {
944 w.connected = false
945 w.noticed = true
946 notice = "WhatsApp is disconnected and does not connect again by itself: " + event.PermanentDisconnectDescription() + ". Restart the daemon to try again."
947 }
948 }
949 w.mutex.Unlock()
950
951 if notice != "" {
952 w.broadcast(notice)
953 }
954
955 return true
956 }
957
958 // receive puts the text of event into its batch and wakes Run. It returns false
959 // when the state file cannot hold the message, so that the library does not
960 // acknowledge it, and Run stops the daemon. A message that is not text, or that
961 // the batches do not take, is handled with nothing to do.
962 func (w *WhatsApp) receive(event *events.Message) bool {
963 ctx, cancel := context.WithTimeout(context.Background(), whatsappLookupTime)
964 defer cancel()
965
966 w.mutex.Lock()
967 device := w.device
968 own := slices.Clone(w.own)
969 w.mutex.Unlock()
970
971 chat, message, found := whatsappMessage(ctx, device, own, event)
972 if !found {
973 return true
974 }
975
976 _, err := w.state.Add(chat, message, time.Now())
977 if err != nil {
978 select {
979 case w.failures <- err:
980 default:
981 }
982
983 return false
984 }
985
986 select {
987 case w.wake <- struct{}{}:
988 default:
989 }
990
991 return true
992 }
993
994 // caughtUpBy ends the catch-up of the connection with the number catchups, when
995 // no later connection started a new one.
996 func (w *WhatsApp) caughtUpBy(catchups int) {
997 w.mutex.Lock()
998 defer w.mutex.Unlock()
999
1000 if w.catchups == catchups {
1001 w.endCatchup()
1002 }
1003 }
1004
1005 // endCatchup ends a running catch-up and wakes Run, which logs all batches. The
1006 // caller holds the mutex.
1007 func (w *WhatsApp) endCatchup() {
1008 if !w.catchingUp {
1009 return
1010 }
1011
1012 w.catchingUp = false
1013 w.caughtUp = true
1014 if w.catchupTimer != nil {
1015 w.catchupTimer.Stop()
1016 }
1017
1018 select {
1019 case w.wake <- struct{}{}:
1020 default:
1021 }
1022 }
1023
1024 // disconnectedLong gives the notice of a long disconnection, unless the
1025 // disconnection with the number disconnections ended.
1026 func (w *WhatsApp) disconnectedLong(disconnections int) {
1027 w.mutex.Lock()
1028 long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections
1029 if long {
1030 w.noticed = true
1031 }
1032
1033 since := w.disconnectedAt
1034 w.mutex.Unlock()
1035
1036 if long {
1037 w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.")
1038 }
1039 }
1040
1041 // broadcast gives text as a notice to all clients.
1042 func (w *WhatsApp) broadcast(text string) {
1043 w.mutex.Lock()
1044 notify := w.notify
1045 closed := w.closed
1046 w.mutex.Unlock()
1047
1048 if notify != nil && !closed {
1049 notify(Event{
1050 Kind: "notice",
1051 Text: text,
1052 })
1053 }
1054 }
1055
1056 // redact replaces the own number and the own LID in text with OWN.
1057 func (w *WhatsApp) redact(text string) string {
1058 w.mutex.Lock()
1059 defer w.mutex.Unlock()
1060
1061 return whatsappRedact(w.own, text)
1062 }
1063
1064 // Send sends text to chat, the ID of a private chat or a group, and returns the
1065 // answer of the tool. id names the send: a call with an id that has a record
1066 // gives the answer of the record and sends nothing, and the same id with
1067 // another chat or text is an error. The record "sending" is durable before the
1068 // send, so a stop of the daemon during the send gives an answer that the result
1069 // is unknown, and not a second send. A disconnected WhatsApp sends nothing and
1070 // makes no record.
1071 func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) {
1072 w.mutex.Lock()
1073 linked := w.linked
1074 device := w.device
1075 client := w.client
1076 connected := w.connected
1077 own := slices.Clone(w.own)
1078 w.mutex.Unlock()
1079
1080 if !linked {
1081 return whatsappNotLinked, nil
1082 }
1083
1084 jid, err := whatsappSendCheck(id, chat, text, own)
1085 if err != nil {
1086 return "", err
1087 }
1088
1089 digest := sha256.Sum256([]byte(text))
1090 record := WhatsAppSend{
1091 ID: id,
1092 Chat: chat,
1093 Digest: hex.EncodeToString(digest[:]),
1094 State: whatsappSending,
1095 Time: w.clock(),
1096 }
1097
1098 held, found := w.state.Send(id)
1099 switch {
1100 case found && (held.Chat != record.Chat || held.Digest != record.Digest):
1101 return "", fmt.Errorf("the id %s belongs to another message: give a new id for each message", id)
1102 case found && held.State == whatsappSending:
1103 return "The send with this id started, but its result is unknown, because the daemon stopped during it. Ask the user to look at the chat in WhatsApp before you send the message again with a new id.", nil
1104 case found:
1105 return held.Answer + " This is the answer of the first call with this id: nothing was sent again.", nil
1106 case client == nil || !connected:
1107 return "", errors.New("WhatsApp is disconnected, so nothing was sent: try again later")
1108 }
1109
1110 record.MessageID = client.GenerateMessageID()
1111 err = w.state.PutSend(record)
1112 if err != nil {
1113 return "", err
1114 }
1115
1116 sendCtx, cancel := context.WithTimeout(ctx, whatsappSendTime)
1117 defer cancel()
1118
1119 response, err := client.SendMessage(sendCtx, jid, &waE2E.Message{
1120 Conversation: new(text),
1121 }, whatsmeow.SendRequestExtra{
1122 ID: record.MessageID,
1123 })
1124
1125 record.State = whatsappSent
1126 if err != nil {
1127 record.State = whatsappFailed
1128 record.Answer = "The send failed: " + w.redact(err.Error()) + "."
1129 } else {
1130 name := w.groups.name(ctx, w.link(), jid.String())
1131 if jid.Server != types.GroupServer {
1132 name = whatsappContactName(ctx, device, jid, "")
1133 }
1134
1135 record.Answer = "Sent to " + name + " at " + response.Timestamp.In(w.clock().Location()).Format("15:04") + "."
1136 }
1137
1138 err = w.state.PutSend(record)
1139 if err != nil {
1140 return "", err
1141 }
1142
1143 return record.Answer, nil
1144 }
1145
1146 // WhatsAppMatch is one contact or group of Find.
1147 type WhatsAppMatch struct {
1148 id string
1149 name string
1150 kind string
1151 }
1152
1153 // WhatsAppLink is a copy of the link of the device at one time, for the parts
1154 // of WhatsApp that do not own it: the groups, the batches, find, and send.
1155 type WhatsAppLink struct {
1156 linked bool
1157 device *store.Device
1158 client WhatsAppClient
1159 connected bool
1160 own []string
1161 }
1162
1163 // WhatsAppRequest is a pair or unlink command for Run, with the function that
1164 // gives its answers to the client that asked. A pairing has the phone number.
1165 type WhatsAppRequest struct {
1166 phone string
1167 unlink bool
1168 answer func(text string)
1169 }
1170
1171 // whatsappNewClient returns the client of the library for device, with handle
1172 // as its event handler. The client tries again after a failed first connection,
1173 // acknowledges a message only after handle returns true, and downloads no old
1174 // history. The phone shows the device with the name OneChat.
1175 func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
1176 store.SetOSInfo("OneChat", [3]uint32{0, 1, 0})
1177 client := whatsmeow.NewClient(device, waLog.Noop)
1178 client.InitialAutoReconnect = true
1179 client.SynchronousAck = true
1180 client.ManualHistorySyncDownload = true
1181 client.AddEventHandlerWithSuccessStatus(handle)
1182
1183 return client
1184 }
1185
1186 // whatsappMessage returns the chat and the batch message of event, or false
1187 // when the batches do not take it. device is the linked device, and own holds
1188 // the own IDs. The batches take the text of a private chat or a group, from
1189 // another person or from the user. They do not take media, reactions, edits,
1190 // deletions, view-once and disappearing messages, old history, status
1191 // broadcasts, channels, and the chat of the user with itself.
1192 //
1193 // A mention of the user becomes @you, another mention @NAME when the name is
1194 // known, and the own number and LID become OWN, so that they never go to the
1195 // log.
1196 func whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
1197 info := event.Info
1198 chatJID := info.Chat.ToNonAD()
1199 switch {
1200 case device == nil:
1201 case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil:
1202 case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
1203 case slices.Contains(own, chatJID.User):
1204 default:
1205 return whatsappText(ctx, device, own, event)
1206 }
1207
1208 return WhatsAppChat{}, WhatsAppMessage{}, false
1209 }
1210
1211 // whatsappText returns the chat and the batch message of event, a message that
1212 // the batches take when it has text.
1213 func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
1214 info := event.Info
1215 text := event.Message.GetConversation()
1216 extended := event.Message.GetExtendedTextMessage()
1217 if text == "" {
1218 text = extended.GetText()
1219 }
1220
1221 if strings.TrimSpace(text) == "" {
1222 return WhatsAppChat{}, WhatsAppMessage{}, false
1223 }
1224
1225 contextInfo := extended.GetContextInfo()
1226 message := WhatsAppMessage{
1227 ID: info.ID,
1228 Time: info.Timestamp,
1229 Mine: info.IsFromMe,
1230 }
1231
1232 if !message.Mine {
1233 message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
1234 }
1235
1236 for _, mentioned := range contextInfo.GetMentionedJID() {
1237 jid, err := types.ParseJID(mentioned)
1238 switch {
1239 case err != nil:
1240 case slices.Contains(own, jid.User):
1241 message.Mention = true
1242 text = strings.ReplaceAll(text, "@"+jid.User, "@you")
1243 default:
1244 text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, ""))
1245 }
1246 }
1247
1248 participant, err := types.ParseJID(contextInfo.GetParticipant())
1249 message.Reply = err == nil && slices.Contains(own, participant.User)
1250
1251 message.Text = whatsappRedact(own, text)
1252 chatJID := info.Chat.ToNonAD()
1253 chat := WhatsAppChat{
1254 ID: chatJID.String(),
1255 Group: chatJID.Server == types.GroupServer,
1256 }
1257
1258 if !chat.Group {
1259 push := info.PushName
1260 if message.Mine {
1261 push = ""
1262 }
1263
1264 chat.Name = whatsappContactName(ctx, device, chatJID, push)
1265 }
1266
1267 return chat, message, true
1268 }
1269
1270 // whatsappContactName returns the name of the person jid: the full name, the
1271 // first name, the business name, or the push name of the contact in the session
1272 // of device, then push, the push name of a message, then the phone number. A
1273 // LID gets its phone number from the LID map of the session.
1274 func whatsappContactName(ctx context.Context, device *store.Device, jid types.JID, push string) string {
1275 jid = jid.ToNonAD()
1276 candidates := []types.JID{jid}
1277 phone := types.EmptyJID
1278 switch jid.Server {
1279 case types.DefaultUserServer:
1280 phone = jid
1281 case types.HiddenUserServer:
1282 mapped, err := device.LIDs.GetPNForLID(ctx, jid)
1283 if err == nil && !mapped.IsEmpty() {
1284 phone = mapped
1285 candidates = append(candidates, mapped)
1286 }
1287 }
1288
1289 for _, candidate := range candidates {
1290 contact, err := device.Contacts.GetContact(ctx, candidate)
1291 if err != nil || !contact.Found {
1292 continue
1293 }
1294
1295 for _, name := range []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName} {
1296 if name != "" {
1297 return name
1298 }
1299 }
1300 }
1301
1302 switch {
1303 case push != "":
1304 return push
1305 case !phone.IsEmpty():
1306 return "+" + phone.User
1307 default:
1308 return jid.User
1309 }
1310 }
1311
1312 // whatsappFold returns text in lower case with no accents and no space at its
1313 // ends, so that names match with no regard to case and accents.
1314 func whatsappFold(text string) string {
1315 folded, _, err := transform.String(transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC), text)
1316 if err != nil {
1317 folded = text
1318 }
1319
1320 return strings.ToLower(strings.TrimSpace(folded))
1321 }
1322
1323 // whatsappFindAnswer returns the answer of Find for name with matches. Without
1324 // groups, the answer says that it has no groups.
1325 func whatsappFindAnswer(name string, matches []WhatsAppMatch, groups bool) string {
1326 slices.SortFunc(matches, func(a, b WhatsAppMatch) int {
1327 return cmp.Or(strings.Compare(whatsappFold(a.name), whatsappFold(b.name)), strings.Compare(a.id, b.id))
1328 })
1329
1330 var lines []string
1331 for _, match := range matches[:min(len(matches), whatsappFindMatches)] {
1332 lines = append(lines, match.id+" | "+match.name+" | "+match.kind)
1333 }
1334
1335 if len(matches) == 0 {
1336 lines = append(lines, "No contact or group has a name with \""+name+"\".")
1337 }
1338
1339 if len(matches) > whatsappFindMatches {
1340 lines = append(lines, fmt.Sprintf("%d more: give a longer part of the name.", len(matches)-whatsappFindMatches))
1341 }
1342
1343 if !groups {
1344 lines = append(lines, "The groups are not in this answer, because WhatsApp is disconnected.")
1345 }
1346
1347 return strings.Join(lines, "\n")
1348 }
1349
1350 // whatsappSendCheck returns the JID of chat, and an error when id, chat, or
1351 // text cannot go to a send. The chat is a private chat or a group, and not the
1352 // chat of the user with itself.
1353 func whatsappSendCheck(id, chat, text string, own []string) (types.JID, error) {
1354 jid, err := types.ParseJID(chat)
1355 switch {
1356 case strings.TrimSpace(id) == "" || len(id) > whatsappSendIDBytes:
1357 return types.EmptyJID, fmt.Errorf("the id must have 1 to %d bytes", whatsappSendIDBytes)
1358 case strings.TrimSpace(text) == "" || len(text) > whatsappSendBytes:
1359 return types.EmptyJID, fmt.Errorf("the text must have 1 to %d bytes", whatsappSendBytes)
1360 case err != nil || jid.User == "" || jid != jid.ToNonAD() || jid.String() != chat:
1361 return types.EmptyJID, fmt.Errorf("%q is not the ID of a WhatsApp chat: find the ID with whatsapp_find", chat)
1362 case jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer && jid.Server != types.GroupServer:
1363 return types.EmptyJID, fmt.Errorf("%q is not a private chat or a group", chat)
1364 case slices.Contains(own, jid.User):
1365 return types.EmptyJID, errors.New("this is the chat of the user with itself: send to another chat")
1366 }
1367
1368 return jid, nil
1369 }
1370
1371 // whatsappOwnIDs returns the own number and the own LID that are not empty.
1372 func whatsappOwnIDs(ids ...string) []string {
1373 var own []string
1374 for _, id := range ids {
1375 if id != "" {
1376 own = append(own, id)
1377 }
1378 }
1379
1380 return own
1381 }
1382
1383 // whatsappRedact replaces each of the own IDs own in text with OWN.
1384 func whatsappRedact(own []string, text string) string {
1385 for _, id := range own {
1386 text = strings.ReplaceAll(text, id, "OWN")
1387 }
1388
1389 return text
1390 }
1391
1392 // whatsappSessionAddress returns the SQLite address of the session database at
1393 // path, with foreign keys on, as the library needs, and a wait for a busy
1394 // database.
1395 func whatsappSessionAddress(path string) (string, error) {
1396 path, err := filepath.Abs(path)
1397 if err != nil {
1398 return "", fmt.Errorf("find WhatsApp session: %w", err)
1399 }
1400
1401 address := url.URL{
1402 Scheme: "file",
1403 Path: path,
1404 RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
1405 }
1406
1407 return address.String(), nil
1408 }
1409
1410 // whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
1411 // command, or false for any other line.
1412 func whatsappCommandArgs(line string) (string, bool) {
1413 line = strings.TrimSpace(line)
1414 if line == whatsappCommand {
1415 return "", true
1416 }
1417
1418 args, found := strings.CutPrefix(line, whatsappCommand+" ")
1419 if !found {
1420 return "", false
1421 }
1422
1423 return strings.TrimSpace(args), true
1424 }
1425
1426 // whatsappPhone returns the digits of phone, a number with its country code and
1427 // an optional + and dashes, and tells if it has the form of a number: 7 to 15
1428 // digits, with no 0 first.
1429 func whatsappPhone(phone string) (string, bool) {
1430 phone = strings.ReplaceAll(strings.TrimPrefix(phone, "+"), "-", "")
1431 valid := len(phone) >= 7 && len(phone) <= 15 && phone[0] != '0' && strings.Trim(phone, "0123456789") == ""
1432
1433 return phone, valid
1434 }
1435
1436 // whatsappItemError returns the error of a pairing item that is not a code, a
1437 // success, or a timeout.
1438 func whatsappItemError(item whatsmeow.QRChannelItem) string {
1439 if item.Error != nil {
1440 return item.Error.Error()
1441 }
1442
1443 return item.Event
1444 }