19 "go.mau.fi/whatsmeow/proto/waCompanionReg"
20 "go.mau.fi/whatsmeow/proto/waE2E"
21 "go.mau.fi/whatsmeow/store"
22 "go.mau.fi/whatsmeow/store/sqlstore"
23 "go.mau.fi/whatsmeow/types"
24 "go.mau.fi/whatsmeow/types/events"
25 waLog "go.mau.fi/whatsmeow/util/log"
26 // The session store of the library opens SQLite with this pure Go
28 _ "modernc.org/sqlite"
32 // whatsappDirName is the directory of the WhatsApp files in the data
34 whatsappDirName = "whatsapp"
36 // whatsappSessionFileName is the session database of the library in
38 whatsappSessionFileName = "session.db"
40 // whatsappCommand starts the input lines of the WhatsApp commands, such
41 // as "/whatsapp status".
42 whatsappCommand = "/whatsapp"
44 // whatsappUsage is the answer to a WhatsApp command that does not
46 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."
48 // whatsappPairSteps tells the user what to do with the pairing code,
49 // which comes in the next message.
50 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."
52 // whatsappPairExpired is the answer when the pairing code expires.
53 whatsappPairExpired = "The pairing code expired. Run /whatsapp pair PHONE again for a new code."
55 // whatsappTimeLayout is the layout of the times in the answers.
56 whatsappTimeLayout = "2006-01-02 15:04"
58 // whatsappPairTimeout is the longest time of a pairing. The library
59 // closes the pairing connection after about 160 seconds.
60 whatsappPairTimeout = 3 * time.Minute
62 // whatsappLogoutTimeout is the longest time of the logout call of an
64 whatsappLogoutTimeout = 30 * time.Second
66 // whatsappOutageNoticeDelay is the delay from a disconnection to its
67 // notice to the user.
68 whatsappOutageNoticeDelay = 10 * time.Minute
70 // whatsappRetryDelay is the first wait after a failed starting
72 whatsappRetryDelay = 2 * time.Second
74 // whatsappRetryLimit is the longest wait between starting connections.
75 whatsappRetryLimit = 5 * time.Minute
77 // whatsappRequests is the number of pair and unlink commands that can
81 // whatsappNotLinked is the answer of a WhatsApp tool without a link.
82 whatsappNotLinked = "WhatsApp is not linked. Tell the user to run /whatsapp pair PHONE in the terminal or in Telegram."
85 // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
86 // real one is a *whatsmeow.Client from whatsappNewClient, and the tests use a
88 type WhatsAppClient interface {
89 ConnectContext(ctx context.Context) error
91 GetQRChannel(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error)
92 PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error)
93 Logout(ctx context.Context) error
94 GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error)
95 Download(ctx context.Context, message whatsmeow.DownloadableMessage) ([]byte, error)
96 Upload(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error)
97 GenerateMessageID() types.MessageID
98 SendMessage(ctx context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error)
101 // WhatsApp is the link of the daemon to the WhatsApp account of the user, as a
102 // linked device. The session database of the library in dir holds the keys of
103 // the device. Run keeps a linked device connected, and the library connects
104 // again by itself after a network failure.
106 // The commands pair and unlink go to Run as requests, and Run runs one at a
107 // time. A pairing runs in its own goroutine until its code expires, and a new
108 // pairing ends the one before. Its phone number and code go only to the answer
109 // of the client that asked. A logout from the phone comes as an event of the
110 // library, and Run deletes the session. The mutex protects the session, the
111 // client, and the state of the connection, which the event handler of the
112 // library changes from its own goroutines. The sessionMutex serializes session
113 // creation, close, removal, and snapshots. It is held before mutex, and stays
114 // held when removal releases mutex for client callbacks.
116 // ctx is the caller context until Run sets its context for library callbacks.
117 // The mutex protects it, so callbacks follow daemon cancellation.
119 // The inbox puts messages into batches and logs them, and groups holds the
120 // names of the groups. Both get a WhatsAppLink from link for each call, so they
121 // use the current device and client.
123 // The library logs nothing, because its log lines hold the ID of the own
124 // account, which is the phone number of the user. The own number and the own
125 // LID never go to the log of the agent or to the log of the daemon: redact
126 // removes them from the errors, and a batch shows the messages of the user as
127 // You and a mention of the user as @you. The IDs of the other chats go to the
128 // log, and those of private chats hold phone numbers, because the agent needs
130 type WhatsApp struct {
133 clock func() time.Time
135 newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient
136 requests chan WhatsAppRequest
137 logouts chan struct{}
139 groups *WhatsAppGroups
143 sessionMutex sync.Mutex
145 container *sqlstore.Container
147 client WhatsAppClient
153 connectedAt time.Time
154 disconnectedAt time.Time
161 // WhatsAppLink is a copy of the link of the device at one time, for the parts
162 // of WhatsApp that do not own it: the groups, the batches, find, and send.
163 type WhatsAppLink struct {
166 client WhatsAppClient
171 // WhatsAppRequest is a pair or unlink command for Run, with the function that
172 // gives its answers to the client that asked. A pairing has the phone number.
173 type WhatsAppRequest struct {
176 answer func(text string)
179 // openWhatsApp opens the WhatsApp session in the data directory dir. Without
180 // the session file, WhatsApp is not linked. The session gives full access to
181 // the account, so a file that another user can read is an error. The clock
182 // gives the times of the status, logger gets the failures, and newClient makes
183 // the client of a device with its event handler.
184 func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *slog.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
187 dir: filepath.Join(dir, whatsappDirName),
190 newClient: newClient,
191 requests: make(chan WhatsAppRequest, whatsappRequests),
192 logouts: make(chan struct{}, 1),
193 groups: newWhatsAppGroups(logger),
198 w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName))
200 return nil, fmt.Errorf("open WhatsApp state: %w", err)
203 w.inbox = newWhatsAppInbox(w.state, clock, w.groups, &ImageStore{dir: filepath.Join(dir, imageDirName)}, logger)
205 info, err := os.Stat(w.sessionPath())
206 if errors.Is(err, os.ErrNotExist) {
211 return nil, fmt.Errorf("read WhatsApp session: %w", err)
214 if info.Mode().Perm()&0o077 != 0 {
215 return nil, fmt.Errorf("%s holds the WhatsApp session, so its mode must be 0600", filepath.Join(whatsappDirName, whatsappSessionFileName))
218 err = w.openSession(ctx)
220 return nil, fmt.Errorf("open WhatsApp session: %w", err)
223 if w.device.ID != nil {
225 w.inbox.waitForCatchup()
227 w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
233 func (w *WhatsApp) sessionPath() string {
234 return filepath.Join(w.dir, whatsappSessionFileName)
237 // openSession opens the session database and its first device. The caller holds
238 // the mutex, or no other goroutine uses w yet.
239 func (w *WhatsApp) openSession(ctx context.Context) error {
240 address, err := whatsappSessionAddress(w.sessionPath())
242 return fmt.Errorf("find WhatsApp session address: %w", err)
245 container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
247 return fmt.Errorf("open WhatsApp session: %w", err)
250 device, err := container.GetFirstDevice(ctx)
252 return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close())
255 w.container = container
261 // Run connects a linked device, logs the closed batches in l, runs the pair and
262 // unlink commands, and handles a logout from the phone, until ctx ends. Its
263 // notes go to the log of l, and notify gets the notices for all clients. A
264 // failed first connection starts the same outage notice as a lost connection. A
265 // timer in the select retries it with a doubled wait from 2 seconds to 5
266 // minutes, without a retry goroutine or a wait that blocks commands or the
267 // inbox. A connection, unlink, logout, or ctx end stops retries. The library
268 // owns retries after a connection opens. Run returns the error of a write of
269 // the log or of the state file, which stops the daemon, because then messages
271 func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error {
280 timer := time.NewTimer(whatsappRetryDelay)
284 var retry <-chan time.Time
286 delay := whatsappRetryDelay
293 delay = min(2*delay, whatsappRetryLimit)
297 stopRetry := func() {
308 var pairing sync.WaitGroup
324 err := w.inbox.deliver(ctx, l, w.link(), false)
326 return fmt.Errorf("deliver WhatsApp inbox: %w", err)
332 case err = <-w.inbox.failures:
334 return fmt.Errorf("receive WhatsApp inbox: %w", err)
339 case <-w.inbox.due():
345 err = w.loggedOut(ctx, l)
347 return fmt.Errorf("handle WhatsApp logout: %w", err)
349 case request := <-w.requests:
360 err = w.unlink(ctx, l, request.answer)
362 return fmt.Errorf("unlink WhatsApp: %w", err)
365 request.answer("WhatsApp is linked already. Run /whatsapp unlink first.")
367 pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTimeout)
372 w.pair(pairCtx, ctx, l, request.phone, request.answer)
379 // connect tries the starting connection of the linked device with one client.
380 // It returns whether Run must retry. A failed call starts the shared outage
381 // state and logs its redacted cause once, because a returned ConnectContext
382 // error does not emit Disconnected. Connected logs recovery after
384 func (w *WhatsApp) connect(ctx context.Context) bool {
387 if ctx.Err() != nil || !w.linked || !w.retrying || w.closed {
394 w.client = w.newClient(w.device, w.handle)
400 err := client.ConnectContext(ctx)
404 if err == nil || ctx.Err() != nil || !w.retrying || !w.linked {
411 first := w.startOutage()
412 cause := whatsappRedact(w.own, err.Error())
416 w.logger.Warn("first connect failed, retrying", "err", cause)
422 // close ends the connection and the timer of the notice, and stops all notices.
423 func (w *WhatsApp) close() {
442 // link returns a copy of the link of the device: whether it is linked, the
443 // device, the client, whether it is connected, and the own IDs.
444 func (w *WhatsApp) link() WhatsAppLink {
446 defer w.mutex.Unlock()
452 connected: w.connected,
453 own: slices.Clone(w.own),
457 // loggedOut logs the open batches, deletes the session after a logout from the
458 // phone, notes it in the log of l, and tells all clients one time.
459 func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
464 w.logger.Error("logged out by the phone")
466 err := w.inbox.deliver(ctx, l, w.link(), true)
471 err = w.removeSession()
473 w.logger.Error("session delete failed", "err", err)
476 err = l.note("WhatsApp was unlinked from the phone.")
478 w.logger.Error("chat note failed", "err", err)
481 w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
486 // unlink logs the open batches, logs out the linked device, and deletes the
487 // session. When the logout call fails, as it does while WhatsApp is
488 // disconnected, the session is deleted all the same, and answer tells the user
489 // to remove the device in the phone. Its error is one of the log or the state
491 func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) error {
499 answer("WhatsApp is not linked.")
504 err := w.inbox.deliver(ctx, l, w.link(), true)
512 ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTimeout)
515 logoutErr = client.Logout(ctx)
518 err = w.removeSession()
520 answer("The unlink failed: " + w.redact(err.Error()))
525 w.logger.Info("unlinked")
527 err = l.note("WhatsApp is unlinked.")
529 w.logger.Error("chat note failed", "err", err)
532 if client == nil || logoutErr != nil {
533 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.")
538 answer("WhatsApp is unlinked.")
543 func (w *WhatsApp) isLinked() bool {
545 defer w.mutex.Unlock()
550 // pair links a new device to the account of phone with a pairing code. It gives
551 // the steps and the code to answer, and then the end of the pairing: the linked
552 // number, an expired code, or a failure. A pairing that a new one ends gives no
553 // answer. A link notes "WhatsApp is linked." in the log of l, with no number.
554 // The client of a pairing that does not link is disconnected.
556 // ctx limits the pairing, but the connection uses runCtx, the context of Run.
557 // The library keeps the context of the connection for its keep-alive, its event
558 // handler, and its reconnections, and after the link the server ends the stream
559 // and the library connects again. With ctx, the end of the pairing would stop
560 // that reconnection, and the linked device would never connect.
561 func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string, answer func(text string)) {
562 device, err := w.newDevice(ctx)
564 answer("The pairing failed: " + err.Error())
569 client := w.newClient(device, w.handle)
577 qr, err := client.GetQRChannel(ctx)
579 err = client.ConnectContext(runCtx)
583 answer("The connection to WhatsApp failed: " + err.Error())
589 case item, open := <-qr:
590 if !open || item.Event != whatsmeow.QRChannelEventCode {
591 answer("WhatsApp did not start the pairing. Try again later.")
596 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
597 answer("The connection to WhatsApp did not start the pairing in time. Try again later.")
603 // The server accepts only a display name of the form "Browser (OS)"
604 // with a common browser and OS, and refuses the pairing otherwise.
605 code, err := client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
607 answer("WhatsApp refused the pairing: " + err.Error() + ". Check the phone number and run /whatsapp pair PHONE again.")
612 answer(whatsappPairSteps)
616 var item whatsmeow.QRChannelItem
620 case item, open = <-qr:
622 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
623 answer(whatsappPairExpired)
630 case !open || item.Event == whatsmeow.QRChannelTimeout.Event:
631 answer(whatsappPairExpired)
634 case item.Event == whatsmeow.QRChannelEventCode:
636 case item.Event == whatsmeow.QRChannelSuccess.Event:
640 w.logger.Info("linked")
642 err = l.note("WhatsApp is linked.")
644 w.logger.Error("chat note failed", "err", err)
647 answer("WhatsApp is linked to +" + phone + ".")
651 answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.")
658 // handle takes one event of the library and tells if it was handled. A message
659 // goes to the inbox. The names of the groups follow the events of the groups. A
660 // connection starts a catch-up of the inbox, which ends at OfflineSyncCompleted
661 // or after whatsappCatchupTimeout. A disconnection keeps the time of its start
662 // while Run or the library tries again. After whatsappOutageNoticeDelay of a
663 // disconnection, all clients get one notice, and they get one more when the
664 // connection comes back. A disconnection that the library does not try again
665 // gives a notice at once.
667 // The log of the daemon gets one line for each change of the connection of the
668 // linked device: connected, a failed first connection, a lost connection, and
669 // connected again with the length of the outage. The retries of the library and
670 // Run between them log nothing.
671 func (w *WhatsApp) handle(event any) bool {
677 switch event := event.(type) {
678 case *events.Message:
679 return w.inbox.receive(ctx, w.link(), event)
680 case *events.GroupInfo:
681 if event.Name != nil {
682 w.groups.set(event.JID.String(), event.Name.Name)
686 case *events.JoinedGroup:
687 w.groups.set(event.JID.String(), event.Name)
690 case *events.OfflineSyncCompleted:
694 case *events.Connected:
695 w.inbox.startCatchup()
698 var notice, line string
700 level := slog.LevelInfo
706 switch event := event.(type) {
707 case *events.PairSuccess:
708 w.own = whatsappOwnIDs(event.ID.User, event.LID.User)
709 case *events.Connected:
711 case !w.linked || w.connected:
712 case w.disconnectedAt.IsZero():
715 line = "connected again"
716 attrs = append(attrs, "after", w.clock().Sub(w.disconnectedAt))
721 w.connectedAt = w.clock()
728 notice = "WhatsApp is connected again."
730 case *events.Disconnected:
731 connected := w.connected
733 level = slog.LevelWarn
734 line = "disconnected, retrying"
736 line = "first connect failed, retrying"
739 case *events.LoggedOut:
742 case w.logouts <- struct{}{}:
745 case events.PermanentDisconnect:
750 notice = "WhatsApp is disconnected and does not connect again by itself: " + event.PermanentDisconnectDescription() + ". Restart the daemon to try again."
751 level = slog.LevelError
752 line = "disconnected for good, restart to retry"
753 attrs = append(attrs, "err", whatsappRedact(w.own, event.PermanentDisconnectDescription()))
764 w.logger.Log(ctx, level, line, attrs...)
774 // startOutage starts the shared outage time and notice timer once. The caller
775 // holds the mutex. Its result tells whether this is the first failure.
776 func (w *WhatsApp) startOutage() bool {
777 first := w.linked && (w.connected || w.disconnectedAt.IsZero())
779 w.disconnectedAt = w.clock()
781 disconnections := w.disconnections
782 w.timer = time.AfterFunc(whatsappOutageNoticeDelay, func() {
783 w.disconnectedLong(disconnections)
792 // removeSession ends the connection, closes the session database, and deletes
793 // its files. The session lock covers the cleanup after mutex is released, so a
794 // snapshot or a new session cannot open files that cleanup removes.
795 func (w *WhatsApp) removeSession() error {
796 w.sessionMutex.Lock()
797 defer w.sessionMutex.Unlock()
807 w.disconnectedAt = time.Time{}
813 container := w.container
818 w.inbox.stopCatchup()
826 if container != nil {
827 err = container.Close()
830 for _, suffix := range []string{"", "-wal", "-shm"} {
831 removeErr := os.Remove(w.sessionPath() + suffix)
832 if !errors.Is(removeErr, os.ErrNotExist) {
833 err = errors.Join(err, removeErr)
840 // broadcast gives text as a notice to all clients.
841 func (w *WhatsApp) broadcast(text string) {
848 if notify != nil && !closed {
856 // redact replaces the own number and the own LID in text with OWN.
857 func (w *WhatsApp) redact(text string) string {
859 defer w.mutex.Unlock()
861 return whatsappRedact(w.own, text)
864 // newDevice returns a new device in the session database. It makes the
865 // directory and the file of the session first, with their modes, when they do
867 func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) {
868 w.sessionMutex.Lock()
869 defer w.sessionMutex.Unlock()
872 defer w.mutex.Unlock()
874 if w.container == nil {
875 err := os.MkdirAll(w.dir, 0o700)
877 return nil, fmt.Errorf("create WhatsApp directory: %w", err)
880 file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600)
882 return nil, fmt.Errorf("create WhatsApp session: %w", err)
887 return nil, fmt.Errorf("create WhatsApp session: %w", err)
890 err = w.openSession(ctx)
892 return nil, fmt.Errorf("open new WhatsApp session: %w", err)
896 w.device = w.container.NewDevice()
901 // linkedBy makes client the client of the linked device.
902 func (w *WhatsApp) linkedBy(client WhatsAppClient) {
904 defer w.mutex.Unlock()
910 // disconnectedLong gives the notice of a long disconnection, unless the
911 // disconnection with the number disconnections ended.
912 func (w *WhatsApp) disconnectedLong(disconnections int) {
915 long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections
920 since := w.disconnectedAt
924 w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.")
928 // Command runs the WhatsApp command args, the text after /whatsapp, and gives
929 // each answer to answer. The answers go only to the client that sent the
931 func (w *WhatsApp) Command(args string, answer func(text string)) {
932 fields := strings.Fields(args)
933 request := WhatsAppRequest{
938 case len(fields) == 1 && fields[0] == "status":
942 case len(fields) == 1 && fields[0] == "unlink":
943 request.unlink = true
944 case len(fields) >= 2 && fields[0] == "pair":
945 phone, valid := whatsappPhone(strings.Join(fields[1:], ""))
947 answer("PHONE must be the number of your WhatsApp account with its country code: 7 to 15 digits, such as +15550001111.")
952 request.phone = phone
954 answer(whatsappUsage)
960 case w.requests <- request:
962 answer("Too many WhatsApp commands wait. Try again in a minute.")
966 // Status returns the state of the link: not linked, linked and connected, or
967 // linked and disconnected, with the times of the connection and the routes.
968 // Route source IDs can hold the phone numbers of other chats.
969 func (w *WhatsApp) Status() string {
971 defer w.mutex.Unlock()
975 listed := w.routes.List()
976 if listed != "No routes." {
977 routes = "\nWhatsApp routes:\n" + listed
983 return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it." + routes
985 return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
986 case w.disconnectedAt.IsZero():
987 return "WhatsApp is linked and not connected yet." + routes
988 case w.connectedAt.IsZero():
989 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "." + routes
991 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
995 // BackupSession writes a consistent SQLite snapshot to path while the session
996 // cannot be created, closed, or removed. The source opens without creation
997 // permission. No session file means no linked state to copy.
998 func (w *WhatsApp) BackupSession(ctx context.Context, path string) (err error) {
999 w.sessionMutex.Lock()
1000 defer w.sessionMutex.Unlock()
1003 defer w.mutex.Unlock()
1005 _, err = os.Stat(w.sessionPath())
1006 if errors.Is(err, os.ErrNotExist) {
1011 return fmt.Errorf("read session file: %w", err)
1014 err = os.MkdirAll(filepath.Dir(path), 0o700)
1016 return fmt.Errorf("create session snapshot directory: %w", err)
1019 address, err := whatsappSessionAddress(w.sessionPath())
1021 return fmt.Errorf("find session database: %w", err)
1024 database, err := sql.Open("sqlite", address+"&mode=rw")
1026 return fmt.Errorf("open session snapshot source: %w", err)
1030 err = errors.Join(err, database.Close())
1033 _, err = database.ExecContext(ctx, "VACUUM INTO ?", path)
1035 return fmt.Errorf("snapshot session database: %w", err)
1038 err = os.Chmod(path, 0o600)
1040 return fmt.Errorf("set session snapshot mode: %w", err)
1046 // Close closes the session database.
1047 func (w *WhatsApp) Close() error {
1048 w.sessionMutex.Lock()
1049 defer w.sessionMutex.Unlock()
1052 defer w.mutex.Unlock()
1054 if w.container == nil {
1058 err := w.container.Close()
1064 // Find returns the answer of whatsapp_find for name, as whatsappFind says.
1065 func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) {
1066 return whatsappFind(ctx, w.link(), w.groups, name)
1069 // Send returns the answer of whatsapp_send for text and an optional stored
1070 // image to chat, with the durable send id that whatsappSend describes.
1071 func (w *WhatsApp) Send(ctx context.Context, id, chat, text, image string) (string, error) {
1072 return whatsappSend(ctx, w.link(), w.groups, w.state, w.inbox.images, w.logger, w.clock, id, chat, text, image)
1075 // whatsappOwnIDs returns the own number and the own LID that are not empty.
1076 func whatsappOwnIDs(ids ...string) []string {
1079 for _, id := range ids {
1081 own = append(own, id)
1088 // whatsappSessionAddress returns the SQLite address of the session database at
1089 // path, with foreign keys on, as the library needs, and a wait for a busy
1091 func whatsappSessionAddress(path string) (string, error) {
1092 path, err := filepath.Abs(path)
1094 return "", fmt.Errorf("find WhatsApp session: %w", err)
1100 RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
1103 return address.String(), nil
1106 // whatsappRedact replaces each of the own IDs own in text with OWN.
1107 func whatsappRedact(own []string, text string) string {
1108 for _, id := range own {
1109 text = strings.ReplaceAll(text, id, "OWN")
1115 // whatsappItemError returns the error of a pairing item that is not a code, a
1116 // success, or a timeout.
1117 func whatsappItemError(item whatsmeow.QRChannelItem) string {
1118 if item.Error != nil {
1119 return item.Error.Error()
1125 // whatsappPhone returns the digits of phone, a number with its country code and
1126 // an optional + and dashes, and tells if it has the form of a number: 7 to 15
1127 // digits, with no 0 first.
1128 func whatsappPhone(phone string) (string, bool) {
1129 phone = strings.ReplaceAll(strings.TrimPrefix(phone, "+"), "-", "")
1130 valid := len(phone) >= 7 && len(phone) <= 15 && phone[0] != '0' && strings.Trim(phone, "0123456789") == ""
1135 // whatsappSetDeviceProps sets the device properties that the library sends at
1136 // the pairing. They are global values of the library, so the daemon sets them
1137 // once before any client exists. The phone shows the linked device with the
1138 // name OneChat and a desktop icon only when the platform type is known.
1139 func whatsappSetDeviceProps() {
1140 store.SetOSInfo("OneChat", [3]uint32{0, 1, 0})
1142 store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_DESKTOP.Enum()
1145 // whatsappNewClient returns the client of the library for device, with handle
1146 // as its event handler. Run owns failed starting connections of linked devices,
1147 // so only an unlinked device enables the initial retry of the library for
1148 // pairing. The library still retries a lost connection. The client acknowledges
1149 // a message only after handle returns true, and downloads no old history.
1150 func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
1151 client := whatsmeow.NewClient(device, waLog.Noop)
1152 client.InitialAutoReconnect = device.ID == nil
1153 client.SynchronousAck = true
1154 client.ManualHistorySyncDownload = true
1155 client.AddEventHandlerWithSuccessStatus(handle)
1160 // whatsappContactName returns the name of the person jid: the full name, the
1161 // first name, the business name, or the push name of the contact in the session
1162 // of device, then push, the push name of a message, then the phone number. A
1163 // LID gets its phone number from the LID map of the session.
1164 func whatsappContactName(ctx context.Context, device *store.Device, jid types.JID, push string) string {
1166 candidates := []types.JID{jid}
1167 phone := types.EmptyJID
1169 case types.DefaultUserServer:
1171 case types.HiddenUserServer:
1172 mapped, err := device.LIDs.GetPNForLID(ctx, jid)
1173 if err == nil && !mapped.IsEmpty() {
1175 candidates = append(candidates, mapped)
1179 for _, candidate := range candidates {
1180 contact, err := device.Contacts.GetContact(ctx, candidate)
1181 if err != nil || !contact.Found {
1185 for _, name := range []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName} {
1195 case !phone.IsEmpty():
1196 return "+" + phone.User
1202 // whatsappChatHash returns a short hash of chat for the daemon log, so that a
1203 // log line can tell chats apart without a phone number.
1204 func whatsappChatHash(chat types.JID) string {
1205 digest := sha256.Sum256([]byte(chat.ToNonAD().String()))
1207 return fmt.Sprintf("%x", digest[:6])
1210 // whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
1211 // command, or false for any other line.
1212 func whatsappCommandArgs(line string) (string, bool) {
1213 line = strings.TrimSpace(line)
1214 if line == whatsappCommand {
1218 args, found := strings.CutPrefix(line, whatsappCommand+" ")
1223 return strings.TrimSpace(args), true