From f7eacfd65d3c75dd093301bc1e5904c0c2114189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Novis?= Date: Fri, 31 Jul 2026 16:43:18 -0300 Subject: [PATCH] feat(presence): subscribe to a contact's presence (online/last-seen) Add POST /message/subscribe so an instance can subscribe to a contact's WhatsApp presence. whatsmeow only delivers events.Presence for JIDs we've explicitly subscribed to (and only while marked available), so the handler sends available first, then SubscribePresence(jid). Subscriptions are ephemeral, so callers re-subscribe when a chat is opened. Also enrich the events.Presence webhook with explicit top-level 'from' and 'lastSeen' (unix) fields so consumers don't depend on JID/time marshaling. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/message/handler/message_handler.go | 41 ++++++++++++++++++++++++++ pkg/message/service/message_service.go | 36 ++++++++++++++++++++++ pkg/routes/routes.go | 1 + pkg/whatsmeow/service/whatsmeow.go | 3 ++ 4 files changed, 81 insertions(+) diff --git a/pkg/message/handler/message_handler.go b/pkg/message/handler/message_handler.go index 6ba7db48..5ff4911e 100644 --- a/pkg/message/handler/message_handler.go +++ b/pkg/message/handler/message_handler.go @@ -11,6 +11,7 @@ import ( type MessageHandler interface { React(ctx *gin.Context) ChatPresence(ctx *gin.Context) + SubscribePresence(ctx *gin.Context) MarkRead(ctx *gin.Context) MarkPlayed(ctx *gin.Context) DownloadMedia(ctx *gin.Context) @@ -119,6 +120,46 @@ func (m *messageHandler) ChatPresence(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": responseData}) } +// SubscribePresence subscribe to a contact's presence (online / last-seen) +// @Summary Subscribe to a contact's presence +// @Description Subscribe to a contact's presence so the instance starts receiving Presence (online/offline/last-seen) webhook events for that number +// @Tags Message +// @Accept json +// @Produce json +// @Param message body message_service.SubscribePresenceStruct true "Number to subscribe presence for" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /message/subscribe [post] +func (m *messageHandler) SubscribePresence(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 *message_service.SubscribePresenceStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if err := m.messageService.SubscribePresence(data, instance); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success"}) +} + // MarkRead mark a message as read // @Summary Mark a message as read // @Description Mark a message as read diff --git a/pkg/message/service/message_service.go b/pkg/message/service/message_service.go index f8f13920..4011e275 100644 --- a/pkg/message/service/message_service.go +++ b/pkg/message/service/message_service.go @@ -26,6 +26,7 @@ import ( type MessageService interface { React(data *ReactStruct, instance *instance_model.Instance) (*MessageSendStruct, error) ChatPresence(data *ChatPresenceStruct, instance *instance_model.Instance) (string, error) + SubscribePresence(data *SubscribePresenceStruct, instance *instance_model.Instance) error MarkRead(data *MarkReadStruct, instance *instance_model.Instance) (string, error) MarkPlayed(data *MarkPlayedStruct, instance *instance_model.Instance) (string, error) DownloadMedia(data *DownloadMediaStruct, instance *instance_model.Instance, request *http.Request) (*dataurl.DataURL, string, error) @@ -59,6 +60,10 @@ type ChatPresenceStruct struct { Delay int `json:"delay"` } +type SubscribePresenceStruct struct { + Number string `json:"number"` +} + type MarkReadStruct struct { Id []string `json:"id"` Number string `json:"number"` @@ -306,6 +311,37 @@ func (m *messageService) ChatPresence(data *ChatPresenceStruct, instance *instan return ts.String(), nil } +// SubscribePresence subscribes to a contact's presence (online / last-seen). +// WhatsApp only delivers events.Presence for JIDs we've explicitly subscribed to, +// and only while we're marked available — so we send available first (idempotent; +// ChatPresence and the background presence loop already do this). Subscriptions are +// ephemeral (reset on reconnect), so the caller re-subscribes when a chat is opened. +func (m *messageService) SubscribePresence(data *SubscribePresenceStruct, instance *instance_model.Instance) error { + client, err := m.ensureClientConnected(instance.Id) + if err != nil { + return err + } + + recipient, ok := utils.ParseJID(data.Number) + if !ok { + m.loggerWrapper.GetLogger(instance.Id).LogError("[%s] SubscribePresence: invalid number %s", instance.Id, data.Number) + return errors.New("invalid phone number") + } + recipient = utils.CanonicalJID(recipient) + + // Must be available to receive others' presence updates (non-fatal if it fails). + if presErr := client.SendPresence(context.Background(), types.PresenceAvailable); presErr != nil { + m.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SendPresence(available) before subscribe failed (non-fatal): %v", instance.Id, presErr) + } + + if err := client.SubscribePresence(context.Background(), recipient); err != nil { + return err + } + + m.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Subscribed to presence of %s", instance.Id, data.Number) + return nil +} + func (m *messageService) MarkRead(data *MarkReadStruct, instance *instance_model.Instance) (string, error) { client, err := m.ensureClientConnected(instance.Id) if err != nil { diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 8e026a87..2a63b777 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -150,6 +150,7 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { { routes.POST("/react", r.jidValidationMiddleware.ValidateJIDFields("number"), r.messageHandler.React) routes.POST("/presence", r.jidValidationMiddleware.ValidateNumberField(), r.messageHandler.ChatPresence) + routes.POST("/subscribe", r.jidValidationMiddleware.ValidateNumberField(), r.messageHandler.SubscribePresence) routes.POST("/markread", r.jidValidationMiddleware.ValidateNumberField(), r.messageHandler.MarkRead) routes.POST("/markplayed", r.jidValidationMiddleware.ValidateNumberField(), r.messageHandler.MarkPlayed) routes.POST("/downloadmedia", r.messageHandler.DownloadMedia) diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 366f0edb..161c4bba 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -1801,12 +1801,15 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { case *events.Presence: doWebhook = true postMap["event"] = "Presence" + // Explicit top-level fields so consumers don't depend on types.JID/time marshaling. + postMap["from"] = evt.From.String() if evt.Unavailable { postMap["state"] = "offline" if evt.LastSeen.IsZero() { mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] User is now offline", mycli.userID) } else { + postMap["lastSeen"] = evt.LastSeen.Unix() mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] User is now offline since %s", mycli.userID, evt.LastSeen.Format("2006-01-02 15:04:05")) } } else {