From 82d2ca30767bb97467835eac5fcf495390c447cc Mon Sep 17 00:00:00 2001 From: Flow-Mind-Company Date: Fri, 26 Jun 2026 12:54:19 +0300 Subject: [PATCH 1/3] feat: add /user/presence/subscribe and forward last_seen in Presence webhook The Presence event already carries the contact's LastSeen, but only the state (online/offline) was forwarded to the webhook. This adds "from" and, when the contact is offline and shares it, a "last_seen" unix timestamp to the Presence webhook payload. Also adds POST /user/presence/subscribe which calls whatsmeow's SubscribePresence(jid), so clients can opt in to receive Presence events for a specific contact (whatsmeow only emits them after subscribing). Docs added to API.md. --- API.md | 31 ++++++++++++++++++++++++++++ handlers.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++ routes.go | 1 + wmiau.go | 2 ++ 4 files changed, 92 insertions(+) diff --git a/API.md b/API.md index c6a2216d..47478557 100644 --- a/API.md +++ b/API.md @@ -1007,6 +1007,37 @@ curl -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{" --- +## Subscribe to Contact Presence + +Subscribes to a contact's presence updates (online/offline and last seen). After +subscribing, your configured webhook receives `Presence` events for that contact. You +should be online yourself to receive presence (wuzapi sends an available presence on +connect). Whether `last_seen` is available depends on the contact's privacy settings. + +endpoint: _/user/presence/subscribe_ + +method: **POST** + +``` +curl -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{"Phone":"5491155554444"}' http://localhost:8080/user/presence/subscribe +``` + +The resulting webhook `Presence` event looks like: + +```json +{ + "type": "Presence", + "state": "offline", + "from": "5491155554444@s.whatsapp.net", + "last_seen": 1750000000 +} +``` + +`last_seen` is a Unix timestamp, present only when the contact is offline and shares +their last seen. When the contact is online, only `type`, `from` and `state` are sent. + +--- + ## Mark message(s) as read Indicates that one or more messages were read. Id is an array of messages Ids. diff --git a/handlers.go b/handlers.go index ee3c5d3e..dbb31af2 100644 --- a/handlers.go +++ b/handlers.go @@ -3433,6 +3433,64 @@ func (s *server) SendPresence() http.HandlerFunc { } } +// Subscribes to a contact's presence (online/offline + last seen). +// After subscribing, the configured webhook receives "Presence" events for that +// contact (with state and, when offline, a last_seen unix timestamp). Requires the +// session to be available/online (wuzapi sends available presence on connect). +func (s *server) SubscribePresence() http.HandlerFunc { + + type subscribePresenceStruct struct { + Phone string + } + + return func(w http.ResponseWriter, r *http.Request) { + + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + if clientManager.GetWhatsmeowClient(txtid) == nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New("no session")) + return + } + + decoder := json.NewDecoder(r.Body) + var t subscribePresenceStruct + err := decoder.Decode(&t) + if err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode Payload")) + return + } + + if len(t.Phone) < 1 { + s.Respond(w, r, http.StatusBadRequest, errors.New("missing Phone in Payload")) + return + } + + jid, ok := parseJID(t.Phone) + if !ok { + s.Respond(w, r, http.StatusBadRequest, errors.New("could not parse Phone")) + return + } + + err = clientManager.GetWhatsmeowClient(txtid).SubscribePresence(context.Background(), jid) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New("failure subscribing to presence")) + return + } + + log.Info().Str("jid", jid.String()).Msg("Subscribed to presence") + + response := map[string]interface{}{"Details": "Presence subscription requested successfuly"} + responseJson, err := json.Marshal(response) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, err) + } else { + s.Respond(w, r, http.StatusOK, string(responseJson)) + } + return + + } +} + // Gets avatar info for user func (s *server) GetAvatar() http.HandlerFunc { diff --git a/routes.go b/routes.go index 21b40251..ddb53655 100644 --- a/routes.go +++ b/routes.go @@ -127,6 +127,7 @@ func (s *server) routes() { s.router.Handle("/call/reject", c.Then(s.RejectCall())).Methods("POST") s.router.Handle("/user/presence", c.Then(s.SendPresence())).Methods("POST") + s.router.Handle("/user/presence/subscribe", c.Then(s.SubscribePresence())).Methods("POST") s.router.Handle("/user/info", c.Then(s.GetUser())).Methods("POST") s.router.Handle("/user/check", c.Then(s.CheckUser())).Methods("POST") s.router.Handle("/user/avatar", c.Then(s.GetAvatar())).Methods("POST") diff --git a/wmiau.go b/wmiau.go index feb69e5a..1a147a7a 100644 --- a/wmiau.go +++ b/wmiau.go @@ -1146,11 +1146,13 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { case *events.Presence: postmap["type"] = "Presence" dowebhook = 1 + postmap["from"] = evt.From.String() if evt.Unavailable { postmap["state"] = "offline" if evt.LastSeen.IsZero() { log.Info().Str("from", evt.From.String()).Msg("User is now offline") } else { + postmap["last_seen"] = evt.LastSeen.Unix() log.Info().Str("from", evt.From.String()).Str("lastSeen", fmt.Sprintf("%v", evt.LastSeen)).Msg("User is now offline") } } else { From 898bf6c9724ff31fb0f9f3b736669f7f0e981ecb Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 1 Jul 2026 13:29:13 -0300 Subject: [PATCH 2/3] Update handlers.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handlers.go b/handlers.go index dbb31af2..bd232144 100644 --- a/handlers.go +++ b/handlers.go @@ -3471,7 +3471,7 @@ func (s *server) SubscribePresence() http.HandlerFunc { return } - err = clientManager.GetWhatsmeowClient(txtid).SubscribePresence(context.Background(), jid) + err = clientManager.GetWhatsmeowClient(txtid).SubscribePresence(r.Context(), jid) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New("failure subscribing to presence")) return From e1ad57f1d3efff3d6aa87d667e2e65282023fae5 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 1 Jul 2026 13:29:23 -0300 Subject: [PATCH 3/3] Update handlers.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handlers.go b/handlers.go index bd232144..615b9e91 100644 --- a/handlers.go +++ b/handlers.go @@ -3479,7 +3479,7 @@ func (s *server) SubscribePresence() http.HandlerFunc { log.Info().Str("jid", jid.String()).Msg("Subscribed to presence") - response := map[string]interface{}{"Details": "Presence subscription requested successfuly"} + response := map[string]interface{}{"Details": "Presence subscription requested successfully"} responseJson, err := json.Marshal(response) if err != nil { s.Respond(w, r, http.StatusInternalServerError, err)