Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pkg/instance/repository/instance_repository.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package instance_repository

import (
"errors"
"fmt"

instance_model "github.com/EvolutionAPI/evolution-go/pkg/instance/model"
Expand All @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
91 changes: 53 additions & 38 deletions pkg/whatsmeow/service/whatsmeow.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"fmt"
"image/png"
"io"
"math/rand"
"net/http"
"regexp"
"strconv"
Expand Down Expand Up @@ -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)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
174 changes: 174 additions & 0 deletions pkg/whatsmeow/service/whatsmeow_presence_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}