]> git.giacomolli.uk - onechat/blobdiff - whatsapp.go
Move whatsapp_find to its own file
[onechat] / whatsapp.go
index 20143a9fee0c6727776e4985ed0051ebeb72c595..5b0730013703fc0966b69ce918f720a05ab48205 100644 (file)
@@ -2,6 +2,8 @@ package main
 
 import (
        "context"
 
 import (
        "context"
+       "crypto/sha256"
+       "encoding/hex"
        "errors"
        "fmt"
        "log"
        "errors"
        "fmt"
        "log"
@@ -14,6 +16,7 @@ import (
        "time"
 
        "go.mau.fi/whatsmeow"
        "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/store"
        "go.mau.fi/whatsmeow/store/sqlstore"
        "go.mau.fi/whatsmeow/types"
@@ -68,19 +71,26 @@ const (
        // wait for Run.
        whatsappRequests = 8
 
        // wait for Run.
        whatsappRequests = 8
 
-       // whatsappSource is the source of the work events of the batches.
-       whatsappSource = "whatsapp"
-
        // 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
 
        // 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
 
-       // whatsappGroupsRefresh is the shortest time between two reads of the
-       // groups from WhatsApp for a group with no name.
-       whatsappGroupsRefresh = time.Minute
+       // 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
 
 
-       // whatsappUnknownGroup is the name of a group whose name is not known.
-       whatsappUnknownGroup = "A group with no known name"
+       // 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
 )
 
 // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
@@ -93,6 +103,8 @@ type WhatsAppClient interface {
        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)
        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
 }
 
 // openWhatsApp opens the WhatsApp session in the data directory dir. Without
@@ -109,17 +121,17 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
                noticeDelay: whatsappNoticeDelay,
                requests:    make(chan WhatsAppRequest, whatsappRequests),
                logouts:     make(chan struct{}, 1),
                noticeDelay: whatsappNoticeDelay,
                requests:    make(chan WhatsAppRequest, whatsappRequests),
                logouts:     make(chan struct{}, 1),
-               wake:        make(chan struct{}, 1),
-               failures:    make(chan error, 1),
-               groupNames:  make(map[string]string),
+               groups:      newWhatsAppGroups(logger),
        }
 
        var err error
        }
 
        var err error
-       w.batches, err = openWhatsAppBatches(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow)
+       w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow)
        if err != nil {
                return nil, err
        }
 
        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
        info, err := os.Stat(w.sessionPath())
        if errors.Is(err, os.ErrNotExist) {
                return w, nil
@@ -140,6 +152,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
 
        if w.device.ID != nil {
                w.linked = true
 
        if w.device.ID != nil {
                w.linked = true
+               w.inbox.waitForCatchup()
                w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
        }
 
                w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
        }
 
@@ -159,13 +172,9 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge
 // client, and the state of the connection, which the event handler of the
 // library changes from its own goroutines.
 //
 // client, and the state of the connection, which the event handler of the
 // library changes from its own goroutines.
 //
-// The text messages go into batches, one chat at a time, as WhatsAppBatches
-// says. Run logs each closed batch as a work message that starts with
-// [WhatsApp], and a batch that needs the user wakes the turn loop. The event
-// handler acknowledges a message only after the state file holds it, so the
-// servers send again a message that a crash lost. A batch leaves the state file
-// only after its work message is durable in the log. A crash between the two
-// makes the batch repeat once.
+// 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
 //
 // 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
@@ -182,9 +191,9 @@ type WhatsApp struct {
        noticeDelay time.Duration
        requests    chan WhatsAppRequest
        logouts     chan struct{}
        noticeDelay time.Duration
        requests    chan WhatsAppRequest
        logouts     chan struct{}
-       wake        chan struct{}
-       failures    chan error
-       batches     *WhatsAppBatches
+       state       *WhatsAppState
+       groups      *WhatsAppGroups
+       inbox       *WhatsAppInbox
 
        mutex          sync.Mutex
        container      *sqlstore.Container
 
        mutex          sync.Mutex
        container      *sqlstore.Container
@@ -200,8 +209,6 @@ type WhatsApp struct {
        noticed        bool
        timer          *time.Timer
        closed         bool
        noticed        bool
        timer          *time.Timer
        closed         bool
-       groupNames     map[string]string
-       groupsAt       time.Time
 }
 
 // Run connects a linked device, logs the closed batches in l, runs the pair and
 }
 
 // Run connects a linked device, logs the closed batches in l, runs the pair and
@@ -230,24 +237,18 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err
        }()
 
        for {
        }()
 
        for {
-               err := w.deliver(ctx, l)
+               err := w.inbox.deliver(ctx, l, w.link(), false)
                if err != nil {
                        return err
                }
 
                if err != nil {
                        return err
                }
 
-               var due <-chan time.Time
-               wait, open := w.batches.Wait(time.Now())
-               if open {
-                       due = time.After(wait)
-               }
-
                select {
                case <-ctx.Done():
                        return nil
                select {
                case <-ctx.Done():
                        return nil
-               case err = <-w.failures:
+               case err = <-w.inbox.failures:
                        return err
                        return err
-               case <-w.wake:
-               case <-due:
+               case <-w.inbox.wake:
+               case <-w.inbox.due():
                case <-w.logouts:
                        err = w.loggedOut(ctx, l)
                        if err != nil {
                case <-w.logouts:
                        err = w.loggedOut(ctx, l)
                        if err != nil {
@@ -272,7 +273,7 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err
                                pairing.Go(func() {
                                        defer cancelPair()
 
                                pairing.Go(func() {
                                        defer cancelPair()
 
-                                       w.pair(pairCtx, l, request.phone, request.answer)
+                                       w.pair(pairCtx, ctx, l, request.phone, request.answer)
                                })
                        }
                }
                                })
                        }
                }
@@ -350,6 +351,11 @@ func (w *WhatsApp) Close() error {
        return err
 }
 
        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)
 }
 func (w *WhatsApp) sessionPath() string {
        return filepath.Join(w.dir, whatsappSessionFileName)
 }
@@ -385,6 +391,21 @@ func (w *WhatsApp) isLinked() bool {
        return w.linked
 }
 
        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()
 // connect makes the client of the linked device and connects it.
 func (w *WhatsApp) connect(ctx context.Context) {
        w.mutex.Lock()
@@ -410,6 +431,8 @@ func (w *WhatsApp) close() {
        w.client = nil
        w.mutex.Unlock()
 
        w.client = nil
        w.mutex.Unlock()
 
+       w.inbox.close()
+
        if client != nil {
                client.Disconnect()
        }
        if client != nil {
                client.Disconnect()
        }
@@ -422,7 +445,7 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
                return nil
        }
 
                return nil
        }
 
-       err := w.deliverAll(ctx, l)
+       err := w.inbox.deliver(ctx, l, w.link(), true)
        if err != nil {
                return err
        }
        if err != nil {
                return err
        }
@@ -458,7 +481,7 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
                return nil
        }
 
                return nil
        }
 
-       err := w.deliverAll(ctx, l)
+       err := w.inbox.deliver(ctx, l, w.link(), true)
        if err != nil {
                return err
        }
        if err != nil {
                return err
        }
@@ -492,99 +515,6 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
        return nil
 }
 
        return nil
 }
 
-// deliver logs the closed batches in l in order, each as a work message of
-// whatsappSource that wakes the loop when the batch starts a turn. A batch
-// leaves the state file after its message is durable in the log. A group with
-// no name gets its name from WhatsApp first.
-func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop) error {
-       _, err := w.batches.CloseDue(time.Now())
-       if err != nil {
-               return err
-       }
-
-       for _, batch := range w.batches.Closed() {
-               if batch.Chat.Group && batch.Chat.Name == "" {
-                       batch.Chat.Name = w.groupName(ctx, batch.Chat.ID)
-               }
-
-               _, err = l.Work(whatsappSource, batch.Text(w.clock().Location()), "", batch.Turn())
-               if err != nil {
-                       return fmt.Errorf("log a WhatsApp batch: %w", err)
-               }
-
-               // A stop of the daemon during the flush is no failure: the
-               // batch stays in the state file and repeats once at the next
-               // start.
-               err = l.Flush(ctx)
-               if ctx.Err() != nil {
-                       return nil
-               }
-
-               if err != nil {
-                       return fmt.Errorf("log a WhatsApp batch: %w", err)
-               }
-
-               err = w.batches.Remove(batch.Number)
-               if err != nil {
-                       return err
-               }
-       }
-
-       return nil
-}
-
-// deliverAll closes every open batch and logs all of them.
-func (w *WhatsApp) deliverAll(ctx context.Context, l *TurnLoop) error {
-       err := w.batches.CloseAll()
-       if err != nil {
-               return err
-       }
-
-       return w.deliver(ctx, l)
-}
-
-// groupName returns the name of the group id, or whatsappUnknownGroup. A group
-// that is not in the names of the groups reads the groups from WhatsApp again,
-// at most once in whatsappGroupsRefresh.
-func (w *WhatsApp) groupName(ctx context.Context, id string) string {
-       w.mutex.Lock()
-       name, found := w.groupNames[id]
-       client := w.client
-       fresh := time.Since(w.groupsAt) < whatsappGroupsRefresh
-       w.mutex.Unlock()
-
-       if found {
-               return name
-       }
-
-       if client == nil || fresh {
-               return whatsappUnknownGroup
-       }
-
-       ctx, cancel := context.WithTimeout(ctx, whatsappLookupTime)
-       defer cancel()
-
-       groups, err := client.GetJoinedGroups(ctx)
-       if err != nil {
-               w.logger.Printf("read the groups: %s", w.redact(err.Error()))
-       }
-
-       w.mutex.Lock()
-       defer w.mutex.Unlock()
-
-       w.groupsAt = time.Now()
-       for _, group := range groups {
-               w.groupNames[group.JID.String()] = group.Name
-       }
-
-       name, found = w.groupNames[id]
-       if !found || name == "" {
-               return whatsappUnknownGroup
-       }
-
-       return name
-}
-
 // removeSession ends the connection, closes the session database, and deletes
 // its files.
 func (w *WhatsApp) removeSession() error {
 // removeSession ends the connection, closes the session database, and deletes
 // its files.
 func (w *WhatsApp) removeSession() error {
@@ -604,6 +534,8 @@ func (w *WhatsApp) removeSession() error {
        w.device = nil
        w.mutex.Unlock()
 
        w.device = nil
        w.mutex.Unlock()
 
+       w.inbox.stopCatchup()
+
        if client != nil {
                client.Disconnect()
        }
        if client != nil {
                client.Disconnect()
        }
@@ -628,7 +560,13 @@ func (w *WhatsApp) removeSession() error {
 // 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.
 // 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.
-func (w *WhatsApp) pair(ctx context.Context, l *TurnLoop, phone string, answer func(text string)) {
+//
+// 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())
        device, err := w.newDevice(ctx)
        if err != nil {
                answer("The pairing failed: " + err.Error())
@@ -645,7 +583,7 @@ func (w *WhatsApp) pair(ctx context.Context, l *TurnLoop, phone string, answer f
 
        qr, err := client.GetQRChannel(ctx)
        if err == nil {
 
        qr, err := client.GetQRChannel(ctx)
        if err == nil {
-               err = client.ConnectContext(ctx)
+               err = client.ConnectContext(runCtx)
        }
 
        if err != nil {
        }
 
        if err != nil {
@@ -758,26 +696,35 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) {
 }
 
 // handle takes one event of the library and tells if it was handled. A message
 }
 
 // handle takes one event of the library and tells if it was handled. A message
-// goes to receive. The names of the groups follow the events of the groups. 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.
+// 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 {
 func (w *WhatsApp) handle(event any) bool {
-       message, isMessage := event.(*events.Message)
-       if isMessage {
-               return w.receive(message)
-       }
-
-       var notice string
-       w.mutex.Lock()
        switch event := event.(type) {
        switch event := event.(type) {
+       case *events.Message:
+               return w.inbox.receive(w.link(), event)
        case *events.GroupInfo:
                if event.Name != nil {
        case *events.GroupInfo:
                if event.Name != nil {
-                       w.groupNames[event.JID.String()] = event.Name.Name
+                       w.groups.set(event.JID.String(), event.Name.Name)
                }
                }
+
+               return true
        case *events.JoinedGroup:
        case *events.JoinedGroup:
-               w.groupNames[event.JID.String()] = event.Name
+               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:
        case *events.PairSuccess:
                w.own = whatsappOwnIDs(event.ID.User, event.LID.User)
        case *events.Connected:
@@ -823,129 +770,6 @@ func (w *WhatsApp) handle(event any) bool {
        return true
 }
 
        return true
 }
 
-// receive puts the text of event into its batch and wakes Run. It returns false
-// when the state file cannot hold the message, so that the library does not
-// acknowledge it, and Run stops the daemon. A message that is not text, or that
-// the batches do not take, is handled with nothing to do.
-func (w *WhatsApp) receive(event *events.Message) bool {
-       ctx, cancel := context.WithTimeout(context.Background(), whatsappLookupTime)
-       defer cancel()
-
-       chat, message, found := w.message(ctx, event)
-       if !found {
-               return true
-       }
-
-       _, err := w.batches.Add(chat, message, time.Now())
-       if err != nil {
-               select {
-               case w.failures <- err:
-               default:
-               }
-
-               return false
-       }
-
-       select {
-       case w.wake <- struct{}{}:
-       default:
-       }
-
-       return true
-}
-
-// message returns the chat and the batch message of event, or false when the
-// batches do not take it. They take the text of a private chat or a group, from
-// another person or from the user. They do not take media, reactions, edits,
-// deletions, view-once and disappearing messages, old history, status
-// broadcasts, channels, and the chat of the user with itself.
-//
-// A mention of the user becomes @you, another mention @NAME when the name is
-// known, and the own number and LID become OWN, so that they never go to the
-// log.
-func (w *WhatsApp) message(ctx context.Context, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
-       w.mutex.Lock()
-       device := w.device
-       own := slices.Clone(w.own)
-       w.mutex.Unlock()
-
-       info := event.Info
-       chatJID := info.Chat.ToNonAD()
-       switch {
-       case device == nil:
-       case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil:
-       case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
-       case slices.Contains(own, chatJID.User):
-       default:
-               return w.text(ctx, device, own, event)
-       }
-
-       return WhatsAppChat{}, WhatsAppMessage{}, false
-}
-
-// text returns the chat and the batch message of event, a message that the
-// batches take when it has text.
-func (w *WhatsApp) text(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
-       info := event.Info
-       text := event.Message.GetConversation()
-       extended := event.Message.GetExtendedTextMessage()
-       if text == "" {
-               text = extended.GetText()
-       }
-
-       if strings.TrimSpace(text) == "" {
-               return WhatsAppChat{}, WhatsAppMessage{}, false
-       }
-
-       contextInfo := extended.GetContextInfo()
-       message := WhatsAppMessage{
-               ID:   info.ID,
-               Time: info.Timestamp,
-               Mine: info.IsFromMe,
-       }
-
-       if !message.Mine {
-               message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName)
-       }
-
-       for _, mentioned := range contextInfo.GetMentionedJID() {
-               jid, err := types.ParseJID(mentioned)
-               switch {
-               case err != nil:
-               case slices.Contains(own, jid.User):
-                       message.Mention = true
-                       text = strings.ReplaceAll(text, "@"+jid.User, "@you")
-               default:
-                       text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, ""))
-               }
-       }
-
-       participant, err := types.ParseJID(contextInfo.GetParticipant())
-       message.Reply = err == nil && slices.Contains(own, participant.User)
-
-       for _, id := range own {
-               text = strings.ReplaceAll(text, id, "OWN")
-       }
-
-       message.Text = text
-       chatJID := info.Chat.ToNonAD()
-       chat := WhatsAppChat{
-               ID:    chatJID.String(),
-               Group: chatJID.Server == types.GroupServer,
-       }
-
-       if !chat.Group {
-               push := info.PushName
-               if message.Mine {
-                       push = ""
-               }
-
-               chat.Name = whatsappContactName(ctx, device, chatJID, push)
-       }
-
-       return chat, message, true
-}
-
 // disconnectedLong gives the notice of a long disconnection, unless the
 // disconnection with the number disconnections ended.
 func (w *WhatsApp) disconnectedLong(disconnections int) {
 // disconnectedLong gives the notice of a long disconnection, unless the
 // disconnection with the number disconnections ended.
 func (w *WhatsApp) disconnectedLong(disconnections int) {
@@ -983,11 +807,99 @@ func (w *WhatsApp) redact(text string) string {
        w.mutex.Lock()
        defer w.mutex.Unlock()
 
        w.mutex.Lock()
        defer w.mutex.Unlock()
 
-       for _, id := range w.own {
-               text = strings.ReplaceAll(text, id, "OWN")
+       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
        }
 
        }
 
-       return text
+       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
 }
 
 // WhatsAppRequest is a pair or unlink command for Run, with the function that
@@ -1055,6 +967,27 @@ func whatsappContactName(ctx context.Context, device *store.Device, jid types.JI
        }
 }
 
        }
 }
 
+// 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
 // whatsappOwnIDs returns the own number and the own LID that are not empty.
 func whatsappOwnIDs(ids ...string) []string {
        var own []string
@@ -1067,6 +1000,15 @@ func whatsappOwnIDs(ids ...string) []string {
        return own
 }
 
        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.
 // 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.