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