]> git.giacomolli.uk - onechat/blobdiff - whatsapp.go
Import WhatsApp exports before live messages
[onechat] / whatsapp.go
index 2c1b6cb9aaa70e327fd4a342d282fb97ecd4a247..c3db573f9c8d332ab885e662fd2b6e4e07b189f5 100644 (file)
@@ -65,6 +65,13 @@ const (
        // gets a notice.
        whatsappNoticeDelay = 10 * time.Minute
 
        // gets a notice.
        whatsappNoticeDelay = 10 * time.Minute
 
+       // whatsappRetryDelay is the first wait after a failed starting
+       // connection.
+       whatsappRetryDelay = 2 * time.Second
+
+       // whatsappRetryLimit is the longest wait between starting connections.
+       whatsappRetryLimit = 5 * time.Minute
+
        // whatsappRequests is the number of pair and unlink commands that can
        // wait for Run.
        whatsappRequests = 8
        // whatsappRequests is the number of pair and unlink commands that can
        // wait for Run.
        whatsappRequests = 8
@@ -187,6 +194,7 @@ type WhatsApp struct {
        own            []string
        notify         func(Event)
        connected      bool
        own            []string
        notify         func(Event)
        connected      bool
+       retrying       bool
        connectedAt    time.Time
        disconnectedAt time.Time
        disconnections int
        connectedAt    time.Time
        disconnectedAt time.Time
        disconnections int
@@ -198,18 +206,41 @@ type WhatsApp struct {
 // 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
 // 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.
+// failed first connection starts the same outage notice as a lost connection. A
+// timer in the select retries it with a doubled wait from 2 seconds to 5
+// minutes, without a retry goroutine or a wait that blocks commands or the
+// inbox. A connection, unlink, logout, or ctx end stops retries. The library
+// owns retries after a connection opens. 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
 func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error {
        w.mutex.Lock()
        w.notify = notify
        linked := w.linked
+       w.retrying = linked
        w.mutex.Unlock()
 
        w.mutex.Unlock()
 
-       if linked {
-               w.connect(ctx)
+       timer := time.NewTimer(whatsappRetryDelay)
+       timer.Stop()
+       defer timer.Stop()
+
+       var retry <-chan time.Time
+       delay := whatsappRetryDelay
+       connect := func() {
+               retry = nil
+               if w.connect(ctx) {
+                       timer.Reset(delay)
+                       retry = timer.C
+                       delay = min(2*delay, whatsappRetryLimit)
+               }
+       }
+
+       stopRetry := func() {
+               timer.Stop()
+               retry = nil
+               w.mutex.Lock()
+               w.retrying = false
+               w.mutex.Unlock()
        }
 
        var pairing sync.WaitGroup
        }
 
        var pairing sync.WaitGroup
@@ -220,6 +251,10 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err
                w.close()
        }()
 
                w.close()
        }()
 
+       if linked {
+               connect()
+       }
+
        for {
                err := w.inbox.deliver(ctx, l, w.link(), false)
                if err != nil {
        for {
                err := w.inbox.deliver(ctx, l, w.link(), false)
                if err != nil {
@@ -233,7 +268,10 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err
                        return err
                case <-w.inbox.wake:
                case <-w.inbox.due():
                        return err
                case <-w.inbox.wake:
                case <-w.inbox.due():
+               case <-retry:
+                       connect()
                case <-w.logouts:
                case <-w.logouts:
+                       stopRetry()
                        err = w.loggedOut(ctx, l)
                        if err != nil {
                                return err
                        err = w.loggedOut(ctx, l)
                        if err != nil {
                                return err
@@ -245,6 +283,7 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err
 
                        switch {
                        case request.unlink:
 
                        switch {
                        case request.unlink:
+                               stopRetry()
                                err = w.unlink(ctx, l, request.answer)
                                if err != nil {
                                        return err
                                err = w.unlink(ctx, l, request.answer)
                                if err != nil {
                                        return err
@@ -396,17 +435,59 @@ func (w *WhatsApp) link() WhatsAppLink {
        }
 }
 
        }
 }
 
-// connect makes the client of the linked device and connects it.
-func (w *WhatsApp) connect(ctx context.Context) {
+// connect tries the starting connection of the linked device with one client.
+// It returns whether Run must retry. A failed call starts the shared outage
+// state and logs its redacted cause once, because a returned ConnectContext
+// error does not emit Disconnected. Connected logs recovery after
+// authentication.
+func (w *WhatsApp) connect(ctx context.Context) bool {
        w.mutex.Lock()
        w.mutex.Lock()
-       w.client = w.newClient(w.device, w.handle)
+       if ctx.Err() != nil || !w.linked || !w.retrying || w.closed {
+               w.mutex.Unlock()
+               return false
+       }
+
+       if w.client == nil {
+               w.client = w.newClient(w.device, w.handle)
+       }
+
        client := w.client
        w.mutex.Unlock()
 
        err := client.ConnectContext(ctx)
        client := w.client
        w.mutex.Unlock()
 
        err := client.ConnectContext(ctx)
-       if err != nil {
-               w.logger.Printf("WhatsApp messages do not come, because the connection failed, and it is tried again only at a restart of the daemon: %s", w.redact(err.Error()))
+       w.mutex.Lock()
+       if err == nil || ctx.Err() != nil || !w.retrying || !w.linked {
+               w.retrying = false
+               w.mutex.Unlock()
+               return false
        }
        }
+
+       first := w.startOutage()
+       cause := whatsappRedact(w.own, err.Error())
+       w.mutex.Unlock()
+       if first {
+               w.logger.Printf("first connect failed, retrying: %s", cause)
+       }
+
+       return true
+}
+
+// startOutage starts the shared outage time and notice timer once. The caller
+// holds the mutex. Its result tells whether this is the first failure.
+func (w *WhatsApp) startOutage() bool {
+       first := w.linked && (w.connected || w.disconnectedAt.IsZero())
+       if first {
+               w.disconnectedAt = w.clock()
+               w.disconnections++
+               disconnections := w.disconnections
+               w.timer = time.AfterFunc(w.noticeDelay, func() {
+                       w.disconnectedLong(disconnections)
+               })
+       }
+
+       w.connected = false
+
+       return first
 }
 
 // close ends the connection and the timer of the notice, and stops all notices.
 }
 
 // close ends the connection and the timer of the notice, and stops all notices.
@@ -435,7 +516,7 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
                return nil
        }
 
                return nil
        }
 
-       w.logger.Print("WhatsApp messages stop, because the phone logged out this device (run /whatsapp pair PHONE to link it again)")
+       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
        err := w.inbox.deliver(ctx, l, w.link(), true)
        if err != nil {
                return err
@@ -443,12 +524,12 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
 
        err = w.removeSession()
        if err != nil {
 
        err = w.removeSession()
        if err != nil {
-               w.logger.Printf("the session of the logged out device stays on the disk, because its delete failed: %v", err)
+               w.logger.Printf("session delete failed: %v", err)
        }
 
        err = l.note("WhatsApp was unlinked from the phone.")
        if err != nil {
        }
 
        err = l.note("WhatsApp was unlinked from the phone.")
        if err != nil {
-               w.logger.Printf("the chat has no note of the logout, because its write failed: %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.")
        }
 
        w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
@@ -491,10 +572,10 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str
                return nil
        }
 
                return nil
        }
 
-       w.logger.Print("WhatsApp messages stop, because the user unlinked WhatsApp")
+       w.logger.Print("unlinked")
        err = l.note("WhatsApp is unlinked.")
        if err != nil {
        err = l.note("WhatsApp is unlinked.")
        if err != nil {
-               w.logger.Printf("the chat has no note of the unlink, because its write failed: %v", err)
+               w.logger.Printf("chat note failed: %v", err)
        }
 
        if client == nil || logoutErr != nil {
        }
 
        if client == nil || logoutErr != nil {
@@ -514,6 +595,7 @@ func (w *WhatsApp) removeSession() error {
        client := w.client
        w.client = nil
        w.linked = false
        client := w.client
        w.client = nil
        w.linked = false
+       w.retrying = false
        w.own = nil
        w.connected = false
        w.disconnectedAt = time.Time{}
        w.own = nil
        w.connected = false
        w.disconnectedAt = time.Time{}
@@ -631,11 +713,11 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string,
                case item.Event == whatsmeow.QRChannelSuccess.Event:
                        linked = true
                        w.linkedBy(client)
                case item.Event == whatsmeow.QRChannelSuccess.Event:
                        linked = true
                        w.linkedBy(client)
-                       w.logger.Print("WhatsApp is linked")
+                       w.logger.Print("linked")
 
                        err = l.note("WhatsApp is linked.")
                        if err != nil {
 
                        err = l.note("WhatsApp is linked.")
                        if err != nil {
-                               w.logger.Printf("the chat has no note of the link, because its write failed: %v", err)
+                               w.logger.Printf("chat note failed: %v", err)
                        }
 
                        answer("WhatsApp is linked to +" + phone + ".")
                        }
 
                        answer("WhatsApp is linked to +" + phone + ".")
@@ -695,14 +777,14 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) {
 // 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
 // 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.
+// Run or 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
 //
 // 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.
+// connected again with the length of the outage. The retries of the library and
+// Run between them log nothing.
 func (w *WhatsApp) handle(event any) bool {
        switch event := event.(type) {
        case *events.Message:
 func (w *WhatsApp) handle(event any) bool {
        switch event := event.(type) {
        case *events.Message:
@@ -732,12 +814,13 @@ func (w *WhatsApp) handle(event any) bool {
                switch {
                case !w.linked || w.connected:
                case w.disconnectedAt.IsZero():
                switch {
                case !w.linked || w.connected:
                case w.disconnectedAt.IsZero():
-                       line = "WhatsApp is connected, and new messages come in"
+                       line = "connected"
                default:
                default:
-                       line = "WhatsApp is connected again, after " + durationText(w.clock().Sub(w.disconnectedAt))
+                       line = "connected again after " + durationText(w.clock().Sub(w.disconnectedAt))
                }
 
                w.connected = true
                }
 
                w.connected = true
+               w.retrying = false
                w.connectedAt = w.clock()
                if w.timer != nil {
                        w.timer.Stop()
                w.connectedAt = w.clock()
                if w.timer != nil {
                        w.timer.Stop()
@@ -748,32 +831,26 @@ func (w *WhatsApp) handle(event any) bool {
                        notice = "WhatsApp is connected again."
                }
        case *events.Disconnected:
                        notice = "WhatsApp is connected again."
                }
        case *events.Disconnected:
-               if w.linked && (w.connected || w.disconnectedAt.IsZero()) {
-                       line = "new WhatsApp messages wait, because the connection is lost (the next try is automatic)"
-                       if !w.connected {
-                               line = "new WhatsApp messages wait, because the first connection failed (the next try is automatic)"
+               connected := w.connected
+               if w.startOutage() {
+                       line = "disconnected, retrying"
+                       if !connected {
+                               line = "first connect failed, retrying"
                        }
                        }
-
-                       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:
        case *events.LoggedOut:
+               w.retrying = false
                select {
                case w.logouts <- struct{}{}:
                default:
                }
        case events.PermanentDisconnect:
                select {
                case w.logouts <- struct{}{}:
                default:
                }
        case events.PermanentDisconnect:
+               w.retrying = false
                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."
                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."
-                       line = "WhatsApp messages do not come, because the connection is lost and it is tried again only at a restart of the daemon: " + whatsappRedact(w.own, event.PermanentDisconnectDescription())
+                       line = "disconnected for good, restart to retry: " + whatsappRedact(w.own, event.PermanentDisconnectDescription())
                }
        }
 
                }
        }
 
@@ -862,12 +939,13 @@ func whatsappSetDeviceProps() {
 }
 
 // whatsappNewClient returns the client of the library for device, with handle
 }
 
 // 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.
+// as its event handler. Run owns failed starting connections of linked devices,
+// so only an unlinked device enables the initial retry of the library for
+// pairing. The library still retries a lost connection. The client acknowledges
+// a message only after handle returns true, and downloads no old history.
 func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
        client := whatsmeow.NewClient(device, waLog.Noop)
 func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
        client := whatsmeow.NewClient(device, waLog.Noop)
-       client.InitialAutoReconnect = true
+       client.InitialAutoReconnect = device.ID == nil
        client.SynchronousAck = true
        client.ManualHistorySyncDownload = true
        client.AddEventHandlerWithSuccessStatus(handle)
        client.SynchronousAck = true
        client.ManualHistorySyncDownload = true
        client.AddEventHandlerWithSuccessStatus(handle)