fix(whatsapp): handle LoggedOut/StreamReplaced events and abort infinite reconnect

The whatsapp_native event handler only handled *events.Disconnected, so
permanent session invalidations (LoggedOut, StreamReplaced, TemporaryBan,
ClientOutdated) fell through silently. reconnectWithBackoff() would loop
forever (backoff up to 5 min) on a dead session, leaving the gateway
stuck and unresponsive.

- Add loggedOut atomic flag set on terminal events
- Handle LoggedOut, StreamReplaced, ConnectFailure, KeepAliveTimeout,
  TemporaryBan, and ClientOutdated with proper logging
- Abort reconnect loop immediately when session is permanently invalid
- Reset loggedOut on Start() so re-pairing works after restart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Fornalha 2026-04-06 21:53:57 -03:00
parent 1175f4a62b
commit 336d6402fe

View file

@ -58,6 +58,7 @@ type WhatsAppNativeChannel struct {
reconnectMu sync.Mutex reconnectMu sync.Mutex
reconnecting bool reconnecting bool
stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls
loggedOut atomic.Bool // set when session is permanently invalid (LoggedOut, StreamReplaced, etc.)
wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect) wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect)
} }
@ -88,6 +89,7 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
// and Stop() which coordinate under the same lock. // and Stop() which coordinate under the same lock.
c.reconnectMu.Lock() c.reconnectMu.Lock()
c.stopping.Store(false) c.stopping.Store(false)
c.loggedOut.Store(false)
c.reconnecting = false c.reconnecting = false
c.reconnectMu.Unlock() c.reconnectMu.Unlock()
@ -267,9 +269,37 @@ func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
} }
func (c *WhatsAppNativeChannel) eventHandler(evt any) { func (c *WhatsAppNativeChannel) eventHandler(evt any) {
switch evt.(type) { switch v := evt.(type) {
case *events.Message: case *events.Message:
c.handleIncoming(evt.(*events.Message)) c.handleIncoming(v)
case *events.LoggedOut:
c.loggedOut.Store(true)
logger.ErrorCF("whatsapp", "WhatsApp session logged out — re-pairing required", map[string]any{
"on_connect": v.OnConnect,
"reason": v.Reason.String(),
})
case *events.StreamReplaced:
c.loggedOut.Store(true)
logger.ErrorCF("whatsapp", "WhatsApp stream replaced by another session — re-pairing required", nil)
case *events.KeepAliveTimeout:
logger.WarnCF("whatsapp", "WhatsApp keep-alive timeout", map[string]any{
"error_count": v.ErrorCount,
"last_success": v.LastSuccess.Format(time.RFC3339),
})
case *events.ConnectFailure:
logger.ErrorCF("whatsapp", "WhatsApp connect failure", map[string]any{
"reason": v.Reason.String(),
"message": v.Message,
})
case *events.TemporaryBan:
c.loggedOut.Store(true)
logger.ErrorCF("whatsapp", "WhatsApp temporary ban — stopping reconnect", map[string]any{
"code": v.Code.String(),
"expire": v.Expire.String(),
})
case *events.ClientOutdated:
c.loggedOut.Store(true)
logger.ErrorCF("whatsapp", "WhatsApp client outdated — update whatsmeow required", nil)
case *events.Disconnected: case *events.Disconnected:
logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil) logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil)
c.reconnectMu.Lock() c.reconnectMu.Lock()
@ -303,6 +333,11 @@ func (c *WhatsAppNativeChannel) reconnectWithBackoff() {
backoff := reconnectInitial backoff := reconnectInitial
for { for {
if c.loggedOut.Load() {
logger.ErrorCF("whatsapp", "Aborting reconnect — session invalid, re-pairing required", nil)
return
}
select { select {
case <-c.runCtx.Done(): case <-c.runCtx.Done():
return return