21 "go.mau.fi/whatsmeow/proto/waE2E"
22 "go.mau.fi/whatsmeow/store"
23 "go.mau.fi/whatsmeow/store/sqlstore"
24 "go.mau.fi/whatsmeow/types"
25 "go.mau.fi/whatsmeow/types/events"
26 waLog "go.mau.fi/whatsmeow/util/log"
27 "golang.org/x/text/runes"
28 "golang.org/x/text/transform"
29 "golang.org/x/text/unicode/norm"
31 // The session store of the library opens SQLite with this pure Go
33 _ "modernc.org/sqlite"
37 // whatsappDirName is the directory of the WhatsApp files in the data
39 whatsappDirName = "whatsapp"
41 // whatsappSessionFileName is the session database of the library in
43 whatsappSessionFileName = "session.db"
45 // whatsappCommand starts the input lines of the WhatsApp commands, such
46 // as "/whatsapp status".
47 whatsappCommand = "/whatsapp"
49 // whatsappUsage is the answer to a WhatsApp command that does not
51 whatsappUsage = "Use /whatsapp pair PHONE, /whatsapp status, or /whatsapp unlink. PHONE is the number of your WhatsApp account with its country code, such as +15550001111."
53 // whatsappPairSteps tells the user what to do with the pairing code,
54 // which comes in the next message.
55 whatsappPairSteps = "In WhatsApp on your phone, open Linked devices, tap Link a device, then tap Link with phone number instead, and enter the code of the next message. The code expires in about 2 minutes."
57 // whatsappPairExpired is the answer when the pairing code expires.
58 whatsappPairExpired = "The pairing code expired. Run /whatsapp pair PHONE again for a new code."
60 // whatsappTimeLayout is the layout of the times in the answers.
61 whatsappTimeLayout = "2006-01-02 15:04"
63 // whatsappPairTime is the longest time of a pairing. The library closes
64 // the pairing connection after about 160 seconds.
65 whatsappPairTime = 3 * time.Minute
67 // whatsappLogoutTime is the longest time of the logout call of an
69 whatsappLogoutTime = 30 * time.Second
71 // whatsappNoticeDelay is how long a disconnection lasts before the user
73 whatsappNoticeDelay = 10 * time.Minute
75 // whatsappRequests is the number of pair and unlink commands that can
79 // whatsappSource is the source of the work events of the batches.
80 whatsappSource = "whatsapp"
82 // whatsappLookupTime is the longest time of a read of the names in the
83 // session database or of the groups from WhatsApp.
84 whatsappLookupTime = 10 * time.Second
86 // whatsappCatchupLimit is the longest wait for the end of the catch-up
87 // after a connection.
88 whatsappCatchupLimit = time.Minute
90 // whatsappFindMatches is the most matches of whatsapp_find.
91 whatsappFindMatches = 20
93 // whatsappNotLinked is the answer of a WhatsApp tool without a link.
94 whatsappNotLinked = "WhatsApp is not linked. Tell the user to run /whatsapp pair PHONE in the terminal or in Telegram."
96 // whatsappSendTime is the longest time of a send.
97 whatsappSendTime = 30 * time.Second
99 // whatsappSendBytes is the most bytes of the text of a send.
100 whatsappSendBytes = 65536
102 // whatsappSendIDBytes is the most bytes of the ID of a send.
103 whatsappSendIDBytes = 100
105 // The states of a send record.
106 whatsappSending = "sending"
107 whatsappSent = "sent"
108 whatsappFailed = "failed"
110 // whatsappNamesNotReady is the answer of whatsapp_find before the names
111 // arrive from the phone.
112 whatsappNamesNotReady = "The names of the contacts and groups have not arrived from the phone yet. They come some minutes after the link. Try again later."
115 // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
116 // real one is a *whatsmeow.Client from whatsappNewClient, and the tests use a
118 type WhatsAppClient interface {
119 ConnectContext(ctx context.Context) error
121 GetQRChannel(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error)
122 PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error)
123 Logout(ctx context.Context) error
124 GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error)
125 GenerateMessageID() types.MessageID
126 SendMessage(ctx context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error)
129 // openWhatsApp opens the WhatsApp session in the data directory dir. Without
130 // the session file, WhatsApp is not linked. The session gives full access to
131 // the account, so a file that another user can read is an error. The clock
132 // gives the times of the status, logger gets the failures, and newClient makes
133 // the client of a device with its event handler.
134 func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *log.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
136 dir: filepath.Join(dir, whatsappDirName),
139 newClient: newClient,
140 noticeDelay: whatsappNoticeDelay,
141 catchupLimit: whatsappCatchupLimit,
142 requests: make(chan WhatsAppRequest, whatsappRequests),
143 logouts: make(chan struct{}, 1),
144 wake: make(chan struct{}, 1),
145 failures: make(chan error, 1),
146 groups: newWhatsAppGroups(logger),
150 w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow)
155 info, err := os.Stat(w.sessionPath())
156 if errors.Is(err, os.ErrNotExist) {
161 return nil, fmt.Errorf("read WhatsApp session: %w", err)
164 if info.Mode().Perm()&0o077 != 0 {
165 return nil, fmt.Errorf("%s holds the WhatsApp session, so its mode must be 0600", filepath.Join(whatsappDirName, whatsappSessionFileName))
168 err = w.openSession(ctx)
173 if w.device.ID != nil {
176 w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
182 // WhatsApp is the link of the daemon to the WhatsApp account of the user, as a
183 // linked device. The session database of the library in dir holds the keys of
184 // the device. Run keeps a linked device connected, and the library connects
185 // again by itself after a network failure.
187 // The commands pair and unlink go to Run as requests, and Run runs one at a
188 // time. A pairing runs in its own goroutine until its code expires, and a new
189 // pairing ends the one before. Its phone number and code go only to the answer
190 // of the client that asked. A logout from the phone comes as an event of the
191 // library, and Run deletes the session. The mutex protects the session, the
192 // client, and the state of the connection, which the event handler of the
193 // library changes from its own goroutines.
195 // The text messages go into batches, one chat at a time, as WhatsAppState says.
196 // Run logs each closed batch as a work message that starts with [WhatsApp], and
197 // a batch that needs the user wakes the turn loop.
199 // After each connection, the servers send the messages that came while the
200 // daemon was away, and then OfflineSyncCompleted. Until then, or until
201 // catchupLimit passes, the closed batches wait in the state file. At the end of
202 // the catch-up, Run closes all batches, logs them, and wakes the turn loop
203 // once, so the whole catch-up gives one turn and not one turn for each chat.
204 // The batches of the state file at the start wait for the first catch-up in the
205 // same way. The event handler acknowledges a message only after the state file
206 // holds it, so the servers send again a message that a crash lost. A batch
207 // leaves the state file only after its work message is durable in the log. A
208 // crash between the two makes the batch repeat once.
210 // The library logs nothing, because its log lines hold the ID of the own
211 // account, which is the phone number of the user. The own number and the own
212 // LID never go to the log of the agent or to the log of the daemon: redact
213 // removes them from the errors, and a batch shows the messages of the user as
214 // You and a mention of the user as @you. The IDs of the other chats go to the
215 // log, and those of private chats hold phone numbers, because the agent needs
217 type WhatsApp struct {
219 clock func() time.Time
221 newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient
222 noticeDelay time.Duration
223 catchupLimit time.Duration
224 requests chan WhatsAppRequest
225 logouts chan struct{}
229 groups *WhatsAppGroups
232 container *sqlstore.Container
234 client WhatsAppClient
239 connectedAt time.Time
240 disconnectedAt time.Time
248 catchupTimer *time.Timer
251 // Run connects a linked device, logs the closed batches in l, runs the pair and
252 // unlink commands, and handles a logout from the phone, until ctx ends. Its
253 // notes go to the log of l, and notify gets the notices for all clients. A
254 // failed first connection is logged, and the library tries again when the
255 // failure is one of the network. Run returns the error of a write of the log or
256 // of the state file, which stops the daemon, because then messages would be
258 func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error {
268 var pairing sync.WaitGroup
277 err := w.deliver(ctx, l, false)
282 var due <-chan time.Time
283 wait, open := w.state.Wait(time.Now())
285 due = time.After(wait)
291 case err = <-w.failures:
296 err = w.loggedOut(ctx, l)
300 case request := <-w.requests:
307 err = w.unlink(ctx, l, request.answer)
312 request.answer("WhatsApp is linked already. Run /whatsapp unlink first.")
314 pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTime)
319 w.pair(pairCtx, ctx, l, request.phone, request.answer)
326 // Command runs the WhatsApp command args, the text after /whatsapp, and gives
327 // each answer to answer. The answers go only to the client that sent the
329 func (w *WhatsApp) Command(args string, answer func(text string)) {
330 fields := strings.Fields(args)
331 request := WhatsAppRequest{
336 case len(fields) == 1 && fields[0] == "status":
339 case len(fields) == 1 && fields[0] == "unlink":
340 request.unlink = true
341 case len(fields) >= 2 && fields[0] == "pair":
342 phone, valid := whatsappPhone(strings.Join(fields[1:], ""))
344 answer("PHONE must be the number of your WhatsApp account with its country code: 7 to 15 digits, such as +15550001111.")
348 request.phone = phone
350 answer(whatsappUsage)
355 case w.requests <- request:
357 answer("Too many WhatsApp commands wait. Try again in a minute.")
361 // Status returns the state of the link: not linked, linked and connected, or
362 // linked and disconnected, with the times of the connection. It never holds the
364 func (w *WhatsApp) Status() string {
366 defer w.mutex.Unlock()
370 return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it."
372 return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "."
373 case w.disconnectedAt.IsZero():
374 return "WhatsApp is linked and not connected yet."
375 case w.connectedAt.IsZero():
376 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "."
378 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "."
382 // Close closes the session database.
383 func (w *WhatsApp) Close() error {
385 defer w.mutex.Unlock()
387 if w.container == nil {
391 err := w.container.Close()
397 func (w *WhatsApp) sessionPath() string {
398 return filepath.Join(w.dir, whatsappSessionFileName)
401 // openSession opens the session database and its first device. The caller holds
402 // the mutex, or no other goroutine uses w yet.
403 func (w *WhatsApp) openSession(ctx context.Context) error {
404 address, err := whatsappSessionAddress(w.sessionPath())
409 container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
411 return fmt.Errorf("open WhatsApp session: %w", err)
414 device, err := container.GetFirstDevice(ctx)
416 return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close())
419 w.container = container
425 func (w *WhatsApp) isLinked() bool {
427 defer w.mutex.Unlock()
432 // link returns a copy of the link of the device: whether it is linked, the
433 // device, the client, whether it is connected, and the own IDs.
434 func (w *WhatsApp) link() WhatsAppLink {
436 defer w.mutex.Unlock()
442 connected: w.connected,
443 own: slices.Clone(w.own),
447 // connect makes the client of the linked device and connects it.
448 func (w *WhatsApp) connect(ctx context.Context) {
450 w.client = w.newClient(w.device, w.handle)
454 err := client.ConnectContext(ctx)
456 w.logger.Printf("connect: %s", w.redact(err.Error()))
460 // close ends the connection and the timer of the notice, and stops all notices.
461 func (w *WhatsApp) close() {
468 if w.catchupTimer != nil {
469 w.catchupTimer.Stop()
481 // loggedOut logs the open batches, deletes the session after a logout from the
482 // phone, notes it in the log of l, and tells all clients one time.
483 func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
488 err := w.deliver(ctx, l, true)
493 err = w.removeSession()
495 w.logger.Printf("delete the session after the logout: %v", err)
498 err = l.note("WhatsApp was unlinked from the phone.")
500 w.logger.Printf("note the logout: %v", err)
503 w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
508 // unlink logs the open batches, logs out the linked device, and deletes the
509 // session. When the logout call fails, as it does while WhatsApp is
510 // disconnected, the session is deleted all the same, and answer tells the user
511 // to remove the device in the phone. Its error is one of the log or the state
513 func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) error {
520 answer("WhatsApp is not linked.")
524 err := w.deliver(ctx, l, true)
531 ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTime)
534 logoutErr = client.Logout(ctx)
537 err = w.removeSession()
539 answer("The unlink failed: " + w.redact(err.Error()))
543 err = l.note("WhatsApp is unlinked.")
545 w.logger.Printf("note the unlink: %v", err)
548 if client == nil || logoutErr != nil {
549 answer("WhatsApp is unlinked here, but the phone did not get the logout. In WhatsApp on your phone, open Linked devices and remove the device.")
553 answer("WhatsApp is unlinked.")
558 // deliver logs the closed batches in l in order, each as a work message of
559 // whatsappSource, and wakes the loop once with the batches that start a turn. A
560 // batch leaves the state file after its message is durable in the log. A group
561 // with no name gets its name from WhatsApp first. During a catch-up it logs
562 // nothing, and at its end it closes all batches first. With all, it closes and
563 // logs all batches also during a catch-up.
564 func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error {
566 catchingUp := w.catchingUp
567 caughtUp := w.caughtUp
573 case all || caughtUp:
574 err = w.state.CloseAll()
578 _, err = w.state.CloseDue(time.Now())
586 for _, batch := range w.state.Closed() {
587 if batch.Chat.Group && batch.Chat.Name == "" {
588 batch.Chat.Name = w.groups.name(ctx, w.link(), batch.Chat.ID)
591 message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "")
593 return fmt.Errorf("log a WhatsApp batch: %w", err)
596 // A stop of the daemon during the flush is no failure: the
597 // batch stays in the state file and repeats once at the next
600 if ctx.Err() != nil {
605 return fmt.Errorf("log a WhatsApp batch: %w", err)
608 err = w.state.Remove(batch.Number)
614 turns = append(turns, message)
623 // Find returns the contacts and the groups whose name contains name, with no
624 // regard to case and accents, as lines "ID | name | contact" or "ID | name |
625 // group", sorted by name. It gives at most whatsappFindMatches lines and the
626 // number of the others. The contacts come from the session database, and the
627 // groups from WhatsApp, read again after whatsappGroupsAge. When the groups
628 // cannot be read and none are known, the answer says so. Before the names
629 // arrive from the phone, it says that they are not ready.
630 func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) {
634 own := slices.Clone(w.own)
637 stale := w.groups.stale()
640 return whatsappNotLinked, nil
643 query := whatsappFold(name)
645 return "", errors.New("name is empty: give a part of the name of the contact or the group")
648 contacts, err := device.Contacts.GetAllContacts(ctx)
650 return "", fmt.Errorf("read the contacts: %s", w.redact(err.Error()))
653 read := !stale || w.groups.read(ctx, w.link())
654 groups := w.groups.all()
656 if len(contacts) == 0 && len(groups) == 0 {
658 return "The names are not known yet, and WhatsApp is disconnected, so the groups cannot be read. Try again later.", nil
661 return whatsappNamesNotReady, nil
664 var matches []WhatsAppMatch
665 for jid, contact := range contacts {
666 names := []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName}
667 shown := slices.IndexFunc(names, func(name string) bool {
671 if slices.Contains(own, jid.User) || shown < 0 || !slices.ContainsFunc(names, func(name string) bool {
672 return strings.Contains(whatsappFold(name), query)
677 matches = append(matches, WhatsAppMatch{
678 id: jid.ToNonAD().String(),
684 for id, group := range groups {
685 if strings.Contains(whatsappFold(group), query) {
686 matches = append(matches, WhatsAppMatch{
694 return whatsappFindAnswer(name, matches, read || len(groups) > 0), nil
697 // removeSession ends the connection, closes the session database, and deletes
699 func (w *WhatsApp) removeSession() error {
712 container := w.container
722 if container != nil {
723 err = container.Close()
726 for _, suffix := range []string{"", "-wal", "-shm"} {
727 removeErr := os.Remove(w.sessionPath() + suffix)
728 if !errors.Is(removeErr, os.ErrNotExist) {
729 err = errors.Join(err, removeErr)
736 // pair links a new device to the account of phone with a pairing code. It gives
737 // the steps and the code to answer, and then the end of the pairing: the linked
738 // number, an expired code, or a failure. A pairing that a new one ends gives no
739 // answer. A link notes "WhatsApp is linked." in the log of l, with no number.
740 // The client of a pairing that does not link is disconnected.
742 // ctx limits the pairing, but the connection uses runCtx, the context of Run.
743 // The library keeps the context of the connection for its keep-alive, its event
744 // handler, and its reconnections, and after the link the server ends the stream
745 // and the library connects again. With ctx, the end of the pairing would stop
746 // that reconnection, and the linked device would never connect.
747 func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string, answer func(text string)) {
748 device, err := w.newDevice(ctx)
750 answer("The pairing failed: " + err.Error())
754 client := w.newClient(device, w.handle)
762 qr, err := client.GetQRChannel(ctx)
764 err = client.ConnectContext(runCtx)
768 answer("The connection to WhatsApp failed: " + err.Error())
773 case item, open := <-qr:
774 if !open || item.Event != whatsmeow.QRChannelEventCode {
775 answer("WhatsApp did not start the pairing. Try again later.")
779 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
780 answer("The connection to WhatsApp did not start the pairing in time. Try again later.")
786 code, err := client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
788 answer("WhatsApp refused the pairing: " + err.Error() + ". Check the phone number and run /whatsapp pair PHONE again.")
792 answer(whatsappPairSteps)
796 var item whatsmeow.QRChannelItem
799 case item, open = <-qr:
801 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
802 answer(whatsappPairExpired)
809 case !open || item.Event == whatsmeow.QRChannelTimeout.Event:
810 answer(whatsappPairExpired)
812 case item.Event == whatsmeow.QRChannelEventCode:
814 case item.Event == whatsmeow.QRChannelSuccess.Event:
818 err = l.note("WhatsApp is linked.")
820 w.logger.Printf("note the link: %v", err)
823 answer("WhatsApp is linked to +" + phone + ".")
827 answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.")
833 // newDevice returns a new device in the session database. It makes the
834 // directory and the file of the session first, with their modes, when they do
836 func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) {
838 defer w.mutex.Unlock()
840 if w.container == nil {
841 err := os.MkdirAll(w.dir, 0o700)
843 return nil, fmt.Errorf("create WhatsApp directory: %w", err)
846 file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600)
848 return nil, fmt.Errorf("create WhatsApp session: %w", err)
853 return nil, fmt.Errorf("create WhatsApp session: %w", err)
856 err = w.openSession(ctx)
862 w.device = w.container.NewDevice()
867 // linkedBy makes client the client of the linked device.
868 func (w *WhatsApp) linkedBy(client WhatsAppClient) {
870 defer w.mutex.Unlock()
876 // handle takes one event of the library and tells if it was handled. A message
877 // goes to receive. The names of the groups follow the events of the groups. A
878 // connection starts a catch-up, which ends at OfflineSyncCompleted or after
879 // catchupLimit. A disconnection keeps the time of its start while the library
880 // tries again. After noticeDelay of a disconnection, all clients get one
881 // notice, and they get one more when the connection comes back. A disconnection
882 // that the library does not try again gives a notice at once.
883 func (w *WhatsApp) handle(event any) bool {
884 switch event := event.(type) {
885 case *events.Message:
886 return w.receive(event)
887 case *events.GroupInfo:
888 if event.Name != nil {
889 w.groups.set(event.JID.String(), event.Name.Name)
893 case *events.JoinedGroup:
894 w.groups.set(event.JID.String(), event.Name)
900 switch event := event.(type) {
901 case *events.PairSuccess:
902 w.own = whatsappOwnIDs(event.ID.User, event.LID.User)
903 case *events.Connected:
905 w.connectedAt = w.clock()
908 catchups := w.catchups
909 if w.catchupTimer != nil {
910 w.catchupTimer.Stop()
913 w.catchupTimer = time.AfterFunc(w.catchupLimit, func() {
914 w.caughtUpBy(catchups)
922 notice = "WhatsApp is connected again."
924 case *events.Disconnected:
925 if w.linked && (w.connected || w.disconnectedAt.IsZero()) {
926 w.disconnectedAt = w.clock()
928 disconnections := w.disconnections
929 w.timer = time.AfterFunc(w.noticeDelay, func() {
930 w.disconnectedLong(disconnections)
935 case *events.OfflineSyncCompleted:
937 case *events.LoggedOut:
939 case w.logouts <- struct{}{}:
942 case events.PermanentDisconnect:
946 notice = "WhatsApp is disconnected and does not connect again by itself: " + event.PermanentDisconnectDescription() + ". Restart the daemon to try again."
958 // receive puts the text of event into its batch and wakes Run. It returns false
959 // when the state file cannot hold the message, so that the library does not
960 // acknowledge it, and Run stops the daemon. A message that is not text, or that
961 // the batches do not take, is handled with nothing to do.
962 func (w *WhatsApp) receive(event *events.Message) bool {
963 ctx, cancel := context.WithTimeout(context.Background(), whatsappLookupTime)
968 own := slices.Clone(w.own)
971 chat, message, found := whatsappMessage(ctx, device, own, event)
976 _, err := w.state.Add(chat, message, time.Now())
979 case w.failures <- err:
987 case w.wake <- struct{}{}:
994 // caughtUpBy ends the catch-up of the connection with the number catchups, when
995 // no later connection started a new one.
996 func (w *WhatsApp) caughtUpBy(catchups int) {
998 defer w.mutex.Unlock()
1000 if w.catchups == catchups {
1005 // endCatchup ends a running catch-up and wakes Run, which logs all batches. The
1006 // caller holds the mutex.
1007 func (w *WhatsApp) endCatchup() {
1012 w.catchingUp = false
1014 if w.catchupTimer != nil {
1015 w.catchupTimer.Stop()
1019 case w.wake <- struct{}{}:
1024 // disconnectedLong gives the notice of a long disconnection, unless the
1025 // disconnection with the number disconnections ended.
1026 func (w *WhatsApp) disconnectedLong(disconnections int) {
1028 long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections
1033 since := w.disconnectedAt
1037 w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.")
1041 // broadcast gives text as a notice to all clients.
1042 func (w *WhatsApp) broadcast(text string) {
1048 if notify != nil && !closed {
1056 // redact replaces the own number and the own LID in text with OWN.
1057 func (w *WhatsApp) redact(text string) string {
1059 defer w.mutex.Unlock()
1061 return whatsappRedact(w.own, text)
1064 // Send sends text to chat, the ID of a private chat or a group, and returns the
1065 // answer of the tool. id names the send: a call with an id that has a record
1066 // gives the answer of the record and sends nothing, and the same id with
1067 // another chat or text is an error. The record "sending" is durable before the
1068 // send, so a stop of the daemon during the send gives an answer that the result
1069 // is unknown, and not a second send. A disconnected WhatsApp sends nothing and
1071 func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) {
1076 connected := w.connected
1077 own := slices.Clone(w.own)
1081 return whatsappNotLinked, nil
1084 jid, err := whatsappSendCheck(id, chat, text, own)
1089 digest := sha256.Sum256([]byte(text))
1090 record := WhatsAppSend{
1093 Digest: hex.EncodeToString(digest[:]),
1094 State: whatsappSending,
1098 held, found := w.state.Send(id)
1100 case found && (held.Chat != record.Chat || held.Digest != record.Digest):
1101 return "", fmt.Errorf("the id %s belongs to another message: give a new id for each message", id)
1102 case found && held.State == whatsappSending:
1103 return "The send with this id started, but its result is unknown, because the daemon stopped during it. Ask the user to look at the chat in WhatsApp before you send the message again with a new id.", nil
1105 return held.Answer + " This is the answer of the first call with this id: nothing was sent again.", nil
1106 case client == nil || !connected:
1107 return "", errors.New("WhatsApp is disconnected, so nothing was sent: try again later")
1110 record.MessageID = client.GenerateMessageID()
1111 err = w.state.PutSend(record)
1116 sendCtx, cancel := context.WithTimeout(ctx, whatsappSendTime)
1119 response, err := client.SendMessage(sendCtx, jid, &waE2E.Message{
1120 Conversation: new(text),
1121 }, whatsmeow.SendRequestExtra{
1122 ID: record.MessageID,
1125 record.State = whatsappSent
1127 record.State = whatsappFailed
1128 record.Answer = "The send failed: " + w.redact(err.Error()) + "."
1130 name := w.groups.name(ctx, w.link(), jid.String())
1131 if jid.Server != types.GroupServer {
1132 name = whatsappContactName(ctx, device, jid, "")
1135 record.Answer = "Sent to " + name + " at " + response.Timestamp.In(w.clock().Location()).Format("15:04") + "."
1138 err = w.state.PutSend(record)
1143 return record.Answer, nil
1146 // WhatsAppMatch is one contact or group of Find.
1147 type WhatsAppMatch struct {
1153 // WhatsAppLink is a copy of the link of the device at one time, for the parts
1154 // of WhatsApp that do not own it: the groups, the batches, find, and send.
1155 type WhatsAppLink struct {
1157 device *store.Device
1158 client WhatsAppClient
1163 // WhatsAppRequest is a pair or unlink command for Run, with the function that
1164 // gives its answers to the client that asked. A pairing has the phone number.
1165 type WhatsAppRequest struct {
1168 answer func(text string)
1171 // whatsappNewClient returns the client of the library for device, with handle
1172 // as its event handler. The client tries again after a failed first connection,
1173 // acknowledges a message only after handle returns true, and downloads no old
1174 // history. The phone shows the device with the name OneChat.
1175 func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
1176 store.SetOSInfo("OneChat", [3]uint32{0, 1, 0})
1177 client := whatsmeow.NewClient(device, waLog.Noop)
1178 client.InitialAutoReconnect = true
1179 client.SynchronousAck = true
1180 client.ManualHistorySyncDownload = true
1181 client.AddEventHandlerWithSuccessStatus(handle)
1186 // whatsappMessage returns the chat and the batch message of event, or false
1187 // when the batches do not take it. device is the linked device, and own holds
1188 // the own IDs. The batches take the text of a private chat or a group, from
1189 // another person or from the user. They do not take media, reactions, edits,
1190 // deletions, view-once and disappearing messages, old history, status
1191 // broadcasts, channels, and the chat of the user with itself.
1193 // A mention of the user becomes @you, another mention @NAME when the name is
1194 // known, and the own number and LID become OWN, so that they never go to the
1196 func whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
1198 chatJID := info.Chat.ToNonAD()
1201 case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil:
1202 case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
1203 case slices.Contains(own, chatJID.User):
1205 return whatsappText(ctx, device, own, event)
1208 return WhatsAppChat{}, WhatsAppMessage{}, false
1211 // whatsappText returns the chat and the batch message of event, a message that
1212 // the batches take when it has text.
1213 func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
1215 text := event.Message.GetConversation()
1216 extended := event.Message.GetExtendedTextMessage()
1218 text = extended.GetText()
1221 if strings.TrimSpace(text) == "" {
1222 return WhatsAppChat{}, WhatsAppMessage{}, false
1225 contextInfo := extended.GetContextInfo()
1226 message := WhatsAppMessage{
1228 Time: info.Timestamp,
1229 Mine: info.IsFromMe,
1233 message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
1236 for _, mentioned := range contextInfo.GetMentionedJID() {
1237 jid, err := types.ParseJID(mentioned)
1240 case slices.Contains(own, jid.User):
1241 message.Mention = true
1242 text = strings.ReplaceAll(text, "@"+jid.User, "@you")
1244 text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, ""))
1248 participant, err := types.ParseJID(contextInfo.GetParticipant())
1249 message.Reply = err == nil && slices.Contains(own, participant.User)
1251 message.Text = whatsappRedact(own, text)
1252 chatJID := info.Chat.ToNonAD()
1253 chat := WhatsAppChat{
1254 ID: chatJID.String(),
1255 Group: chatJID.Server == types.GroupServer,
1259 push := info.PushName
1264 chat.Name = whatsappContactName(ctx, device, chatJID, push)
1267 return chat, message, true
1270 // whatsappContactName returns the name of the person jid: the full name, the
1271 // first name, the business name, or the push name of the contact in the session
1272 // of device, then push, the push name of a message, then the phone number. A
1273 // LID gets its phone number from the LID map of the session.
1274 func whatsappContactName(ctx context.Context, device *store.Device, jid types.JID, push string) string {
1276 candidates := []types.JID{jid}
1277 phone := types.EmptyJID
1279 case types.DefaultUserServer:
1281 case types.HiddenUserServer:
1282 mapped, err := device.LIDs.GetPNForLID(ctx, jid)
1283 if err == nil && !mapped.IsEmpty() {
1285 candidates = append(candidates, mapped)
1289 for _, candidate := range candidates {
1290 contact, err := device.Contacts.GetContact(ctx, candidate)
1291 if err != nil || !contact.Found {
1295 for _, name := range []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName} {
1305 case !phone.IsEmpty():
1306 return "+" + phone.User
1312 // whatsappFold returns text in lower case with no accents and no space at its
1313 // ends, so that names match with no regard to case and accents.
1314 func whatsappFold(text string) string {
1315 folded, _, err := transform.String(transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC), text)
1320 return strings.ToLower(strings.TrimSpace(folded))
1323 // whatsappFindAnswer returns the answer of Find for name with matches. Without
1324 // groups, the answer says that it has no groups.
1325 func whatsappFindAnswer(name string, matches []WhatsAppMatch, groups bool) string {
1326 slices.SortFunc(matches, func(a, b WhatsAppMatch) int {
1327 return cmp.Or(strings.Compare(whatsappFold(a.name), whatsappFold(b.name)), strings.Compare(a.id, b.id))
1331 for _, match := range matches[:min(len(matches), whatsappFindMatches)] {
1332 lines = append(lines, match.id+" | "+match.name+" | "+match.kind)
1335 if len(matches) == 0 {
1336 lines = append(lines, "No contact or group has a name with \""+name+"\".")
1339 if len(matches) > whatsappFindMatches {
1340 lines = append(lines, fmt.Sprintf("%d more: give a longer part of the name.", len(matches)-whatsappFindMatches))
1344 lines = append(lines, "The groups are not in this answer, because WhatsApp is disconnected.")
1347 return strings.Join(lines, "\n")
1350 // whatsappSendCheck returns the JID of chat, and an error when id, chat, or
1351 // text cannot go to a send. The chat is a private chat or a group, and not the
1352 // chat of the user with itself.
1353 func whatsappSendCheck(id, chat, text string, own []string) (types.JID, error) {
1354 jid, err := types.ParseJID(chat)
1356 case strings.TrimSpace(id) == "" || len(id) > whatsappSendIDBytes:
1357 return types.EmptyJID, fmt.Errorf("the id must have 1 to %d bytes", whatsappSendIDBytes)
1358 case strings.TrimSpace(text) == "" || len(text) > whatsappSendBytes:
1359 return types.EmptyJID, fmt.Errorf("the text must have 1 to %d bytes", whatsappSendBytes)
1360 case err != nil || jid.User == "" || jid != jid.ToNonAD() || jid.String() != chat:
1361 return types.EmptyJID, fmt.Errorf("%q is not the ID of a WhatsApp chat: find the ID with whatsapp_find", chat)
1362 case jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer && jid.Server != types.GroupServer:
1363 return types.EmptyJID, fmt.Errorf("%q is not a private chat or a group", chat)
1364 case slices.Contains(own, jid.User):
1365 return types.EmptyJID, errors.New("this is the chat of the user with itself: send to another chat")
1371 // whatsappOwnIDs returns the own number and the own LID that are not empty.
1372 func whatsappOwnIDs(ids ...string) []string {
1374 for _, id := range ids {
1376 own = append(own, id)
1383 // whatsappRedact replaces each of the own IDs own in text with OWN.
1384 func whatsappRedact(own []string, text string) string {
1385 for _, id := range own {
1386 text = strings.ReplaceAll(text, id, "OWN")
1392 // whatsappSessionAddress returns the SQLite address of the session database at
1393 // path, with foreign keys on, as the library needs, and a wait for a busy
1395 func whatsappSessionAddress(path string) (string, error) {
1396 path, err := filepath.Abs(path)
1398 return "", fmt.Errorf("find WhatsApp session: %w", err)
1404 RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
1407 return address.String(), nil
1410 // whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
1411 // command, or false for any other line.
1412 func whatsappCommandArgs(line string) (string, bool) {
1413 line = strings.TrimSpace(line)
1414 if line == whatsappCommand {
1418 args, found := strings.CutPrefix(line, whatsappCommand+" ")
1423 return strings.TrimSpace(args), true
1426 // whatsappPhone returns the digits of phone, a number with its country code and
1427 // an optional + and dashes, and tells if it has the form of a number: 7 to 15
1428 // digits, with no 0 first.
1429 func whatsappPhone(phone string) (string, bool) {
1430 phone = strings.ReplaceAll(strings.TrimPrefix(phone, "+"), "-", "")
1431 valid := len(phone) >= 7 && len(phone) <= 15 && phone[0] != '0' && strings.Trim(phone, "0123456789") == ""
1436 // whatsappItemError returns the error of a pairing item that is not a code, a
1437 // success, or a timeout.
1438 func whatsappItemError(item whatsmeow.QRChannelItem) string {
1439 if item.Error != nil {
1440 return item.Error.Error()