]> git.giacomolli.uk - onechat/blob - whatsapp.go
Convert WhatsApp events with pure functions
[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         w.mutex.Lock()
1023         device := w.device
1024         own := slices.Clone(w.own)
1025         w.mutex.Unlock()
1026
1027         chat, message, found := whatsappMessage(ctx, device, own, event)
1028         if !found {
1029                 return true
1030         }
1031
1032         _, err := w.state.Add(chat, message, time.Now())
1033         if err != nil {
1034                 select {
1035                 case w.failures <- err:
1036                 default:
1037                 }
1038
1039                 return false
1040         }
1041
1042         select {
1043         case w.wake <- struct{}{}:
1044         default:
1045         }
1046
1047         return true
1048 }
1049
1050 // caughtUpBy ends the catch-up of the connection with the number catchups, when
1051 // no later connection started a new one.
1052 func (w *WhatsApp) caughtUpBy(catchups int) {
1053         w.mutex.Lock()
1054         defer w.mutex.Unlock()
1055
1056         if w.catchups == catchups {
1057                 w.endCatchup()
1058         }
1059 }
1060
1061 // endCatchup ends a running catch-up and wakes Run, which logs all batches. The
1062 // caller holds the mutex.
1063 func (w *WhatsApp) endCatchup() {
1064         if !w.catchingUp {
1065                 return
1066         }
1067
1068         w.catchingUp = false
1069         w.caughtUp = true
1070         if w.catchupTimer != nil {
1071                 w.catchupTimer.Stop()
1072         }
1073
1074         select {
1075         case w.wake <- struct{}{}:
1076         default:
1077         }
1078 }
1079
1080 // disconnectedLong gives the notice of a long disconnection, unless the
1081 // disconnection with the number disconnections ended.
1082 func (w *WhatsApp) disconnectedLong(disconnections int) {
1083         w.mutex.Lock()
1084         long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections
1085         if long {
1086                 w.noticed = true
1087         }
1088
1089         since := w.disconnectedAt
1090         w.mutex.Unlock()
1091
1092         if long {
1093                 w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.")
1094         }
1095 }
1096
1097 // broadcast gives text as a notice to all clients.
1098 func (w *WhatsApp) broadcast(text string) {
1099         w.mutex.Lock()
1100         notify := w.notify
1101         closed := w.closed
1102         w.mutex.Unlock()
1103
1104         if notify != nil && !closed {
1105                 notify(Event{
1106                         Kind: "notice",
1107                         Text: text,
1108                 })
1109         }
1110 }
1111
1112 // redact replaces the own number and the own LID in text with OWN.
1113 func (w *WhatsApp) redact(text string) string {
1114         w.mutex.Lock()
1115         defer w.mutex.Unlock()
1116
1117         return whatsappRedact(w.own, text)
1118 }
1119
1120 // Send sends text to chat, the ID of a private chat or a group, and returns the
1121 // answer of the tool. id names the send: a call with an id that has a record
1122 // gives the answer of the record and sends nothing, and the same id with
1123 // another chat or text is an error. The record "sending" is durable before the
1124 // send, so a stop of the daemon during the send gives an answer that the result
1125 // is unknown, and not a second send. A disconnected WhatsApp sends nothing and
1126 // makes no record.
1127 func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) {
1128         w.mutex.Lock()
1129         linked := w.linked
1130         device := w.device
1131         client := w.client
1132         connected := w.connected
1133         own := slices.Clone(w.own)
1134         w.mutex.Unlock()
1135
1136         if !linked {
1137                 return whatsappNotLinked, nil
1138         }
1139
1140         jid, err := whatsappSendCheck(id, chat, text, own)
1141         if err != nil {
1142                 return "", err
1143         }
1144
1145         digest := sha256.Sum256([]byte(text))
1146         record := WhatsAppSend{
1147                 ID:     id,
1148                 Chat:   chat,
1149                 Digest: hex.EncodeToString(digest[:]),
1150                 State:  whatsappSending,
1151                 Time:   w.clock(),
1152         }
1153
1154         held, found := w.state.Send(id)
1155         switch {
1156         case found && (held.Chat != record.Chat || held.Digest != record.Digest):
1157                 return "", fmt.Errorf("the id %s belongs to another message: give a new id for each message", id)
1158         case found && held.State == whatsappSending:
1159                 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
1160         case found:
1161                 return held.Answer + " This is the answer of the first call with this id: nothing was sent again.", nil
1162         case client == nil || !connected:
1163                 return "", errors.New("WhatsApp is disconnected, so nothing was sent: try again later")
1164         }
1165
1166         record.MessageID = client.GenerateMessageID()
1167         err = w.state.PutSend(record)
1168         if err != nil {
1169                 return "", err
1170         }
1171
1172         sendCtx, cancel := context.WithTimeout(ctx, whatsappSendTime)
1173         defer cancel()
1174
1175         response, err := client.SendMessage(sendCtx, jid, &waE2E.Message{
1176                 Conversation: new(text),
1177         }, whatsmeow.SendRequestExtra{
1178                 ID: record.MessageID,
1179         })
1180
1181         record.State = whatsappSent
1182         if err != nil {
1183                 record.State = whatsappFailed
1184                 record.Answer = "The send failed: " + w.redact(err.Error()) + "."
1185         } else {
1186                 name := w.groupName(ctx, jid.String())
1187                 if jid.Server != types.GroupServer {
1188                         name = whatsappContactName(ctx, device, jid, "")
1189                 }
1190
1191                 record.Answer = "Sent to " + name + " at " + response.Timestamp.In(w.clock().Location()).Format("15:04") + "."
1192         }
1193
1194         err = w.state.PutSend(record)
1195         if err != nil {
1196                 return "", err
1197         }
1198
1199         return record.Answer, nil
1200 }
1201
1202 // WhatsAppMatch is one contact or group of Find.
1203 type WhatsAppMatch struct {
1204         id   string
1205         name string
1206         kind string
1207 }
1208
1209 // WhatsAppRequest is a pair or unlink command for Run, with the function that
1210 // gives its answers to the client that asked. A pairing has the phone number.
1211 type WhatsAppRequest struct {
1212         phone  string
1213         unlink bool
1214         answer func(text string)
1215 }
1216
1217 // whatsappNewClient returns the client of the library for device, with handle
1218 // as its event handler. The client tries again after a failed first connection,
1219 // acknowledges a message only after handle returns true, and downloads no old
1220 // history. The phone shows the device with the name OneChat.
1221 func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
1222         store.SetOSInfo("OneChat", [3]uint32{0, 1, 0})
1223         client := whatsmeow.NewClient(device, waLog.Noop)
1224         client.InitialAutoReconnect = true
1225         client.SynchronousAck = true
1226         client.ManualHistorySyncDownload = true
1227         client.AddEventHandlerWithSuccessStatus(handle)
1228
1229         return client
1230 }
1231
1232 // whatsappMessage returns the chat and the batch message of event, or false
1233 // when the batches do not take it. device is the linked device, and own holds
1234 // the own IDs. The batches take the text of a private chat or a group, from
1235 // another person or from the user. They do not take media, reactions, edits,
1236 // deletions, view-once and disappearing messages, old history, status
1237 // broadcasts, channels, and the chat of the user with itself.
1238 //
1239 // A mention of the user becomes @you, another mention @NAME when the name is
1240 // known, and the own number and LID become OWN, so that they never go to the
1241 // log.
1242 func whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
1243         info := event.Info
1244         chatJID := info.Chat.ToNonAD()
1245         switch {
1246         case device == nil:
1247         case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil:
1248         case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
1249         case slices.Contains(own, chatJID.User):
1250         default:
1251                 return whatsappText(ctx, device, own, event)
1252         }
1253
1254         return WhatsAppChat{}, WhatsAppMessage{}, false
1255 }
1256
1257 // whatsappText returns the chat and the batch message of event, a message that
1258 // the batches take when it has text.
1259 func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
1260         info := event.Info
1261         text := event.Message.GetConversation()
1262         extended := event.Message.GetExtendedTextMessage()
1263         if text == "" {
1264                 text = extended.GetText()
1265         }
1266
1267         if strings.TrimSpace(text) == "" {
1268                 return WhatsAppChat{}, WhatsAppMessage{}, false
1269         }
1270
1271         contextInfo := extended.GetContextInfo()
1272         message := WhatsAppMessage{
1273                 ID:   info.ID,
1274                 Time: info.Timestamp,
1275                 Mine: info.IsFromMe,
1276         }
1277
1278         if !message.Mine {
1279                 message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
1280         }
1281
1282         for _, mentioned := range contextInfo.GetMentionedJID() {
1283                 jid, err := types.ParseJID(mentioned)
1284                 switch {
1285                 case err != nil:
1286                 case slices.Contains(own, jid.User):
1287                         message.Mention = true
1288                         text = strings.ReplaceAll(text, "@"+jid.User, "@you")
1289                 default:
1290                         text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, ""))
1291                 }
1292         }
1293
1294         participant, err := types.ParseJID(contextInfo.GetParticipant())
1295         message.Reply = err == nil && slices.Contains(own, participant.User)
1296
1297         message.Text = whatsappRedact(own, text)
1298         chatJID := info.Chat.ToNonAD()
1299         chat := WhatsAppChat{
1300                 ID:    chatJID.String(),
1301                 Group: chatJID.Server == types.GroupServer,
1302         }
1303
1304         if !chat.Group {
1305                 push := info.PushName
1306                 if message.Mine {
1307                         push = ""
1308                 }
1309
1310                 chat.Name = whatsappContactName(ctx, device, chatJID, push)
1311         }
1312
1313         return chat, message, true
1314 }
1315
1316 // whatsappContactName returns the name of the person jid: the full name, the
1317 // first name, the business name, or the push name of the contact in the session
1318 // of device, then push, the push name of a message, then the phone number. A
1319 // LID gets its phone number from the LID map of the session.
1320 func whatsappContactName(ctx context.Context, device *store.Device, jid types.JID, push string) string {
1321         jid = jid.ToNonAD()
1322         candidates := []types.JID{jid}
1323         phone := types.EmptyJID
1324         switch jid.Server {
1325         case types.DefaultUserServer:
1326                 phone = jid
1327         case types.HiddenUserServer:
1328                 mapped, err := device.LIDs.GetPNForLID(ctx, jid)
1329                 if err == nil && !mapped.IsEmpty() {
1330                         phone = mapped
1331                         candidates = append(candidates, mapped)
1332                 }
1333         }
1334
1335         for _, candidate := range candidates {
1336                 contact, err := device.Contacts.GetContact(ctx, candidate)
1337                 if err != nil || !contact.Found {
1338                         continue
1339                 }
1340
1341                 for _, name := range []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName} {
1342                         if name != "" {
1343                                 return name
1344                         }
1345                 }
1346         }
1347
1348         switch {
1349         case push != "":
1350                 return push
1351         case !phone.IsEmpty():
1352                 return "+" + phone.User
1353         default:
1354                 return jid.User
1355         }
1356 }
1357
1358 // whatsappFold returns text in lower case with no accents and no space at its
1359 // ends, so that names match with no regard to case and accents.
1360 func whatsappFold(text string) string {
1361         folded, _, err := transform.String(transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC), text)
1362         if err != nil {
1363                 folded = text
1364         }
1365
1366         return strings.ToLower(strings.TrimSpace(folded))
1367 }
1368
1369 // whatsappFindAnswer returns the answer of Find for name with matches. Without
1370 // groups, the answer says that it has no groups.
1371 func whatsappFindAnswer(name string, matches []WhatsAppMatch, groups bool) string {
1372         slices.SortFunc(matches, func(a, b WhatsAppMatch) int {
1373                 return cmp.Or(strings.Compare(whatsappFold(a.name), whatsappFold(b.name)), strings.Compare(a.id, b.id))
1374         })
1375
1376         var lines []string
1377         for _, match := range matches[:min(len(matches), whatsappFindMatches)] {
1378                 lines = append(lines, match.id+" | "+match.name+" | "+match.kind)
1379         }
1380
1381         if len(matches) == 0 {
1382                 lines = append(lines, "No contact or group has a name with \""+name+"\".")
1383         }
1384
1385         if len(matches) > whatsappFindMatches {
1386                 lines = append(lines, fmt.Sprintf("%d more: give a longer part of the name.", len(matches)-whatsappFindMatches))
1387         }
1388
1389         if !groups {
1390                 lines = append(lines, "The groups are not in this answer, because WhatsApp is disconnected.")
1391         }
1392
1393         return strings.Join(lines, "\n")
1394 }
1395
1396 // whatsappSendCheck returns the JID of chat, and an error when id, chat, or
1397 // text cannot go to a send. The chat is a private chat or a group, and not the
1398 // chat of the user with itself.
1399 func whatsappSendCheck(id, chat, text string, own []string) (types.JID, error) {
1400         jid, err := types.ParseJID(chat)
1401         switch {
1402         case strings.TrimSpace(id) == "" || len(id) > whatsappSendIDBytes:
1403                 return types.EmptyJID, fmt.Errorf("the id must have 1 to %d bytes", whatsappSendIDBytes)
1404         case strings.TrimSpace(text) == "" || len(text) > whatsappSendBytes:
1405                 return types.EmptyJID, fmt.Errorf("the text must have 1 to %d bytes", whatsappSendBytes)
1406         case err != nil || jid.User == "" || jid != jid.ToNonAD() || jid.String() != chat:
1407                 return types.EmptyJID, fmt.Errorf("%q is not the ID of a WhatsApp chat: find the ID with whatsapp_find", chat)
1408         case jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer && jid.Server != types.GroupServer:
1409                 return types.EmptyJID, fmt.Errorf("%q is not a private chat or a group", chat)
1410         case slices.Contains(own, jid.User):
1411                 return types.EmptyJID, errors.New("this is the chat of the user with itself: send to another chat")
1412         }
1413
1414         return jid, nil
1415 }
1416
1417 // whatsappOwnIDs returns the own number and the own LID that are not empty.
1418 func whatsappOwnIDs(ids ...string) []string {
1419         var own []string
1420         for _, id := range ids {
1421                 if id != "" {
1422                         own = append(own, id)
1423                 }
1424         }
1425
1426         return own
1427 }
1428
1429 // whatsappRedact replaces each of the own IDs own in text with OWN.
1430 func whatsappRedact(own []string, text string) string {
1431         for _, id := range own {
1432                 text = strings.ReplaceAll(text, id, "OWN")
1433         }
1434
1435         return text
1436 }
1437
1438 // whatsappSessionAddress returns the SQLite address of the session database at
1439 // path, with foreign keys on, as the library needs, and a wait for a busy
1440 // database.
1441 func whatsappSessionAddress(path string) (string, error) {
1442         path, err := filepath.Abs(path)
1443         if err != nil {
1444                 return "", fmt.Errorf("find WhatsApp session: %w", err)
1445         }
1446
1447         address := url.URL{
1448                 Scheme:   "file",
1449                 Path:     path,
1450                 RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
1451         }
1452
1453         return address.String(), nil
1454 }
1455
1456 // whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
1457 // command, or false for any other line.
1458 func whatsappCommandArgs(line string) (string, bool) {
1459         line = strings.TrimSpace(line)
1460         if line == whatsappCommand {
1461                 return "", true
1462         }
1463
1464         args, found := strings.CutPrefix(line, whatsappCommand+" ")
1465         if !found {
1466                 return "", false
1467         }
1468
1469         return strings.TrimSpace(args), true
1470 }
1471
1472 // whatsappPhone returns the digits of phone, a number with its country code and
1473 // an optional + and dashes, and tells if it has the form of a number: 7 to 15
1474 // digits, with no 0 first.
1475 func whatsappPhone(phone string) (string, bool) {
1476         phone = strings.ReplaceAll(strings.TrimPrefix(phone, "+"), "-", "")
1477         valid := len(phone) >= 7 && len(phone) <= 15 && phone[0] != '0' && strings.Trim(phone, "0123456789") == ""
1478
1479         return phone, valid
1480 }
1481
1482 // whatsappItemError returns the error of a pairing item that is not a code, a
1483 // success, or a timeout.
1484 func whatsappItemError(item whatsmeow.QRChannelItem) string {
1485         if item.Error != nil {
1486                 return item.Error.Error()
1487         }
1488
1489         return item.Event
1490 }