package main import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "log" "net/url" "os" "path/filepath" "slices" "strings" "sync" "time" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" // The session store of the library opens SQLite with this pure Go // driver. _ "modernc.org/sqlite" ) const ( // whatsappDirName is the directory of the WhatsApp files in the data // directory. whatsappDirName = "whatsapp" // whatsappSessionFileName is the session database of the library in // whatsappDirName. whatsappSessionFileName = "session.db" // whatsappCommand starts the input lines of the WhatsApp commands, such // as "/whatsapp status". whatsappCommand = "/whatsapp" // whatsappUsage is the answer to a WhatsApp command that does not // exist. 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." // whatsappPairSteps tells the user what to do with the pairing code, // which comes in the next message. 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." // whatsappPairExpired is the answer when the pairing code expires. whatsappPairExpired = "The pairing code expired. Run /whatsapp pair PHONE again for a new code." // whatsappTimeLayout is the layout of the times in the answers. whatsappTimeLayout = "2006-01-02 15:04" // whatsappPairTime is the longest time of a pairing. The library closes // the pairing connection after about 160 seconds. whatsappPairTime = 3 * time.Minute // whatsappLogoutTime is the longest time of the logout call of an // unlink. whatsappLogoutTime = 30 * time.Second // whatsappNoticeDelay is how long a disconnection lasts before the user // gets a notice. whatsappNoticeDelay = 10 * time.Minute // whatsappRequests is the number of pair and unlink commands that can // wait for Run. whatsappRequests = 8 // whatsappLookupTime is the longest time of a read of the names in the // session database or of the groups from WhatsApp. whatsappLookupTime = 10 * time.Second // whatsappNotLinked is the answer of a WhatsApp tool without a link. whatsappNotLinked = "WhatsApp is not linked. Tell the user to run /whatsapp pair PHONE in the terminal or in Telegram." // whatsappSendTime is the longest time of a send. whatsappSendTime = 30 * time.Second // whatsappSendBytes is the most bytes of the text of a send. whatsappSendBytes = 65536 // whatsappSendIDBytes is the most bytes of the ID of a send. whatsappSendIDBytes = 100 // The states of a send record. whatsappSending = "sending" whatsappSent = "sent" whatsappFailed = "failed" ) // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The // real one is a *whatsmeow.Client from whatsappNewClient, and the tests use a // fake. type WhatsAppClient interface { ConnectContext(ctx context.Context) error Disconnect() GetQRChannel(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error) Logout(ctx context.Context) error GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error) GenerateMessageID() types.MessageID SendMessage(ctx context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error) } // openWhatsApp opens the WhatsApp session in the data directory dir. Without // the session file, WhatsApp is not linked. The session gives full access to // the account, so a file that another user can read is an error. The clock // gives the times of the status, logger gets the failures, and newClient makes // the client of a device with its event handler. 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) { w := &WhatsApp{ dir: filepath.Join(dir, whatsappDirName), clock: clock, logger: logger, newClient: newClient, noticeDelay: whatsappNoticeDelay, requests: make(chan WhatsAppRequest, whatsappRequests), logouts: make(chan struct{}, 1), groups: newWhatsAppGroups(logger), } var err error w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow) if err != nil { return nil, err } w.inbox = newWhatsAppInbox(w.state, clock, w.groups) info, err := os.Stat(w.sessionPath()) if errors.Is(err, os.ErrNotExist) { return w, nil } if err != nil { return nil, fmt.Errorf("read WhatsApp session: %w", err) } if info.Mode().Perm()&0o077 != 0 { return nil, fmt.Errorf("%s holds the WhatsApp session, so its mode must be 0600", filepath.Join(whatsappDirName, whatsappSessionFileName)) } err = w.openSession(ctx) if err != nil { return nil, err } if w.device.ID != nil { w.linked = true w.inbox.waitForCatchup() w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User) } return w, nil } // WhatsApp is the link of the daemon to the WhatsApp account of the user, as a // linked device. The session database of the library in dir holds the keys of // the device. Run keeps a linked device connected, and the library connects // again by itself after a network failure. // // The commands pair and unlink go to Run as requests, and Run runs one at a // time. A pairing runs in its own goroutine until its code expires, and a new // pairing ends the one before. Its phone number and code go only to the answer // of the client that asked. A logout from the phone comes as an event of the // library, and Run deletes the session. The mutex protects the session, the // client, and the state of the connection, which the event handler of the // library changes from its own goroutines. // // The inbox puts the text messages into batches and logs them, and groups // holds the names of the groups. Both get a WhatsAppLink from link for each // call, so they use the current device and client. // // The library logs nothing, because its log lines hold the ID of the own // account, which is the phone number of the user. The own number and the own // LID never go to the log of the agent or to the log of the daemon: redact // removes them from the errors, and a batch shows the messages of the user as // You and a mention of the user as @you. The IDs of the other chats go to the // log, and those of private chats hold phone numbers, because the agent needs // them to send. type WhatsApp struct { dir string clock func() time.Time logger *log.Logger newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient noticeDelay time.Duration requests chan WhatsAppRequest logouts chan struct{} state *WhatsAppState groups *WhatsAppGroups inbox *WhatsAppInbox mutex sync.Mutex container *sqlstore.Container device *store.Device client WhatsAppClient linked bool own []string notify func(Event) connected bool connectedAt time.Time disconnectedAt time.Time disconnections int noticed bool timer *time.Timer closed bool } // Run connects a linked device, logs the closed batches in l, runs the pair and // unlink commands, and handles a logout from the phone, until ctx ends. Its // notes go to the log of l, and notify gets the notices for all clients. A // failed first connection is logged, and the library tries again when the // failure is one of the network. Run returns the error of a write of the log or // of the state file, which stops the daemon, because then messages would be // lost. func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error { w.mutex.Lock() w.notify = notify linked := w.linked w.mutex.Unlock() if linked { w.connect(ctx) } var pairing sync.WaitGroup cancel := func() {} defer func() { cancel() pairing.Wait() w.close() }() for { err := w.inbox.deliver(ctx, l, w.link(), false) if err != nil { return err } select { case <-ctx.Done(): return nil case err = <-w.inbox.failures: return err case <-w.inbox.wake: case <-w.inbox.due(): case <-w.logouts: err = w.loggedOut(ctx, l) if err != nil { return err } case request := <-w.requests: cancel() pairing.Wait() cancel = func() {} switch { case request.unlink: err = w.unlink(ctx, l, request.answer) if err != nil { return err } case w.isLinked(): request.answer("WhatsApp is linked already. Run /whatsapp unlink first.") default: pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTime) cancel = cancelPair pairing.Go(func() { defer cancelPair() w.pair(pairCtx, ctx, l, request.phone, request.answer) }) } } } } // Command runs the WhatsApp command args, the text after /whatsapp, and gives // each answer to answer. The answers go only to the client that sent the // command. func (w *WhatsApp) Command(args string, answer func(text string)) { fields := strings.Fields(args) request := WhatsAppRequest{ answer: answer, } switch { case len(fields) == 1 && fields[0] == "status": answer(w.Status()) return case len(fields) == 1 && fields[0] == "unlink": request.unlink = true case len(fields) >= 2 && fields[0] == "pair": phone, valid := whatsappPhone(strings.Join(fields[1:], "")) if !valid { answer("PHONE must be the number of your WhatsApp account with its country code: 7 to 15 digits, such as +15550001111.") return } request.phone = phone default: answer(whatsappUsage) return } select { case w.requests <- request: default: answer("Too many WhatsApp commands wait. Try again in a minute.") } } // Status returns the state of the link: not linked, linked and connected, or // linked and disconnected, with the times of the connection. It never holds the // phone number. func (w *WhatsApp) Status() string { w.mutex.Lock() defer w.mutex.Unlock() switch { case !w.linked: return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it." case w.connected: return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "." case w.disconnectedAt.IsZero(): return "WhatsApp is linked and not connected yet." case w.connectedAt.IsZero(): return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "." default: return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "." } } // Close closes the session database. func (w *WhatsApp) Close() error { w.mutex.Lock() defer w.mutex.Unlock() if w.container == nil { return nil } err := w.container.Close() w.container = nil return err } // Find returns the answer of whatsapp_find for name, as whatsappFind says. func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) { return whatsappFind(ctx, w.link(), w.groups, name) } func (w *WhatsApp) sessionPath() string { return filepath.Join(w.dir, whatsappSessionFileName) } // openSession opens the session database and its first device. The caller holds // the mutex, or no other goroutine uses w yet. func (w *WhatsApp) openSession(ctx context.Context) error { address, err := whatsappSessionAddress(w.sessionPath()) if err != nil { return err } container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop) if err != nil { return fmt.Errorf("open WhatsApp session: %w", err) } device, err := container.GetFirstDevice(ctx) if err != nil { return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close()) } w.container = container w.device = device return nil } func (w *WhatsApp) isLinked() bool { w.mutex.Lock() defer w.mutex.Unlock() return w.linked } // link returns a copy of the link of the device: whether it is linked, the // device, the client, whether it is connected, and the own IDs. func (w *WhatsApp) link() WhatsAppLink { w.mutex.Lock() defer w.mutex.Unlock() return WhatsAppLink{ linked: w.linked, device: w.device, client: w.client, connected: w.connected, own: slices.Clone(w.own), } } // connect makes the client of the linked device and connects it. func (w *WhatsApp) connect(ctx context.Context) { w.mutex.Lock() w.client = w.newClient(w.device, w.handle) client := w.client w.mutex.Unlock() err := client.ConnectContext(ctx) if err != nil { w.logger.Printf("connect: %s", w.redact(err.Error())) } } // close ends the connection and the timer of the notice, and stops all notices. func (w *WhatsApp) close() { w.mutex.Lock() w.closed = true if w.timer != nil { w.timer.Stop() } client := w.client w.client = nil w.mutex.Unlock() w.inbox.close() if client != nil { client.Disconnect() } } // loggedOut logs the open batches, deletes the session after a logout from the // phone, notes it in the log of l, and tells all clients one time. func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error { if !w.isLinked() { return nil } err := w.inbox.deliver(ctx, l, w.link(), true) if err != nil { return err } err = w.removeSession() if err != nil { w.logger.Printf("delete the session after the logout: %v", err) } err = l.note("WhatsApp was unlinked from the phone.") if err != nil { w.logger.Printf("note the logout: %v", err) } w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.") return nil } // unlink logs the open batches, logs out the linked device, and deletes the // session. When the logout call fails, as it does while WhatsApp is // disconnected, the session is deleted all the same, and answer tells the user // to remove the device in the phone. Its error is one of the log or the state // file. func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) error { w.mutex.Lock() linked := w.linked client := w.client w.mutex.Unlock() if !linked { answer("WhatsApp is not linked.") return nil } err := w.inbox.deliver(ctx, l, w.link(), true) if err != nil { return err } var logoutErr error if client != nil { ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTime) defer cancel() logoutErr = client.Logout(ctx) } err = w.removeSession() if err != nil { answer("The unlink failed: " + w.redact(err.Error())) return nil } err = l.note("WhatsApp is unlinked.") if err != nil { w.logger.Printf("note the unlink: %v", err) } if client == nil || logoutErr != nil { 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.") return nil } answer("WhatsApp is unlinked.") return nil } // removeSession ends the connection, closes the session database, and deletes // its files. func (w *WhatsApp) removeSession() error { w.mutex.Lock() client := w.client w.client = nil w.linked = false w.own = nil w.connected = false if w.timer != nil { w.timer.Stop() } w.noticed = false container := w.container w.container = nil w.device = nil w.mutex.Unlock() w.inbox.stopCatchup() if client != nil { client.Disconnect() } var err error if container != nil { err = container.Close() } for _, suffix := range []string{"", "-wal", "-shm"} { removeErr := os.Remove(w.sessionPath() + suffix) if !errors.Is(removeErr, os.ErrNotExist) { err = errors.Join(err, removeErr) } } return err } // pair links a new device to the account of phone with a pairing code. It gives // the steps and the code to answer, and then the end of the pairing: the linked // number, an expired code, or a failure. A pairing that a new one ends gives no // answer. A link notes "WhatsApp is linked." in the log of l, with no number. // The client of a pairing that does not link is disconnected. // // ctx limits the pairing, but the connection uses runCtx, the context of Run. // The library keeps the context of the connection for its keep-alive, its event // handler, and its reconnections, and after the link the server ends the stream // and the library connects again. With ctx, the end of the pairing would stop // that reconnection, and the linked device would never connect. func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string, answer func(text string)) { device, err := w.newDevice(ctx) if err != nil { answer("The pairing failed: " + err.Error()) return } client := w.newClient(device, w.handle) linked := false defer func() { if !linked { client.Disconnect() } }() qr, err := client.GetQRChannel(ctx) if err == nil { err = client.ConnectContext(runCtx) } if err != nil { answer("The connection to WhatsApp failed: " + err.Error()) return } select { case item, open := <-qr: if !open || item.Event != whatsmeow.QRChannelEventCode { answer("WhatsApp did not start the pairing. Try again later.") return } case <-ctx.Done(): if errors.Is(ctx.Err(), context.DeadlineExceeded) { answer("The connection to WhatsApp did not start the pairing in time. Try again later.") } return } code, err := client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)") if err != nil { answer("WhatsApp refused the pairing: " + err.Error() + ". Check the phone number and run /whatsapp pair PHONE again.") return } answer(whatsappPairSteps) answer(code) for { var item whatsmeow.QRChannelItem open := true select { case item, open = <-qr: case <-ctx.Done(): if errors.Is(ctx.Err(), context.DeadlineExceeded) { answer(whatsappPairExpired) } return } switch { case !open || item.Event == whatsmeow.QRChannelTimeout.Event: answer(whatsappPairExpired) return case item.Event == whatsmeow.QRChannelEventCode: continue case item.Event == whatsmeow.QRChannelSuccess.Event: linked = true w.linkedBy(client) err = l.note("WhatsApp is linked.") if err != nil { w.logger.Printf("note the link: %v", err) } answer("WhatsApp is linked to +" + phone + ".") return default: answer("The pairing failed: " + whatsappItemError(item) + ". Run /whatsapp pair PHONE again.") return } } } // newDevice returns a new device in the session database. It makes the // directory and the file of the session first, with their modes, when they do // not exist. func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) { w.mutex.Lock() defer w.mutex.Unlock() if w.container == nil { err := os.MkdirAll(w.dir, 0o700) if err != nil { return nil, fmt.Errorf("create WhatsApp directory: %w", err) } file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600) if err != nil { return nil, fmt.Errorf("create WhatsApp session: %w", err) } err = file.Close() if err != nil { return nil, fmt.Errorf("create WhatsApp session: %w", err) } err = w.openSession(ctx) if err != nil { return nil, err } } w.device = w.container.NewDevice() return w.device, nil } // linkedBy makes client the client of the linked device. func (w *WhatsApp) linkedBy(client WhatsAppClient) { w.mutex.Lock() defer w.mutex.Unlock() w.client = client w.linked = true } // handle takes one event of the library and tells if it was handled. A message // goes to the inbox. The names of the groups follow the events of the groups. A // connection starts a catch-up of the inbox, which ends at OfflineSyncCompleted // or after its catchupLimit. A disconnection keeps the time of its start while // the library tries again. After noticeDelay of a disconnection, all clients // get one notice, and they get one more when the connection comes back. A // disconnection that the library does not try again gives a notice at once. func (w *WhatsApp) handle(event any) bool { switch event := event.(type) { case *events.Message: return w.inbox.receive(w.link(), event) case *events.GroupInfo: if event.Name != nil { w.groups.set(event.JID.String(), event.Name.Name) } return true case *events.JoinedGroup: w.groups.set(event.JID.String(), event.Name) return true case *events.OfflineSyncCompleted: w.inbox.endCatchup() return true case *events.Connected: w.inbox.startCatchup() } var notice string w.mutex.Lock() switch event := event.(type) { case *events.PairSuccess: w.own = whatsappOwnIDs(event.ID.User, event.LID.User) case *events.Connected: w.connected = true w.connectedAt = w.clock() if w.timer != nil { w.timer.Stop() } if w.noticed { w.noticed = false notice = "WhatsApp is connected again." } case *events.Disconnected: if w.linked && (w.connected || w.disconnectedAt.IsZero()) { w.disconnectedAt = w.clock() w.disconnections++ disconnections := w.disconnections w.timer = time.AfterFunc(w.noticeDelay, func() { w.disconnectedLong(disconnections) }) } w.connected = false case *events.LoggedOut: select { case w.logouts <- struct{}{}: default: } case events.PermanentDisconnect: if w.linked { w.connected = false w.noticed = true notice = "WhatsApp is disconnected and does not connect again by itself: " + event.PermanentDisconnectDescription() + ". Restart the daemon to try again." } } w.mutex.Unlock() if notice != "" { w.broadcast(notice) } return true } // disconnectedLong gives the notice of a long disconnection, unless the // disconnection with the number disconnections ended. func (w *WhatsApp) disconnectedLong(disconnections int) { w.mutex.Lock() long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections if long { w.noticed = true } since := w.disconnectedAt w.mutex.Unlock() if long { w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.") } } // broadcast gives text as a notice to all clients. func (w *WhatsApp) broadcast(text string) { w.mutex.Lock() notify := w.notify closed := w.closed w.mutex.Unlock() if notify != nil && !closed { notify(Event{ Kind: "notice", Text: text, }) } } // redact replaces the own number and the own LID in text with OWN. func (w *WhatsApp) redact(text string) string { w.mutex.Lock() defer w.mutex.Unlock() return whatsappRedact(w.own, text) } // Send sends text to chat, the ID of a private chat or a group, and returns the // answer of the tool. id names the send: a call with an id that has a record // gives the answer of the record and sends nothing, and the same id with // another chat or text is an error. The record "sending" is durable before the // send, so a stop of the daemon during the send gives an answer that the result // is unknown, and not a second send. A disconnected WhatsApp sends nothing and // makes no record. func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) { w.mutex.Lock() linked := w.linked device := w.device client := w.client connected := w.connected own := slices.Clone(w.own) w.mutex.Unlock() if !linked { return whatsappNotLinked, nil } jid, err := whatsappSendCheck(id, chat, text, own) if err != nil { return "", err } digest := sha256.Sum256([]byte(text)) record := WhatsAppSend{ ID: id, Chat: chat, Digest: hex.EncodeToString(digest[:]), State: whatsappSending, Time: w.clock(), } held, found := w.state.Send(id) switch { case found && (held.Chat != record.Chat || held.Digest != record.Digest): return "", fmt.Errorf("the id %s belongs to another message: give a new id for each message", id) case found && held.State == whatsappSending: 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 case found: return held.Answer + " This is the answer of the first call with this id: nothing was sent again.", nil case client == nil || !connected: return "", errors.New("WhatsApp is disconnected, so nothing was sent: try again later") } record.MessageID = client.GenerateMessageID() err = w.state.PutSend(record) if err != nil { return "", err } sendCtx, cancel := context.WithTimeout(ctx, whatsappSendTime) defer cancel() response, err := client.SendMessage(sendCtx, jid, &waE2E.Message{ Conversation: new(text), }, whatsmeow.SendRequestExtra{ ID: record.MessageID, }) record.State = whatsappSent if err != nil { record.State = whatsappFailed record.Answer = "The send failed: " + w.redact(err.Error()) + "." } else { name := w.groups.name(ctx, w.link(), jid.String()) if jid.Server != types.GroupServer { name = whatsappContactName(ctx, device, jid, "") } record.Answer = "Sent to " + name + " at " + response.Timestamp.In(w.clock().Location()).Format("15:04") + "." } err = w.state.PutSend(record) if err != nil { return "", err } return record.Answer, nil } // WhatsAppLink is a copy of the link of the device at one time, for the parts // of WhatsApp that do not own it: the groups, the batches, find, and send. type WhatsAppLink struct { linked bool device *store.Device client WhatsAppClient connected bool own []string } // WhatsAppRequest is a pair or unlink command for Run, with the function that // gives its answers to the client that asked. A pairing has the phone number. type WhatsAppRequest struct { phone string unlink bool answer func(text string) } // whatsappNewClient returns the client of the library for device, with handle // as its event handler. The client tries again after a failed first connection, // acknowledges a message only after handle returns true, and downloads no old // history. The phone shows the device with the name OneChat. func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient { store.SetOSInfo("OneChat", [3]uint32{0, 1, 0}) client := whatsmeow.NewClient(device, waLog.Noop) client.InitialAutoReconnect = true client.SynchronousAck = true client.ManualHistorySyncDownload = true client.AddEventHandlerWithSuccessStatus(handle) return client } // whatsappContactName returns the name of the person jid: the full name, the // first name, the business name, or the push name of the contact in the session // of device, then push, the push name of a message, then the phone number. A // LID gets its phone number from the LID map of the session. func whatsappContactName(ctx context.Context, device *store.Device, jid types.JID, push string) string { jid = jid.ToNonAD() candidates := []types.JID{jid} phone := types.EmptyJID switch jid.Server { case types.DefaultUserServer: phone = jid case types.HiddenUserServer: mapped, err := device.LIDs.GetPNForLID(ctx, jid) if err == nil && !mapped.IsEmpty() { phone = mapped candidates = append(candidates, mapped) } } for _, candidate := range candidates { contact, err := device.Contacts.GetContact(ctx, candidate) if err != nil || !contact.Found { continue } for _, name := range []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName} { if name != "" { return name } } } switch { case push != "": return push case !phone.IsEmpty(): return "+" + phone.User default: return jid.User } } // whatsappSendCheck returns the JID of chat, and an error when id, chat, or // text cannot go to a send. The chat is a private chat or a group, and not the // chat of the user with itself. func whatsappSendCheck(id, chat, text string, own []string) (types.JID, error) { jid, err := types.ParseJID(chat) switch { case strings.TrimSpace(id) == "" || len(id) > whatsappSendIDBytes: return types.EmptyJID, fmt.Errorf("the id must have 1 to %d bytes", whatsappSendIDBytes) case strings.TrimSpace(text) == "" || len(text) > whatsappSendBytes: return types.EmptyJID, fmt.Errorf("the text must have 1 to %d bytes", whatsappSendBytes) case err != nil || jid.User == "" || jid != jid.ToNonAD() || jid.String() != chat: return types.EmptyJID, fmt.Errorf("%q is not the ID of a WhatsApp chat: find the ID with whatsapp_find", chat) case jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer && jid.Server != types.GroupServer: return types.EmptyJID, fmt.Errorf("%q is not a private chat or a group", chat) case slices.Contains(own, jid.User): return types.EmptyJID, errors.New("this is the chat of the user with itself: send to another chat") } return jid, nil } // whatsappOwnIDs returns the own number and the own LID that are not empty. func whatsappOwnIDs(ids ...string) []string { var own []string for _, id := range ids { if id != "" { own = append(own, id) } } return own } // whatsappRedact replaces each of the own IDs own in text with OWN. func whatsappRedact(own []string, text string) string { for _, id := range own { text = strings.ReplaceAll(text, id, "OWN") } return text } // whatsappSessionAddress returns the SQLite address of the session database at // path, with foreign keys on, as the library needs, and a wait for a busy // database. func whatsappSessionAddress(path string) (string, error) { path, err := filepath.Abs(path) if err != nil { return "", fmt.Errorf("find WhatsApp session: %w", err) } address := url.URL{ Scheme: "file", Path: path, RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)", } return address.String(), nil } // whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp // command, or false for any other line. func whatsappCommandArgs(line string) (string, bool) { line = strings.TrimSpace(line) if line == whatsappCommand { return "", true } args, found := strings.CutPrefix(line, whatsappCommand+" ") if !found { return "", false } return strings.TrimSpace(args), true } // whatsappPhone returns the digits of phone, a number with its country code and // an optional + and dashes, and tells if it has the form of a number: 7 to 15 // digits, with no 0 first. func whatsappPhone(phone string) (string, bool) { phone = strings.ReplaceAll(strings.TrimPrefix(phone, "+"), "-", "") valid := len(phone) >= 7 && len(phone) <= 15 && phone[0] != '0' && strings.Trim(phone, "0123456789") == "" return phone, valid } // whatsappItemError returns the error of a pairing item that is not a code, a // success, or a timeout. func whatsappItemError(item whatsmeow.QRChannelItem) string { if item.Error != nil { return item.Error.Error() } return item.Event }