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 {