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"
32 // The session store of the library opens SQLite with this pure Go
34 _ "modernc.org/sqlite"
38 // whatsappDirName is the directory of the WhatsApp files in the data
40 whatsappDirName = "whatsapp"
42 // whatsappSessionFileName is the session database of the library in
44 whatsappSessionFileName = "session.db"
46 // whatsappCommand starts the input lines of the WhatsApp commands, such
47 // as "/whatsapp status".
48 whatsappCommand = "/whatsapp"
50 // whatsappUsage is the answer to a WhatsApp command that does not
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."
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."
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."
61 // whatsappTimeLayout is the layout of the times in the answers.
62 whatsappTimeLayout = "2006-01-02 15:04"
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
68 // whatsappLogoutTime is the longest time of the logout call of an
70 whatsappLogoutTime = 30 * time.Second
72 // whatsappNoticeDelay is how long a disconnection lasts before the user
74 whatsappNoticeDelay = 10 * time.Minute
76 // whatsappRequests is the number of pair and unlink commands that can
80 // whatsappSource is the source of the work events of the batches.
81 whatsappSource = "whatsapp"
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
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
91 // whatsappUnknownGroup is the name of a group whose name is not known.
92 whatsappUnknownGroup = "A group with no known name"
94 // whatsappCatchupLimit is the longest wait for the end of the catch-up
95 // after a connection.
96 whatsappCatchupLimit = time.Minute
98 // whatsappGroupsAge is how long whatsapp_find uses the groups that it
99 // read from WhatsApp.
100 whatsappGroupsAge = time.Hour
102 // whatsappFindMatches is the most matches of whatsapp_find.
103 whatsappFindMatches = 20
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."
108 // whatsappSendTime is the longest time of a send.
109 whatsappSendTime = 30 * time.Second
111 // whatsappSendBytes is the most bytes of the text of a send.
112 whatsappSendBytes = 65536
114 // whatsappSendIDBytes is the most bytes of the ID of a send.
115 whatsappSendIDBytes = 100
117 // The states of a send record.
118 whatsappSending = "sending"
119 whatsappSent = "sent"
120 whatsappFailed = "failed"
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."
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
130 type WhatsAppClient interface {
131 ConnectContext(ctx context.Context) error
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)
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) {
148 dir: filepath.Join(dir, whatsappDirName),
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),
162 w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow)
167 info, err := os.Stat(w.sessionPath())
168 if errors.Is(err, os.ErrNotExist) {
173 return nil, fmt.Errorf("read WhatsApp session: %w", err)
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))
180 err = w.openSession(ctx)
185 if w.device.ID != nil {
188 w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
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.
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.
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.
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.
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
229 type WhatsApp struct {
231 clock func() time.Time
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{}
243 container *sqlstore.Container
245 client WhatsAppClient
250 connectedAt time.Time
251 disconnectedAt time.Time
256 groupNames map[string]string
262 catchupTimer *time.Timer
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
272 func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error {
282 var pairing sync.WaitGroup
291 err := w.deliver(ctx, l, false)
296 var due <-chan time.Time
297 wait, open := w.state.Wait(time.Now())
299 due = time.After(wait)
305 case err = <-w.failures:
310 err = w.loggedOut(ctx, l)
314 case request := <-w.requests:
321 err = w.unlink(ctx, l, request.answer)
326 request.answer("WhatsApp is linked already. Run /whatsapp unlink first.")
328 pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTime)
333 w.pair(pairCtx, ctx, l, request.phone, request.answer)
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
343 func (w *WhatsApp) Command(args string, answer func(text string)) {
344 fields := strings.Fields(args)
345 request := WhatsAppRequest{
350 case len(fields) == 1 && fields[0] == "status":
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:], ""))
358 answer("PHONE must be the number of your WhatsApp account with its country code: 7 to 15 digits, such as +15550001111.")
362 request.phone = phone
364 answer(whatsappUsage)
369 case w.requests <- request:
371 answer("Too many WhatsApp commands wait. Try again in a minute.")
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
378 func (w *WhatsApp) Status() string {
380 defer w.mutex.Unlock()
384 return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it."
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) + "."
392 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "."
396 // Close closes the session database.
397 func (w *WhatsApp) Close() error {
399 defer w.mutex.Unlock()
401 if w.container == nil {
405 err := w.container.Close()
411 func (w *WhatsApp) sessionPath() string {
412 return filepath.Join(w.dir, whatsappSessionFileName)
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())
423 container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
425 return fmt.Errorf("open WhatsApp session: %w", err)
428 device, err := container.GetFirstDevice(ctx)
430 return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close())
433 w.container = container
439 func (w *WhatsApp) isLinked() bool {
441 defer w.mutex.Unlock()
446 // connect makes the client of the linked device and connects it.
447 func (w *WhatsApp) connect(ctx context.Context) {
449 w.client = w.newClient(w.device, w.handle)
453 err := client.ConnectContext(ctx)
455 w.logger.Printf("connect: %s", w.redact(err.Error()))
459 // close ends the connection and the timer of the notice, and stops all notices.
460 func (w *WhatsApp) close() {
467 if w.catchupTimer != nil {
468 w.catchupTimer.Stop()
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 {
487 err := w.deliver(ctx, l, true)
492 err = w.removeSession()
494 w.logger.Printf("delete the session after the logout: %v", err)
497 err = l.note("WhatsApp was unlinked from the phone.")
499 w.logger.Printf("note the logout: %v", err)
502 w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
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
512 func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) error {
519 answer("WhatsApp is not linked.")
523 err := w.deliver(ctx, l, true)
530 ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTime)
533 logoutErr = client.Logout(ctx)
536 err = w.removeSession()
538 answer("The unlink failed: " + w.redact(err.Error()))
542 err = l.note("WhatsApp is unlinked.")
544 w.logger.Printf("note the unlink: %v", err)
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.")
552 answer("WhatsApp is unlinked.")
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 {
565 catchingUp := w.catchingUp
566 caughtUp := w.caughtUp
572 case all || caughtUp:
573 err = w.state.CloseAll()
577 _, err = w.state.CloseDue(time.Now())
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)
590 message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "")
592 return fmt.Errorf("log a WhatsApp batch: %w", err)
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
599 if ctx.Err() != nil {
604 return fmt.Errorf("log a WhatsApp batch: %w", err)
607 err = w.state.Remove(batch.Number)
613 turns = append(turns, message)
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 {
627 name, found := w.groupNames[id]
628 fresh := time.Since(w.groupsAt) < whatsappGroupsRefresh
631 if !found && !fresh {
635 name, found = w.groupNames[id]
639 if !found || name == "" {
640 return whatsappUnknownGroup
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 {
657 ctx, cancel := context.WithTimeout(ctx, whatsappLookupTime)
660 groups, err := client.GetJoinedGroups(ctx)
662 w.logger.Printf("read the groups: %s", w.redact(err.Error()))
666 defer w.mutex.Unlock()
668 w.groupsAt = time.Now()
673 w.groupsRead = w.groupsAt
674 for _, group := range groups {
675 w.groupNames[group.JID.String()] = group.Name
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) {
692 own := slices.Clone(w.own)
693 stale := time.Since(w.groupsRead) >= whatsappGroupsAge
697 return whatsappNotLinked, nil
700 query := whatsappFold(name)
702 return "", errors.New("name is empty: give a part of the name of the contact or the group")
705 contacts, err := device.Contacts.GetAllContacts(ctx)
707 return "", fmt.Errorf("read the contacts: %s", w.redact(err.Error()))
710 read := !stale || w.readGroups(ctx)
712 groups := maps.Clone(w.groupNames)
715 if len(contacts) == 0 && len(groups) == 0 {
717 return "The names are not known yet, and WhatsApp is disconnected, so the groups cannot be read. Try again later.", nil
720 return whatsappNamesNotReady, nil
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 {
730 if slices.Contains(own, jid.User) || shown < 0 || !slices.ContainsFunc(names, func(name string) bool {
731 return strings.Contains(whatsappFold(name), query)
736 matches = append(matches, WhatsAppMatch{
737 id: jid.ToNonAD().String(),
743 for id, group := range groups {
744 if strings.Contains(whatsappFold(group), query) {
745 matches = append(matches, WhatsAppMatch{
753 return whatsappFindAnswer(name, matches, read || len(groups) > 0), nil
756 // removeSession ends the connection, closes the session database, and deletes
758 func (w *WhatsApp) removeSession() error {
771 container := w.container
781 if container != nil {
782 err = container.Close()
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)
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.
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)
809 answer("The pairing failed: " + err.Error())
813 client := w.newClient(device, w.handle)
821 qr, err := client.GetQRChannel(ctx)
823 err = client.ConnectContext(runCtx)
827 answer("The connection to WhatsApp failed: " + err.Error())
832 case item, open := <-qr:
833 if !open || item.Event != whatsmeow.QRChannelEventCode {
834 answer("WhatsApp did not start the pairing. Try again later.")
838 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
839 answer("The connection to WhatsApp did not start the pairing in time. Try again later.")
845 code, err := client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
847 answer("WhatsApp refused the pairing: " + err.Error() + ". Check the phone number and run /whatsapp pair PHONE again.")
851 answer(whatsappPairSteps)
855 var item whatsmeow.QRChannelItem
858 case item, open = <-qr:
860 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
861 answer(whatsappPairExpired)
868 case !open || item.Event == whatsmeow.QRChannelTimeout.Event:
869 answer(whatsappPairExpired)
871 case item.Event == whatsmeow.QRChannelEventCode:
873 case item.Event == whatsmeow.QRChannelSuccess.Event:
877 err = l.note("WhatsApp is linked.")
879 w.logger.Printf("note the link: %v", err)
882 answer("WhatsApp is linked to +" + phone + ".")
886 answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.")
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
895 func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) {
897 defer w.mutex.Unlock()
899 if w.container == nil {
900 err := os.MkdirAll(w.dir, 0o700)
902 return nil, fmt.Errorf("create WhatsApp directory: %w", err)
905 file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600)
907 return nil, fmt.Errorf("create WhatsApp session: %w", err)
912 return nil, fmt.Errorf("create WhatsApp session: %w", err)
915 err = w.openSession(ctx)
921 w.device = w.container.NewDevice()
926 // linkedBy makes client the client of the linked device.
927 func (w *WhatsApp) linkedBy(client WhatsAppClient) {
929 defer w.mutex.Unlock()
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)
945 return w.receive(message)
950 switch event := event.(type) {
951 case *events.GroupInfo:
952 if event.Name != nil {
953 w.groupNames[event.JID.String()] = event.Name.Name
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:
961 w.connectedAt = w.clock()
964 catchups := w.catchups
965 if w.catchupTimer != nil {
966 w.catchupTimer.Stop()
969 w.catchupTimer = time.AfterFunc(w.catchupLimit, func() {
970 w.caughtUpBy(catchups)
978 notice = "WhatsApp is connected again."
980 case *events.Disconnected:
981 if w.linked && (w.connected || w.disconnectedAt.IsZero()) {
982 w.disconnectedAt = w.clock()
984 disconnections := w.disconnections
985 w.timer = time.AfterFunc(w.noticeDelay, func() {
986 w.disconnectedLong(disconnections)
991 case *events.OfflineSyncCompleted:
993 case *events.LoggedOut:
995 case w.logouts <- struct{}{}:
998 case events.PermanentDisconnect:
1002 notice = "WhatsApp is disconnected and does not connect again by itself: " + event.PermanentDisconnectDescription() + ". Restart the daemon to try again."
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)
1024 own := slices.Clone(w.own)
1027 chat, message, found := whatsappMessage(ctx, device, own, event)
1032 _, err := w.state.Add(chat, message, time.Now())
1035 case w.failures <- err:
1043 case w.wake <- struct{}{}:
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) {
1054 defer w.mutex.Unlock()
1056 if w.catchups == catchups {
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() {
1068 w.catchingUp = false
1070 if w.catchupTimer != nil {
1071 w.catchupTimer.Stop()
1075 case w.wake <- struct{}{}:
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) {
1084 long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections
1089 since := w.disconnectedAt
1093 w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.")
1097 // broadcast gives text as a notice to all clients.
1098 func (w *WhatsApp) broadcast(text string) {
1104 if notify != nil && !closed {
1112 // redact replaces the own number and the own LID in text with OWN.
1113 func (w *WhatsApp) redact(text string) string {
1115 defer w.mutex.Unlock()
1117 return whatsappRedact(w.own, text)
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
1127 func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) {
1132 connected := w.connected
1133 own := slices.Clone(w.own)
1137 return whatsappNotLinked, nil
1140 jid, err := whatsappSendCheck(id, chat, text, own)
1145 digest := sha256.Sum256([]byte(text))
1146 record := WhatsAppSend{
1149 Digest: hex.EncodeToString(digest[:]),
1150 State: whatsappSending,
1154 held, found := w.state.Send(id)
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
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")
1166 record.MessageID = client.GenerateMessageID()
1167 err = w.state.PutSend(record)
1172 sendCtx, cancel := context.WithTimeout(ctx, whatsappSendTime)
1175 response, err := client.SendMessage(sendCtx, jid, &waE2E.Message{
1176 Conversation: new(text),
1177 }, whatsmeow.SendRequestExtra{
1178 ID: record.MessageID,
1181 record.State = whatsappSent
1183 record.State = whatsappFailed
1184 record.Answer = "The send failed: " + w.redact(err.Error()) + "."
1186 name := w.groupName(ctx, jid.String())
1187 if jid.Server != types.GroupServer {
1188 name = whatsappContactName(ctx, device, jid, "")
1191 record.Answer = "Sent to " + name + " at " + response.Timestamp.In(w.clock().Location()).Format("15:04") + "."
1194 err = w.state.PutSend(record)
1199 return record.Answer, nil
1202 // WhatsAppMatch is one contact or group of Find.
1203 type WhatsAppMatch struct {
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 {
1214 answer func(text string)
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)
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.
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
1242 func whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
1244 chatJID := info.Chat.ToNonAD()
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):
1251 return whatsappText(ctx, device, own, event)
1254 return WhatsAppChat{}, WhatsAppMessage{}, false
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) {
1261 text := event.Message.GetConversation()
1262 extended := event.Message.GetExtendedTextMessage()
1264 text = extended.GetText()
1267 if strings.TrimSpace(text) == "" {
1268 return WhatsAppChat{}, WhatsAppMessage{}, false
1271 contextInfo := extended.GetContextInfo()
1272 message := WhatsAppMessage{
1274 Time: info.Timestamp,
1275 Mine: info.IsFromMe,
1279 message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
1282 for _, mentioned := range contextInfo.GetMentionedJID() {
1283 jid, err := types.ParseJID(mentioned)
1286 case slices.Contains(own, jid.User):
1287 message.Mention = true
1288 text = strings.ReplaceAll(text, "@"+jid.User, "@you")
1290 text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, ""))
1294 participant, err := types.ParseJID(contextInfo.GetParticipant())
1295 message.Reply = err == nil && slices.Contains(own, participant.User)
1297 message.Text = whatsappRedact(own, text)
1298 chatJID := info.Chat.ToNonAD()
1299 chat := WhatsAppChat{
1300 ID: chatJID.String(),
1301 Group: chatJID.Server == types.GroupServer,
1305 push := info.PushName
1310 chat.Name = whatsappContactName(ctx, device, chatJID, push)
1313 return chat, message, true
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 {
1322 candidates := []types.JID{jid}
1323 phone := types.EmptyJID
1325 case types.DefaultUserServer:
1327 case types.HiddenUserServer:
1328 mapped, err := device.LIDs.GetPNForLID(ctx, jid)
1329 if err == nil && !mapped.IsEmpty() {
1331 candidates = append(candidates, mapped)
1335 for _, candidate := range candidates {
1336 contact, err := device.Contacts.GetContact(ctx, candidate)
1337 if err != nil || !contact.Found {
1341 for _, name := range []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName} {
1351 case !phone.IsEmpty():
1352 return "+" + phone.User
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)
1366 return strings.ToLower(strings.TrimSpace(folded))
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))
1377 for _, match := range matches[:min(len(matches), whatsappFindMatches)] {
1378 lines = append(lines, match.id+" | "+match.name+" | "+match.kind)
1381 if len(matches) == 0 {
1382 lines = append(lines, "No contact or group has a name with \""+name+"\".")
1385 if len(matches) > whatsappFindMatches {
1386 lines = append(lines, fmt.Sprintf("%d more: give a longer part of the name.", len(matches)-whatsappFindMatches))
1390 lines = append(lines, "The groups are not in this answer, because WhatsApp is disconnected.")
1393 return strings.Join(lines, "\n")
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)
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")
1417 // whatsappOwnIDs returns the own number and the own LID that are not empty.
1418 func whatsappOwnIDs(ids ...string) []string {
1420 for _, id := range ids {
1422 own = append(own, id)
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")
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
1441 func whatsappSessionAddress(path string) (string, error) {
1442 path, err := filepath.Abs(path)
1444 return "", fmt.Errorf("find WhatsApp session: %w", err)
1450 RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
1453 return address.String(), nil
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 {
1464 args, found := strings.CutPrefix(line, whatsappCommand+" ")
1469 return strings.TrimSpace(args), true
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") == ""
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()