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