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
41 changes: 41 additions & 0 deletions pkg/message/handler/message_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions pkg/message/service/message_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions pkg/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions pkg/whatsmeow/service/whatsmeow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down