From 1002ba427e4e8e8e7be931443ec681f93efa5560 Mon Sep 17 00:00:00 2001 From: Edilson Chaves Date: Mon, 20 Apr 2026 09:55:58 -0300 Subject: [PATCH 1/2] feat: implement AlwaysOnline presence feature - Gate schedulePresenceUpdates goroutine behind AlwaysOnline flag (previously ran unconditionally for every instance) - Refactor to extract handlePresenceTick as a pure, testable function - Add PresenceSender interface to decouple from whatsmeow.Client - Start presence goroutine dynamically when AlwaysOnline is toggled ON via the advanced-settings API without requiring reconnect - Remove processPresenceUpdates (random unavailable/available cycle) - Add unit tests covering all 4 tick scenarios --- pkg/whatsmeow/service/whatsmeow.go | 77 +++++----- .../service/whatsmeow_presence_test.go | 145 ++++++++++++++++++ 2 files changed, 184 insertions(+), 38 deletions(-) create mode 100644 pkg/whatsmeow/service/whatsmeow_presence_test.go diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 78ec6c1..df394dc 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -10,7 +10,6 @@ import ( "fmt" "image/png" "io" - "math/rand" "net/http" "regexp" "strconv" @@ -793,6 +792,25 @@ func (w whatsmeowService) StartClient(cd *ClientData) { } } +// PresenceSender abstracts the WhatsApp client method used to update online presence. +type PresenceSender interface { + SendPresence(ctx context.Context, p types.Presence) error +} + +// handlePresenceTick fetches the instance state and sends PresenceAvailable when AlwaysOnline +// is enabled. Returns shouldStop=true when the goroutine should exit (instance gone or flag off). +func handlePresenceTick(ctx context.Context, userID string, repo instance_repository.InstanceRepository, sender PresenceSender) (shouldStop bool, err error) { + instance, err := repo.GetInstanceByID(userID) + if err != nil { + return true, err + } + if !instance.AlwaysOnline { + return true, nil + } + err = sender.SendPresence(ctx, types.PresenceAvailable) + return false, err +} + func schedulePresenceUpdates(mycli *MyClient) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() @@ -800,46 +818,19 @@ func schedulePresenceUpdates(mycli *MyClient) { for { select { case <-ticker.C: - // Verificar se a instância ainda existe - _, err := mycli.instanceRepository.GetInstanceByID(mycli.userID) + shouldStop, err := handlePresenceTick(context.Background(), mycli.userID, mycli.instanceRepository, mycli.WAClient) if err != nil { - mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Instance no longer exists, stopping presence updates", mycli.userID) - return // Encerra a goroutine se a instância não existir mais + mycli.loggerWrapper.GetLogger(mycli.userID).LogError("[%s] Presence update error: %v", mycli.userID, err) } - - processPresenceUpdates(mycli) - - ticker.Stop() - randomInterval := time.Duration(1+rand.Intn(3)) * time.Hour - ticker = time.NewTicker(randomInterval) + if shouldStop { + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Stopping presence updates (AlwaysOnline disabled or instance not found)", mycli.userID) + return + } + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Marked self as available (AlwaysOnline)", mycli.userID) case <-mycli.killChannel[mycli.userID]: mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Received kill signal, stopping presence updates", mycli.userID) - return // Encerra a goroutine quando receber sinal de kill - } - } -} - -func processPresenceUpdates(mycli *MyClient) { - now := time.Now() - location, _ := time.LoadLocation("America/Sao_Paulo") - nowSp := now.In(location) - - if nowSp.Hour() >= 1 && nowSp.Hour() < 24 { - err := mycli.WAClient.SendPresence(context.Background(), types.PresenceUnavailable) - if err != nil { - mycli.loggerWrapper.GetLogger(mycli.userID).LogError("[%s] Failed to set presence as unavailable %v", mycli.userID, err) - } else { - mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Marked self as unavailable", mycli.userID) - } - - time.Sleep(time.Duration(1+rand.Intn(5)) * time.Second) - - err = mycli.WAClient.SendPresence(context.Background(), types.PresenceAvailable) - if err != nil { - mycli.loggerWrapper.GetLogger(mycli.userID).LogError("[%s] Failed to set presence as available %v", mycli.userID, err) - } else { - mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Marked self as available", mycli.userID) + return } } } @@ -905,7 +896,9 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postMap["data"] = dataMap - go schedulePresenceUpdates(mycli) + if mycli.Instance.AlwaysOnline { + go schedulePresenceUpdates(mycli) + } err := mycli.WAClient.SendPresence(context.Background(), types.PresenceAvailable) if err != nil { @@ -2604,9 +2597,17 @@ func (w whatsmeowService) UpdateInstanceAdvancedSettings(instanceId string) erro return fmt.Errorf("instance %s not found in runtime", instanceId) } - // Atualiza a instância no MyClient com as advanced settings atualizadas + // Se AlwaysOnline foi recém ativado e o cliente está conectado, inicia a goroutine de presença + wasAlwaysOnline := myClient.Instance.AlwaysOnline myClient.Instance = instance + if !wasAlwaysOnline && instance.AlwaysOnline { + if myClient.WAClient != nil && myClient.WAClient.IsConnected() { + go schedulePresenceUpdates(myClient) + w.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] AlwaysOnline enabled, started presence updates goroutine", instanceId) + } + } + w.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Advanced settings updated in runtime successfully", instanceId) return nil } diff --git a/pkg/whatsmeow/service/whatsmeow_presence_test.go b/pkg/whatsmeow/service/whatsmeow_presence_test.go new file mode 100644 index 0000000..a8d8d86 --- /dev/null +++ b/pkg/whatsmeow/service/whatsmeow_presence_test.go @@ -0,0 +1,145 @@ +package whatsmeow_service + +import ( + "context" + "errors" + "testing" + + instance_model "github.com/EvolutionAPI/evolution-go/pkg/instance/model" + instance_repository "github.com/EvolutionAPI/evolution-go/pkg/instance/repository" + "go.mau.fi/whatsmeow/types" +) + +// mockInstanceRepository satisfies instance_repository.InstanceRepository for tests. +// Only GetInstanceByID is exercised; all other methods are no-ops. +type mockInstanceRepository struct { + instance *instance_model.Instance + err error +} + +func (m *mockInstanceRepository) GetInstanceByID(_ string) (*instance_model.Instance, error) { + return m.instance, m.err +} +func (m *mockInstanceRepository) Create(_ instance_model.Instance) (*instance_model.Instance, error) { + return nil, nil +} +func (m *mockInstanceRepository) GetConnectedInstanceByID(_ string) (*instance_model.Instance, error) { + return nil, nil +} +func (m *mockInstanceRepository) GetInstanceByToken(_ string) (*instance_model.Instance, error) { + return nil, nil +} +func (m *mockInstanceRepository) GetInstanceByName(_ string) (*instance_model.Instance, error) { + return nil, nil +} +func (m *mockInstanceRepository) Update(_ *instance_model.Instance) error { return nil } +func (m *mockInstanceRepository) UpdateConnected(_ string, _ bool, _ string) error { + return nil +} +func (m *mockInstanceRepository) UpdateQrcode(_ string, _ string) error { return nil } +func (m *mockInstanceRepository) UpdateProxy(_ string, _ string) error { return nil } +func (m *mockInstanceRepository) UpdateJid(_ string, _ string) error { return nil } +func (m *mockInstanceRepository) Delete(_ string) error { return nil } +func (m *mockInstanceRepository) GetAll(_ string) ([]*instance_model.Instance, error) { return nil, nil } +func (m *mockInstanceRepository) GetAllConnectedInstances() ([]*instance_model.Instance, error) { + return nil, nil +} +func (m *mockInstanceRepository) GetAllConnectedInstancesByClientName(_ string) ([]*instance_model.Instance, error) { + return nil, nil +} +func (m *mockInstanceRepository) GetAdvancedSettings(_ string) (*instance_model.AdvancedSettings, error) { + return nil, nil +} +func (m *mockInstanceRepository) UpdateAdvancedSettings(_ string, _ *instance_model.AdvancedSettings) error { + return nil +} + +// Compile-time check that the mock fully satisfies the interface. +var _ instance_repository.InstanceRepository = (*mockInstanceRepository)(nil) + +// mockPresenceSender records calls to SendPresence. +type mockPresenceSender struct { + called bool + lastType types.Presence + err error +} + +func (m *mockPresenceSender) SendPresence(_ context.Context, p types.Presence) error { + m.called = true + m.lastType = p + return m.err +} + +// --- tests --- + +func TestHandlePresenceTick_InstanceNotFound(t *testing.T) { + repo := &mockInstanceRepository{err: errors.New("record not found")} + sender := &mockPresenceSender{} + + shouldStop, err := handlePresenceTick(context.Background(), "test-id", repo, sender) + + if !shouldStop { + t.Error("expected shouldStop=true when instance is not found in DB") + } + if err == nil { + t.Error("expected a non-nil error when instance is not found") + } + if sender.called { + t.Error("SendPresence must not be called when instance is not found") + } +} + +func TestHandlePresenceTick_AlwaysOnlineFalse(t *testing.T) { + repo := &mockInstanceRepository{instance: &instance_model.Instance{AlwaysOnline: false}} + sender := &mockPresenceSender{} + + shouldStop, err := handlePresenceTick(context.Background(), "test-id", repo, sender) + + if !shouldStop { + t.Error("expected shouldStop=true when AlwaysOnline=false") + } + if err != nil { + t.Errorf("expected no error when AlwaysOnline=false, got: %v", err) + } + if sender.called { + t.Error("SendPresence must not be called when AlwaysOnline=false") + } +} + +func TestHandlePresenceTick_AlwaysOnlineTrue_Success(t *testing.T) { + repo := &mockInstanceRepository{instance: &instance_model.Instance{AlwaysOnline: true}} + sender := &mockPresenceSender{} + + shouldStop, err := handlePresenceTick(context.Background(), "test-id", repo, sender) + + if shouldStop { + t.Error("expected shouldStop=false when AlwaysOnline=true and SendPresence succeeds") + } + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + if !sender.called { + t.Error("SendPresence must be called when AlwaysOnline=true") + } + if sender.lastType != types.PresenceAvailable { + t.Errorf("expected PresenceAvailable, got: %v", sender.lastType) + } +} + +func TestHandlePresenceTick_AlwaysOnlineTrue_SendPresenceFails(t *testing.T) { + repo := &mockInstanceRepository{instance: &instance_model.Instance{AlwaysOnline: true}} + sender := &mockPresenceSender{err: errors.New("whatsapp unavailable")} + + shouldStop, err := handlePresenceTick(context.Background(), "test-id", repo, sender) + + // Even when SendPresence fails we keep the goroutine alive to retry next tick. + if shouldStop { + t.Error("expected shouldStop=false even when SendPresence fails (will retry next tick)") + } + if err == nil { + t.Error("expected error when SendPresence fails") + } + if !sender.called { + t.Error("SendPresence must have been attempted") + } +} From bf24078b858e524684a34ecaf2d47ebd5f8b85cc Mon Sep 17 00:00:00 2001 From: Edilson Chaves Date: Mon, 20 Apr 2026 10:46:35 -0300 Subject: [PATCH 2/2] refactor(presence): address code review feedback on AlwaysOnline feature - handlePresenceTick: transient DB/network errors no longer kill the goroutine permanently; only ErrInstanceNotFound (record gone) triggers shouldStop=true so AlwaysOnline survives temporary backend issues - instance_repository: add ErrInstanceNotFound sentinel and wrap gorm.ErrRecordNotFound so callers can distinguish not-found from transient failures without importing gorm directly - schedulePresenceUpdates: accepts context.Context so callers can wire a cancellable/service-level context; adds ctx.Done() select case as an additional shutdown path alongside killChannel - test mocks: unimplemented repository methods now panic instead of silently returning zero values, making accidental usage detectable; added TestHandlePresenceTick_TransientRepoError to cover the new retry-on-transient-error behavior Co-Authored-By: Claude Sonnet 4.6 --- .../repository/instance_repository.go | 7 ++ pkg/whatsmeow/service/whatsmeow.go | 32 ++++++--- .../service/whatsmeow_presence_test.go | 71 +++++++++++++------ 3 files changed, 80 insertions(+), 30 deletions(-) diff --git a/pkg/instance/repository/instance_repository.go b/pkg/instance/repository/instance_repository.go index cfb60b4..2520deb 100644 --- a/pkg/instance/repository/instance_repository.go +++ b/pkg/instance/repository/instance_repository.go @@ -1,6 +1,7 @@ package instance_repository import ( + "errors" "fmt" instance_model "github.com/EvolutionAPI/evolution-go/pkg/instance/model" @@ -15,6 +16,9 @@ import ( message_repository "github.com/EvolutionAPI/evolution-go/pkg/message/repository" ) +// ErrInstanceNotFound is returned by GetInstanceByID when no record matches the given ID. +var ErrInstanceNotFound = errors.New("instance not found") + type InstanceRepository interface { Create(instance instance_model.Instance) (*instance_model.Instance, error) GetInstanceByID(instanceId string) (*instance_model.Instance, error) @@ -76,6 +80,9 @@ func (i *instanceRepository) GetInstanceByID(instanceId string) (*instance_model var instance instance_model.Instance err := i.db.Where("id = ?", instanceId).First(&instance).Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrInstanceNotFound + } return nil, err } diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index df394dc..ce1987c 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -798,27 +798,35 @@ type PresenceSender interface { } // handlePresenceTick fetches the instance state and sends PresenceAvailable when AlwaysOnline -// is enabled. Returns shouldStop=true when the goroutine should exit (instance gone or flag off). +// is enabled. Returns shouldStop=true when the goroutine should exit (instance definitively gone +// or flag disabled). Transient DB/network errors return shouldStop=false so the goroutine retries +// on the next tick instead of dying permanently. func handlePresenceTick(ctx context.Context, userID string, repo instance_repository.InstanceRepository, sender PresenceSender) (shouldStop bool, err error) { instance, err := repo.GetInstanceByID(userID) if err != nil { - return true, err + if errors.Is(err, instance_repository.ErrInstanceNotFound) { + return true, nil + } + // Transient error — log at call site and retry next tick. + return false, err } if !instance.AlwaysOnline { return true, nil } - err = sender.SendPresence(ctx, types.PresenceAvailable) - return false, err + if err := sender.SendPresence(ctx, types.PresenceAvailable); err != nil { + return false, err + } + return false, nil } -func schedulePresenceUpdates(mycli *MyClient) { +func schedulePresenceUpdates(ctx context.Context, mycli *MyClient) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: - shouldStop, err := handlePresenceTick(context.Background(), mycli.userID, mycli.instanceRepository, mycli.WAClient) + shouldStop, err := handlePresenceTick(ctx, mycli.userID, mycli.instanceRepository, mycli.WAClient) if err != nil { mycli.loggerWrapper.GetLogger(mycli.userID).LogError("[%s] Presence update error: %v", mycli.userID, err) } @@ -826,11 +834,17 @@ func schedulePresenceUpdates(mycli *MyClient) { mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Stopping presence updates (AlwaysOnline disabled or instance not found)", mycli.userID) return } - mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Marked self as available (AlwaysOnline)", mycli.userID) + if err == nil { + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Marked self as available (AlwaysOnline)", mycli.userID) + } case <-mycli.killChannel[mycli.userID]: mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Received kill signal, stopping presence updates", mycli.userID) return + + case <-ctx.Done(): + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Context cancelled, stopping presence updates", mycli.userID) + return } } } @@ -897,7 +911,7 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postMap["data"] = dataMap if mycli.Instance.AlwaysOnline { - go schedulePresenceUpdates(mycli) + go schedulePresenceUpdates(context.Background(), mycli) } err := mycli.WAClient.SendPresence(context.Background(), types.PresenceAvailable) @@ -2603,7 +2617,7 @@ func (w whatsmeowService) UpdateInstanceAdvancedSettings(instanceId string) erro if !wasAlwaysOnline && instance.AlwaysOnline { if myClient.WAClient != nil && myClient.WAClient.IsConnected() { - go schedulePresenceUpdates(myClient) + go schedulePresenceUpdates(context.Background(), myClient) w.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] AlwaysOnline enabled, started presence updates goroutine", instanceId) } } diff --git a/pkg/whatsmeow/service/whatsmeow_presence_test.go b/pkg/whatsmeow/service/whatsmeow_presence_test.go index a8d8d86..00ba4e3 100644 --- a/pkg/whatsmeow/service/whatsmeow_presence_test.go +++ b/pkg/whatsmeow/service/whatsmeow_presence_test.go @@ -11,7 +11,7 @@ import ( ) // mockInstanceRepository satisfies instance_repository.InstanceRepository for tests. -// Only GetInstanceByID is exercised; all other methods are no-ops. +// Only GetInstanceByID is exercised; all other methods panic to catch accidental usage. type mockInstanceRepository struct { instance *instance_model.Instance err error @@ -21,37 +21,49 @@ func (m *mockInstanceRepository) GetInstanceByID(_ string) (*instance_model.Inst return m.instance, m.err } func (m *mockInstanceRepository) Create(_ instance_model.Instance) (*instance_model.Instance, error) { - return nil, nil + panic("unexpected call: Create") } func (m *mockInstanceRepository) GetConnectedInstanceByID(_ string) (*instance_model.Instance, error) { - return nil, nil + panic("unexpected call: GetConnectedInstanceByID") } func (m *mockInstanceRepository) GetInstanceByToken(_ string) (*instance_model.Instance, error) { - return nil, nil + panic("unexpected call: GetInstanceByToken") } func (m *mockInstanceRepository) GetInstanceByName(_ string) (*instance_model.Instance, error) { - return nil, nil + panic("unexpected call: GetInstanceByName") +} +func (m *mockInstanceRepository) Update(_ *instance_model.Instance) error { + panic("unexpected call: Update") } -func (m *mockInstanceRepository) Update(_ *instance_model.Instance) error { return nil } func (m *mockInstanceRepository) UpdateConnected(_ string, _ bool, _ string) error { - return nil + panic("unexpected call: UpdateConnected") +} +func (m *mockInstanceRepository) UpdateQrcode(_ string, _ string) error { + panic("unexpected call: UpdateQrcode") +} +func (m *mockInstanceRepository) UpdateProxy(_ string, _ string) error { + panic("unexpected call: UpdateProxy") +} +func (m *mockInstanceRepository) UpdateJid(_ string, _ string) error { + panic("unexpected call: UpdateJid") +} +func (m *mockInstanceRepository) Delete(_ string) error { + panic("unexpected call: Delete") +} +func (m *mockInstanceRepository) GetAll(_ string) ([]*instance_model.Instance, error) { + panic("unexpected call: GetAll") } -func (m *mockInstanceRepository) UpdateQrcode(_ string, _ string) error { return nil } -func (m *mockInstanceRepository) UpdateProxy(_ string, _ string) error { return nil } -func (m *mockInstanceRepository) UpdateJid(_ string, _ string) error { return nil } -func (m *mockInstanceRepository) Delete(_ string) error { return nil } -func (m *mockInstanceRepository) GetAll(_ string) ([]*instance_model.Instance, error) { return nil, nil } func (m *mockInstanceRepository) GetAllConnectedInstances() ([]*instance_model.Instance, error) { - return nil, nil + panic("unexpected call: GetAllConnectedInstances") } func (m *mockInstanceRepository) GetAllConnectedInstancesByClientName(_ string) ([]*instance_model.Instance, error) { - return nil, nil + panic("unexpected call: GetAllConnectedInstancesByClientName") } func (m *mockInstanceRepository) GetAdvancedSettings(_ string) (*instance_model.AdvancedSettings, error) { - return nil, nil + panic("unexpected call: GetAdvancedSettings") } func (m *mockInstanceRepository) UpdateAdvancedSettings(_ string, _ *instance_model.AdvancedSettings) error { - return nil + panic("unexpected call: UpdateAdvancedSettings") } // Compile-time check that the mock fully satisfies the interface. @@ -73,22 +85,39 @@ func (m *mockPresenceSender) SendPresence(_ context.Context, p types.Presence) e // --- tests --- func TestHandlePresenceTick_InstanceNotFound(t *testing.T) { - repo := &mockInstanceRepository{err: errors.New("record not found")} + repo := &mockInstanceRepository{err: instance_repository.ErrInstanceNotFound} sender := &mockPresenceSender{} shouldStop, err := handlePresenceTick(context.Background(), "test-id", repo, sender) if !shouldStop { - t.Error("expected shouldStop=true when instance is not found in DB") + t.Error("expected shouldStop=true when instance is definitively not found") } - if err == nil { - t.Error("expected a non-nil error when instance is not found") + if err != nil { + t.Errorf("expected no error for ErrInstanceNotFound (handled gracefully), got: %v", err) } if sender.called { t.Error("SendPresence must not be called when instance is not found") } } +func TestHandlePresenceTick_TransientRepoError(t *testing.T) { + repo := &mockInstanceRepository{err: errors.New("connection refused")} + sender := &mockPresenceSender{} + + shouldStop, err := handlePresenceTick(context.Background(), "test-id", repo, sender) + + if shouldStop { + t.Error("expected shouldStop=false for transient DB error (goroutine should retry)") + } + if err == nil { + t.Error("expected error to be surfaced for transient failures") + } + if sender.called { + t.Error("SendPresence must not be called when repo errors") + } +} + func TestHandlePresenceTick_AlwaysOnlineFalse(t *testing.T) { repo := &mockInstanceRepository{instance: &instance_model.Instance{AlwaysOnline: false}} sender := &mockPresenceSender{} @@ -132,7 +161,7 @@ func TestHandlePresenceTick_AlwaysOnlineTrue_SendPresenceFails(t *testing.T) { shouldStop, err := handlePresenceTick(context.Background(), "test-id", repo, sender) - // Even when SendPresence fails we keep the goroutine alive to retry next tick. + // Even when SendPresence fails the goroutine stays alive to retry next tick. if shouldStop { t.Error("expected shouldStop=false even when SendPresence fails (will retry next tick)") }