From becce1e5d2d93ece559e84096c5155c6f9f4c8f5 Mon Sep 17 00:00:00 2001 From: Bruno Beise Date: Mon, 13 Apr 2026 19:38:48 -0300 Subject: [PATCH 1/5] fix(message): use ExtendedTextMessage on EditMessage WhatsApp silently ignores edits when the message type differs from the original. SendText sends as ExtendedTextMessage, so edits must match that type instead of Conversation. This patch matches the fix already deployed in production via SSH on 2026-04-07. --- pkg/message/service/message_service.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/message/service/message_service.go b/pkg/message/service/message_service.go index 36ffb68..ddef08c 100644 --- a/pkg/message/service/message_service.go +++ b/pkg/message/service/message_service.go @@ -406,6 +406,9 @@ func (m *messageService) EditMessage(data *EditMessageStruct, instance *instance return "", "", errors.New("invalid phone number") } + // IMOBDEAL PATCH: usar ExtendedTextMessage em vez de Conversation. + // SendText envia como ExtendedTextMessage; o WhatsApp ignora silenciosamente + // edições quando o tipo difere do original. resp, err := client.SendMessage( context.Background(), recipient, @@ -413,7 +416,9 @@ func (m *messageService) EditMessage(data *EditMessageStruct, instance *instance recipient, data.MessageID, &waE2E.Message{ - Conversation: proto.String(data.Message), + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: &data.Message, + }, })) if err != nil { m.loggerWrapper.GetLogger(instance.Id).LogError("[%s] error revoking message: %v", instance.Id, err) From 8184c58252d236bdcf339b549947eb8a7082b253 Mon Sep 17 00:00:00 2001 From: Bruno Beise Date: Mon, 13 Apr 2026 19:45:27 -0300 Subject: [PATCH 2/5] feat(instance): expose SendPresence via POST /instance/presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new authenticated endpoint to expose whatsmeow's SendPresence function so the client can be marked as available/unavailable without disconnecting. This mirrors WhatsApp Web's behavior when minimized — the linked device stays connected but the phone resumes receiving push notifications because WhatsApp server sees the device as inactive. Use case: ImobDeal marks the instance unavailable when the user leaves the WhatsApp screen, and available when they return. Enables brokers on iPhone to receive normal push notifications without having to manually unlink the device. - POST /instance/presence with {"state": "available" | "unavailable"} - Returns 400 on invalid state, 500 on internal error - Requires the standard instance auth (apikey header) --- pkg/instance/handler/instance_handler.go | 37 ++++++++++++++++++++++ pkg/instance/service/instance_service.go | 40 ++++++++++++++++++++++++ pkg/routes/routes.go | 1 + 3 files changed, 78 insertions(+) diff --git a/pkg/instance/handler/instance_handler.go b/pkg/instance/handler/instance_handler.go index ebf8406..1d9ad27 100644 --- a/pkg/instance/handler/instance_handler.go +++ b/pkg/instance/handler/instance_handler.go @@ -16,6 +16,7 @@ type InstanceHandler interface { Connect(ctx *gin.Context) Reconnect(ctx *gin.Context) Disconnect(ctx *gin.Context) + SetPresence(ctx *gin.Context) Logout(ctx *gin.Context) Delete(ctx *gin.Context) Status(ctx *gin.Context) @@ -205,6 +206,42 @@ func (i *instanceHandler) Disconnect(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"message": "success"}) } +// IMOBDEAL PATCH: SetPresence endpoint +// @Summary Set instance presence (available/unavailable) +// @Description Marks the instance as available or unavailable WITHOUT disconnecting. +// @Description Mimics WhatsApp Web's behavior when minimized — the phone resumes +// @Description receiving push notifications while the linked device stays connected. +// @Tags Instance +// @Accept json +// @Produce json +// @Param body body instance_service.SetPresenceStruct true "State: available or unavailable" +// @Success 200 {object} gin.H "Presence updated" +// @Failure 400 {object} gin.H "Invalid state" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /instance/presence [post] +func (i *instanceHandler) SetPresence(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data instance_service.SetPresenceStruct + if err := ctx.ShouldBindJSON(&data); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := i.instanceService.SetPresence(&data, instance); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "state": data.State}) +} + // Logout from instance // @Summary Logout from instance // @Description Logout from instance diff --git a/pkg/instance/service/instance_service.go b/pkg/instance/service/instance_service.go index 32d831e..6103700 100644 --- a/pkg/instance/service/instance_service.go +++ b/pkg/instance/service/instance_service.go @@ -28,6 +28,7 @@ type InstanceService interface { Connect(data *ConnectStruct, instance *instance_model.Instance) (*instance_model.Instance, string, string, error) Reconnect(instance *instance_model.Instance) error Disconnect(instance *instance_model.Instance) (*instance_model.Instance, error) + SetPresence(data *SetPresenceStruct, instance *instance_model.Instance) error Logout(instance *instance_model.Instance) (*instance_model.Instance, error) Status(instance *instance_model.Instance) (*StatusStruct, error) GetQr(instance *instance_model.Instance) (*QrcodeStruct, error) @@ -79,6 +80,12 @@ type ConnectStruct struct { NatsEnable string `json:"natsEnable"` } +// IMOBDEAL PATCH: struct para a rota POST /instance/presence +// state deve ser "available" ou "unavailable" +type SetPresenceStruct struct { + State string `json:"state" binding:"required"` +} + type StatusStruct struct { Connected bool LoggedIn bool @@ -319,6 +326,39 @@ func (i instances) Disconnect(instance *instance_model.Instance) (*instance_mode return instance, nil } +// IMOBDEAL PATCH: SetPresence expõe SendPresence do whatsmeow como endpoint HTTP. +// Permite marcar o cliente como "available" (ativo, intercepta push do celular) ou +// "unavailable" (standby, celular volta a receber push) sem desconectar a sessão. +// Idêntico ao comportamento do WhatsApp Web quando minimizado/em background. +func (i instances) SetPresence(data *SetPresenceStruct, instance *instance_model.Instance) error { + client, err := i.ensureClientConnected(instance.Id) + if err != nil { + return err + } + + if !client.IsConnected() || !client.IsLoggedIn() { + return errors.New("client is not connected or not logged in") + } + + var state types.Presence + switch data.State { + case "available": + state = types.PresenceAvailable + case "unavailable": + state = types.PresenceUnavailable + default: + return errors.New("invalid state, must be 'available' or 'unavailable'") + } + + if err := client.SendPresence(context.Background(), state); err != nil { + i.loggerWrapper.GetLogger(instance.Id).LogError("[%s] SetPresence failed: %v", instance.Id, err) + return err + } + + i.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Presence set to %s", instance.Id, data.State) + return nil +} + func (i instances) Logout(instance *instance_model.Instance) (*instance_model.Instance, error) { client, err := i.ensureClientConnected(instance.Id) if err != nil { diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 0064014..5e6483f 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -102,6 +102,7 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { routes.POST("/pair", r.jidValidationMiddleware.ValidateNumberField(), r.instanceHandler.Pair) routes.POST("/disconnect", r.instanceHandler.Disconnect) routes.POST("/reconnect", r.instanceHandler.Reconnect) + routes.POST("/presence", r.instanceHandler.SetPresence) // IMOBDEAL PATCH routes.DELETE("/logout", r.instanceHandler.Logout) routes.GET("/:instanceId/advanced-settings", r.instanceHandler.GetAdvancedSettings) routes.PUT("/:instanceId/advanced-settings", r.instanceHandler.UpdateAdvancedSettings) From cc90c2f639dbc07b0d886945f1609ce782533041 Mon Sep 17 00:00:00 2001 From: Bruno Beise Date: Mon, 13 Apr 2026 20:06:28 -0300 Subject: [PATCH 3/5] review: apply Sourcery suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SetPresence: validate state in HTTP handler so invalid input returns 400 instead of bubbling up to 500. Service layer now trusts its input and any error it emits is treated as 5xx. - SetPresence: accept a request-scoped context.Context parameter so cancellations/timeouts from the HTTP layer propagate down to whatsmeow's SendPresence call, instead of using context.Background(). - EditMessage: fix misleading log message — was 'error revoking message', now reads 'error editing message'. --- pkg/instance/handler/instance_handler.go | 8 +++++++- pkg/instance/service/instance_service.go | 11 +++++++---- pkg/message/service/message_service.go | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/instance/handler/instance_handler.go b/pkg/instance/handler/instance_handler.go index 1d9ad27..fe4a7ed 100644 --- a/pkg/instance/handler/instance_handler.go +++ b/pkg/instance/handler/instance_handler.go @@ -234,7 +234,13 @@ func (i *instanceHandler) SetPresence(ctx *gin.Context) { return } - if err := i.instanceService.SetPresence(&data, instance); err != nil { + // Validate input here so misuse returns 4xx and service errors remain 5xx + if data.State != "available" && data.State != "unavailable" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid state, must be 'available' or 'unavailable'"}) + return + } + + if err := i.instanceService.SetPresence(ctx.Request.Context(), &data, instance); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/pkg/instance/service/instance_service.go b/pkg/instance/service/instance_service.go index 6103700..28ec7d4 100644 --- a/pkg/instance/service/instance_service.go +++ b/pkg/instance/service/instance_service.go @@ -28,7 +28,7 @@ type InstanceService interface { Connect(data *ConnectStruct, instance *instance_model.Instance) (*instance_model.Instance, string, string, error) Reconnect(instance *instance_model.Instance) error Disconnect(instance *instance_model.Instance) (*instance_model.Instance, error) - SetPresence(data *SetPresenceStruct, instance *instance_model.Instance) error + SetPresence(ctx context.Context, data *SetPresenceStruct, instance *instance_model.Instance) error Logout(instance *instance_model.Instance) (*instance_model.Instance, error) Status(instance *instance_model.Instance) (*StatusStruct, error) GetQr(instance *instance_model.Instance) (*QrcodeStruct, error) @@ -330,7 +330,9 @@ func (i instances) Disconnect(instance *instance_model.Instance) (*instance_mode // Permite marcar o cliente como "available" (ativo, intercepta push do celular) ou // "unavailable" (standby, celular volta a receber push) sem desconectar a sessão. // Idêntico ao comportamento do WhatsApp Web quando minimizado/em background. -func (i instances) SetPresence(data *SetPresenceStruct, instance *instance_model.Instance) error { +// A validação do campo State acontece no handler HTTP (retorna 400 em caso de valor inválido); +// qualquer erro que chega aqui é considerado 5xx. +func (i instances) SetPresence(ctx context.Context, data *SetPresenceStruct, instance *instance_model.Instance) error { client, err := i.ensureClientConnected(instance.Id) if err != nil { return err @@ -347,10 +349,11 @@ func (i instances) SetPresence(data *SetPresenceStruct, instance *instance_model case "unavailable": state = types.PresenceUnavailable default: - return errors.New("invalid state, must be 'available' or 'unavailable'") + // Should never reach here because handler validates first, but keep as safety net. + return errors.New("invalid state") } - if err := client.SendPresence(context.Background(), state); err != nil { + if err := client.SendPresence(ctx, state); err != nil { i.loggerWrapper.GetLogger(instance.Id).LogError("[%s] SetPresence failed: %v", instance.Id, err) return err } diff --git a/pkg/message/service/message_service.go b/pkg/message/service/message_service.go index ddef08c..89c7406 100644 --- a/pkg/message/service/message_service.go +++ b/pkg/message/service/message_service.go @@ -421,7 +421,7 @@ func (m *messageService) EditMessage(data *EditMessageStruct, instance *instance }, })) if err != nil { - m.loggerWrapper.GetLogger(instance.Id).LogError("[%s] error revoking message: %v", instance.Id, err) + m.loggerWrapper.GetLogger(instance.Id).LogError("[%s] error editing message: %v", instance.Id, err) return "", "", err } From 736d9e4790a4310b14ea528a1b4782f456cac4fd Mon Sep 17 00:00:00 2001 From: Bruno Beise Date: Mon, 13 Apr 2026 20:52:10 -0300 Subject: [PATCH 4/5] chore: gitignore docker-compose.yml and init-db.sql for local dev These files are used by developers to run Evolution Go locally with docker-compose without touching the Dockerfile or affecting the upstream repo. Keep them out of the fork so they don't conflict with a potential future upstream docker-compose setup. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 03ad15e..10d2782 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ coverage.* .idea/ .vscode/ .DS_Store + +# ImobDeal — arquivos locais pra rodar o Evolution Go no PC +docker-compose.yml +init-db.sql From 91a4d6733d0ab9d0f266479062409a78f3efca15 Mon Sep 17 00:00:00 2001 From: brunobeise Date: Tue, 14 Apr 2026 10:30:45 -0300 Subject: [PATCH 5/5] chore(gitignore): add deploy_ssh.py, check_deploy.py, and *.local.py to ignored files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 10d2782..321e300 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ coverage.* # ImobDeal — arquivos locais pra rodar o Evolution Go no PC docker-compose.yml init-db.sql +deploy_ssh.py +check_deploy.py +*.local.py