]> git.giacomolli.uk - onechat/blobdiff - whatsapp.go
Log the start and the length of each subagent run
[onechat] / whatsapp.go
index c5af946027b9c4341feadf848ac792f26d3123b3..40729d83b044e8ca1303c2a1b08a10786d587e40 100644 (file)
@@ -1,10 +1,7 @@
 package main
 
 import (
-       "cmp"
        "context"
-       "crypto/sha256"
-       "encoding/hex"
        "errors"
        "fmt"
        "log"
@@ -15,19 +12,15 @@ import (
        "strings"
        "sync"
        "time"
-       "unicode"
 
        "go.mau.fi/whatsmeow"
+       "go.mau.fi/whatsmeow/proto/waCompanionReg"
        "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"
-       "golang.org/x/text/runes"
-       "golang.org/x/text/transform"
-       "golang.org/x/text/unicode/norm"
-
        // The session store of the library opens SQLite with this pure Go
        // driver.
        _ "modernc.org/sqlite"
@@ -80,29 +73,8 @@ const (
        // session database or of the groups from WhatsApp.
        whatsappLookupTime = 10 * time.Second
 
-       // whatsappFindMatches is the most matches of whatsapp_find.
-       whatsappFindMatches = 20
-
        // 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"
-
-       // whatsappNamesNotReady is the answer of whatsapp_find before the names
-       // arrive from the phone.
-       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."
 )
 
 // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
@@ -363,6 +335,17 @@ func (w *WhatsApp) Close() error {
        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)
+}
+
+// Send returns the answer of whatsapp_send for the send id of text to chat, as
+// whatsappSend says.
+func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) {
+       return whatsappSend(ctx, w.link(), w.groups, w.state, w.clock, id, chat, text)
+}
+
 func (w *WhatsApp) sessionPath() string {
        return filepath.Join(w.dir, whatsappSessionFileName)
 }
@@ -422,7 +405,7 @@ func (w *WhatsApp) connect(ctx context.Context) {
 
        err := client.ConnectContext(ctx)
        if err != nil {
-               w.logger.Printf("connect: %s", w.redact(err.Error()))
+               w.logger.Printf("connect failed, restart to retry: %s", w.redact(err.Error()))
        }
 }
 
@@ -452,6 +435,7 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
                return nil
        }
 
+       w.logger.Print("logged out by the phone, run /whatsapp pair PHONE")
        err := w.inbox.deliver(ctx, l, w.link(), true)
        if err != nil {
                return err
@@ -459,12 +443,12 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
 
        err = w.removeSession()
        if err != nil {
-               w.logger.Printf("delete the session after the logout: %v", err)
+               w.logger.Printf("session delete failed: %v", err)
        }
 
        err = l.note("WhatsApp was unlinked from the phone.")
        if err != nil {
-               w.logger.Printf("note the logout: %v", err)
+               w.logger.Printf("chat note failed: %v", err)
        }
 
        w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
@@ -507,9 +491,10 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
                return nil
        }
 
+       w.logger.Print("unlinked")
        err = l.note("WhatsApp is unlinked.")
        if err != nil {
-               w.logger.Printf("note the unlink: %v", err)
+               w.logger.Printf("chat note failed: %v", err)
        }
 
        if client == nil || logoutErr != nil {
@@ -522,80 +507,6 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
        return nil
 }
 
-// Find returns the contacts and the groups whose name contains name, with no
-// regard to case and accents, as lines "ID | name | contact" or "ID | name |
-// group", sorted by name. It gives at most whatsappFindMatches lines and the
-// number of the others. The contacts come from the session database, and the
-// groups from WhatsApp, read again after whatsappGroupsAge. When the groups
-// cannot be read and none are known, the answer says so. Before the names
-// arrive from the phone, it says that they are not ready.
-func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) {
-       w.mutex.Lock()
-       linked := w.linked
-       device := w.device
-       own := slices.Clone(w.own)
-       w.mutex.Unlock()
-
-       stale := w.groups.stale()
-
-       if !linked {
-               return whatsappNotLinked, nil
-       }
-
-       query := whatsappFold(name)
-       if query == "" {
-               return "", errors.New("name is empty: give a part of the name of the contact or the group")
-       }
-
-       contacts, err := device.Contacts.GetAllContacts(ctx)
-       if err != nil {
-               return "", fmt.Errorf("read the contacts: %s", w.redact(err.Error()))
-       }
-
-       read := !stale || w.groups.read(ctx, w.link())
-       groups := w.groups.all()
-
-       if len(contacts) == 0 && len(groups) == 0 {
-               if !read {
-                       return "The names are not known yet, and WhatsApp is disconnected, so the groups cannot be read. Try again later.", nil
-               }
-
-               return whatsappNamesNotReady, nil
-       }
-
-       var matches []WhatsAppMatch
-       for jid, contact := range contacts {
-               names := []string{contact.FullName, contact.FirstName, contact.BusinessName, contact.PushName}
-               shown := slices.IndexFunc(names, func(name string) bool {
-                       return name != ""
-               })
-
-               if slices.Contains(own, jid.User) || shown < 0 || !slices.ContainsFunc(names, func(name string) bool {
-                       return strings.Contains(whatsappFold(name), query)
-               }) {
-                       continue
-               }
-
-               matches = append(matches, WhatsAppMatch{
-                       id:   jid.ToNonAD().String(),
-                       name: names[shown],
-                       kind: "contact",
-               })
-       }
-
-       for id, group := range groups {
-               if strings.Contains(whatsappFold(group), query) {
-                       matches = append(matches, WhatsAppMatch{
-                               id:   id,
-                               name: group,
-                               kind: "group",
-                       })
-               }
-       }
-
-       return whatsappFindAnswer(name, matches, read || len(groups) > 0), nil
-}
-
 // removeSession ends the connection, closes the session database, and deletes
 // its files.
 func (w *WhatsApp) removeSession() error {
@@ -605,6 +516,7 @@ func (w *WhatsApp) removeSession() error {
        w.linked = false
        w.own = nil
        w.connected = false
+       w.disconnectedAt = time.Time{}
        if w.timer != nil {
                w.timer.Stop()
        }
@@ -686,6 +598,8 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
                return
        }
 
+       // The server accepts only a display name of the form "Browser (OS)"
+       // with a common browser and OS, and refuses the pairing otherwise.
        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.")
@@ -717,10 +631,11 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
                case item.Event == whatsmeow.QRChannelSuccess.Event:
                        linked = true
                        w.linkedBy(client)
+                       w.logger.Print("linked")
 
                        err = l.note("WhatsApp is linked.")
                        if err != nil {
-                               w.logger.Printf("note the link: %v", err)
+                               w.logger.Printf("chat note failed: %v", err)
                        }
 
                        answer("WhatsApp is linked to +" + phone + ".")
@@ -783,6 +698,11 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) {
 // 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.
+//
+// The log of the daemon gets one line for each change of the connection of the
+// linked device: connected, a failed first connection, a lost connection, and
+// connected again with the length of the outage. The retries of the library
+// between them log nothing.
 func (w *WhatsApp) handle(event any) bool {
        switch event := event.(type) {
        case *events.Message:
@@ -803,12 +723,20 @@ func (w *WhatsApp) handle(event any) bool {
                w.inbox.startCatchup()
        }
 
-       var notice string
+       var notice, line string
        w.mutex.Lock()
        switch event := event.(type) {
        case *events.PairSuccess:
                w.own = whatsappOwnIDs(event.ID.User, event.LID.User)
        case *events.Connected:
+               switch {
+               case !w.linked || w.connected:
+               case w.disconnectedAt.IsZero():
+                       line = "connected"
+               default:
+                       line = "connected again after " + durationText(w.clock().Sub(w.disconnectedAt))
+               }
+
                w.connected = true
                w.connectedAt = w.clock()
                if w.timer != nil {
@@ -821,6 +749,11 @@ func (w *WhatsApp) handle(event any) bool {
                }
        case *events.Disconnected:
                if w.linked && (w.connected || w.disconnectedAt.IsZero()) {
+                       line = "disconnected, retrying"
+                       if !w.connected {
+                               line = "first connect failed, retrying"
+                       }
+
                        w.disconnectedAt = w.clock()
                        w.disconnections++
                        disconnections := w.disconnections
@@ -840,10 +773,20 @@ func (w *WhatsApp) handle(event any) bool {
                        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."
+                       line = "disconnected for good, restart to retry: " + whatsappRedact(w.own, event.PermanentDisconnectDescription())
                }
        }
+
+       if w.closed {
+               line = ""
+       }
+
        w.mutex.Unlock()
 
+       if line != "" {
+               w.logger.Print(line)
+       }
+
        if notice != "" {
                w.broadcast(notice)
        }
@@ -891,95 +834,6 @@ func (w *WhatsApp) redact(text string) string {
        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
-}
-
-// WhatsAppMatch is one contact or group of Find.
-type WhatsAppMatch struct {
-       id   string
-       name string
-       kind string
-}
-
 // 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 {
@@ -998,12 +852,20 @@ type WhatsAppRequest struct {
        answer func(text string)
 }
 
+// whatsappSetDeviceProps sets the device properties that the library sends at
+// the pairing. They are global values of the library, so the daemon sets them
+// once before any client exists. The phone shows the linked device with the
+// name OneChat and a desktop icon only when the platform type is known.
+func whatsappSetDeviceProps() {
+       store.SetOSInfo("OneChat", [3]uint32{0, 1, 0})
+       store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_DESKTOP.Enum()
+}
+
 // 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.
+// history.
 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
@@ -1055,65 +917,6 @@ func whatsappContactName(ctx context.Context, device *store.Device, jid types.JI
        }
 }
 
-// whatsappFold returns text in lower case with no accents and no space at its
-// ends, so that names match with no regard to case and accents.
-func whatsappFold(text string) string {
-       folded, _, err := transform.String(transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC), text)
-       if err != nil {
-               folded = text
-       }
-
-       return strings.ToLower(strings.TrimSpace(folded))
-}
-
-// whatsappFindAnswer returns the answer of Find for name with matches. Without
-// groups, the answer says that it has no groups.
-func whatsappFindAnswer(name string, matches []WhatsAppMatch, groups bool) string {
-       slices.SortFunc(matches, func(a, b WhatsAppMatch) int {
-               return cmp.Or(strings.Compare(whatsappFold(a.name), whatsappFold(b.name)), strings.Compare(a.id, b.id))
-       })
-
-       var lines []string
-       for _, match := range matches[:min(len(matches), whatsappFindMatches)] {
-               lines = append(lines, match.id+" | "+match.name+" | "+match.kind)
-       }
-
-       if len(matches) == 0 {
-               lines = append(lines, "No contact or group has a name with \""+name+"\".")
-       }
-
-       if len(matches) > whatsappFindMatches {
-               lines = append(lines, fmt.Sprintf("%d more: give a longer part of the name.", len(matches)-whatsappFindMatches))
-       }
-
-       if !groups {
-               lines = append(lines, "The groups are not in this answer, because WhatsApp is disconnected.")
-       }
-
-       return strings.Join(lines, "\n")
-}
-
-// 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