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 78ec6c1..ce1987c 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,53 +792,59 @@ func (w whatsmeowService) StartClient(cd *ClientData) { } } -func schedulePresenceUpdates(mycli *MyClient) { +// 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 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 { + 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 + } + if err := sender.SendPresence(ctx, types.PresenceAvailable); err != nil { + return false, err + } + return false, nil +} + +func schedulePresenceUpdates(ctx context.Context, mycli *MyClient) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: - // Verificar se a instância ainda existe - _, err := mycli.instanceRepository.GetInstanceByID(mycli.userID) + shouldStop, err := handlePresenceTick(ctx, 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) + } + if shouldStop { + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Stopping presence updates (AlwaysOnline disabled or instance not found)", mycli.userID) + return + } + if err == nil { + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Marked self as available (AlwaysOnline)", mycli.userID) } - - processPresenceUpdates(mycli) - - ticker.Stop() - randomInterval := time.Duration(1+rand.Intn(3)) * time.Hour - ticker = time.NewTicker(randomInterval) 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) + return - 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) + case <-ctx.Done(): + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Context cancelled, stopping presence updates", mycli.userID) + return } } } @@ -905,7 +910,9 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postMap["data"] = dataMap - go schedulePresenceUpdates(mycli) + if mycli.Instance.AlwaysOnline { + go schedulePresenceUpdates(context.Background(), mycli) + } err := mycli.WAClient.SendPresence(context.Background(), types.PresenceAvailable) if err != nil { @@ -2604,9 +2611,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(context.Background(), 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..00ba4e3 --- /dev/null +++ b/pkg/whatsmeow/service/whatsmeow_presence_test.go @@ -0,0 +1,174 @@ +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 panic to catch accidental usage. +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) { + panic("unexpected call: Create") +} +func (m *mockInstanceRepository) GetConnectedInstanceByID(_ string) (*instance_model.Instance, error) { + panic("unexpected call: GetConnectedInstanceByID") +} +func (m *mockInstanceRepository) GetInstanceByToken(_ string) (*instance_model.Instance, error) { + panic("unexpected call: GetInstanceByToken") +} +func (m *mockInstanceRepository) GetInstanceByName(_ string) (*instance_model.Instance, error) { + panic("unexpected call: GetInstanceByName") +} +func (m *mockInstanceRepository) Update(_ *instance_model.Instance) error { + panic("unexpected call: Update") +} +func (m *mockInstanceRepository) UpdateConnected(_ string, _ bool, _ string) error { + 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) GetAllConnectedInstances() ([]*instance_model.Instance, error) { + panic("unexpected call: GetAllConnectedInstances") +} +func (m *mockInstanceRepository) GetAllConnectedInstancesByClientName(_ string) ([]*instance_model.Instance, error) { + panic("unexpected call: GetAllConnectedInstancesByClientName") +} +func (m *mockInstanceRepository) GetAdvancedSettings(_ string) (*instance_model.AdvancedSettings, error) { + panic("unexpected call: GetAdvancedSettings") +} +func (m *mockInstanceRepository) UpdateAdvancedSettings(_ string, _ *instance_model.AdvancedSettings) error { + panic("unexpected call: UpdateAdvancedSettings") +} + +// 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: instance_repository.ErrInstanceNotFound} + sender := &mockPresenceSender{} + + shouldStop, err := handlePresenceTick(context.Background(), "test-id", repo, sender) + + if !shouldStop { + t.Error("expected shouldStop=true when instance is definitively 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{} + + 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 the goroutine stays 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") + } +}