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