package rpc import ( "context" "sync" "time" "github.com/gotd/td/tg" "go.uber.org/zap" "telesrv/internal/domain" ) const userOnlineTTL = 5 * time.Minute const presenceDialogFanoutCandidateLimit = 512 type presenceSessionKey struct { rawAuthKeyID [8]byte sessionID int64 } type presenceSessionState struct { userID int64 status domain.UserStatus } type presenceTracker struct { mu sync.RWMutex bySession map[presenceSessionKey]presenceSessionState byUser map[int64]map[presenceSessionKey]domain.UserStatus } func newPresenceTracker() *presenceTracker { return &presenceTracker{ bySession: make(map[presenceSessionKey]presenceSessionState), byUser: make(map[int64]map[presenceSessionKey]domain.UserStatus), } } func (p *presenceTracker) setSessionStatus(key presenceSessionKey, userID int64, status domain.UserStatus) { if p == nil || userID == 0 { return } p.mu.Lock() defer p.mu.Unlock() if old, ok := p.bySession[key]; ok { p.removeSessionLocked(key, old.userID) } p.bySession[key] = presenceSessionState{userID: userID, status: status} sessions := p.byUser[userID] if sessions == nil { sessions = make(map[presenceSessionKey]domain.UserStatus) p.byUser[userID] = sessions } sessions[key] = status } func (p *presenceTracker) clearSession(key presenceSessionKey) { if p == nil { return } p.mu.Lock() defer p.mu.Unlock() if old, ok := p.bySession[key]; ok { p.removeSessionLocked(key, old.userID) } } func (p *presenceTracker) removeSessionLocked(key presenceSessionKey, userID int64) { delete(p.bySession, key) sessions := p.byUser[userID] delete(sessions, key) if len(sessions) == 0 { delete(p.byUser, userID) } } func (p *presenceTracker) statusFor(userID int64, now int) (domain.UserStatus, bool) { if p == nil || userID == 0 { return domain.UserStatus{}, false } p.mu.RLock() defer p.mu.RUnlock() sessions := p.byUser[userID] if len(sessions) == 0 { return domain.UserStatus{}, false } known := false var bestOnline domain.UserStatus var bestOffline domain.UserStatus for _, status := range sessions { status = normalizePresenceStatus(status, now) switch status.Kind { case domain.UserStatusOnline: if bestOnline.Expires == 0 || status.Expires > bestOnline.Expires { bestOnline = status } known = true case domain.UserStatusOffline: if bestOffline.WasOnline == 0 || status.WasOnline > bestOffline.WasOnline { bestOffline = status } known = true case domain.UserStatusRecently, domain.UserStatusLastWeek, domain.UserStatusLastMonth, domain.UserStatusEmpty: if !known { bestOffline = status known = true } } } if bestOnline.Kind == domain.UserStatusOnline { return bestOnline, true } if known { return bestOffline, true } return domain.UserStatus{}, false } func normalizePresenceStatus(status domain.UserStatus, now int) domain.UserStatus { if status.Kind == domain.UserStatusOnline && status.Expires <= now { wasOnline := status.WasOnline if wasOnline == 0 || wasOnline > status.Expires { wasOnline = status.Expires } return domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: wasOnline} } return status } func (r *Router) setPresenceFromContext(ctx context.Context, userID int64, offline bool) domain.UserStatus { now := int(r.clock.Now().Unix()) status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now} if !offline { status = domain.UserStatus{ Kind: domain.UserStatusOnline, Expires: now + int(userOnlineTTL/time.Second), WasOnline: now, } } if key, ok := presenceSessionKeyFromContext(ctx); ok { r.presence.setSessionStatus(key, userID, status) } r.persistLastSeen(ctx, userID, now) return r.userPresenceStatusForUser(domain.User{ID: userID, LastSeenAt: now}) } func (r *Router) announceSessionOnline(ctx context.Context, userID int64) { if userID == 0 { return } status := r.setPresenceFromContext(ctx, userID, false) r.pushUserStatus(ctx, userID, status) r.pushOnlinePeerStatusesToCurrentSession(ctx, userID) } func (r *Router) userPresenceStatus(userID int64) domain.UserStatus { return r.userPresenceStatusForUser(domain.User{ID: userID}) } func (r *Router) userPresenceStatusForUser(u domain.User) domain.UserStatus { userID := u.ID if userID == 0 { return domain.UserStatus{Kind: domain.UserStatusRecently} } now := int(r.clock.Now().Unix()) if status, ok := r.presence.statusFor(userID, now); ok { return status } if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok && provider.IsUserOnline(userID) { return domain.UserStatus{ Kind: domain.UserStatusOnline, Expires: now + int(userOnlineTTL/time.Second), WasOnline: now, } } if u.LastSeenAt > 0 { return domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: u.LastSeenAt} } return domain.UserStatus{Kind: domain.UserStatusRecently} } type userLastSeenUpdater interface { UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error } func (r *Router) persistLastSeen(ctx context.Context, userID int64, lastSeenAt int) { if userID == 0 || lastSeenAt <= 0 || r.deps.Users == nil { return } updater, ok := r.deps.Users.(userLastSeenUpdater) if !ok { return } if err := updater.UpdateLastSeen(ctx, userID, lastSeenAt); err != nil { r.log.Warn("Update user last seen failed", zap.Int64("user_id", userID), zap.Int("last_seen_at", lastSeenAt), zap.Error(err)) } } // SessionOffline is called by mtprotoedge when an active connection disappears. // Business-side effects stay here: mtprotoedge only reports lifecycle facts. func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) { if userID == 0 { return } ctx := WithSessionID(WithRawAuthKeyID(context.Background(), rawAuthKeyID), sessionID) key := presenceSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID} if !lastForUser { r.presence.clearSession(key) return } now := int(r.clock.Now().Unix()) status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now} r.presence.setSessionStatus(key, userID, status) r.persistLastSeen(ctx, userID, now) r.pushUserStatus(ctx, userID, status) } func presenceSessionKeyFromContext(ctx context.Context) (presenceSessionKey, bool) { sessionID, ok := SessionIDFrom(ctx) if !ok { return presenceSessionKey{}, false } if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { return presenceSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}, true } if authKeyID, ok := AuthKeyIDFrom(ctx); ok { return presenceSessionKey{rawAuthKeyID: authKeyID, sessionID: sessionID}, true } return presenceSessionKey{sessionID: sessionID}, true } func (r *Router) withUserPresence(u domain.User) domain.User { if u.ID == 0 { return u } u.Status = r.userPresenceStatusForUser(u) return u } func (r *Router) withUsersPresence(users []domain.User) []domain.User { if len(users) == 0 { return users } out := append([]domain.User(nil), users...) for i := range out { out[i] = r.withUserPresence(out[i]) } return out } func (r *Router) withContactListPresence(list domain.ContactList) domain.ContactList { if len(list.Contacts) == 0 { return list } out := list out.Contacts = append([]domain.Contact(nil), list.Contacts...) for i := range out.Contacts { out.Contacts[i].User = r.withUserPresence(out.Contacts[i].User) } return out } func (r *Router) withMessageListPresence(list domain.MessageList) domain.MessageList { list.Users = r.withUsersPresence(list.Users) return list } func (r *Router) withDialogListPresence(list domain.DialogList) domain.DialogList { list.Users = r.withUsersPresence(list.Users) return list } func (r *Router) withUserSearchPresence(res domain.UserSearchResult) domain.UserSearchResult { res.MyResults = r.withUsersPresence(res.MyResults) res.Results = r.withUsersPresence(res.Results) return res } func (r *Router) tgUser(u domain.User) *tg.User { return tgUser(r.withUserPresence(u)) } func (r *Router) tgSelfUser(u domain.User) *tg.User { return tgSelfUser(r.withUserPresence(u)) } func (r *Router) tgUsers(users []domain.User) []tg.UserClass { return tgUsers(r.withUsersPresence(users)) } func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain.UserStatus) { if userID == 0 { return } update := &tg.Updates{ Updates: []tg.UpdateClass{&tg.UpdateUserStatus{ UserID: userID, Status: tgUserStatus(status), }}, Date: int(r.clock.Now().Unix()), Seq: 0, } r.pushUserUpdates(ctx, userID, update) for _, recipientID := range r.onlinePresenceRecipientIDs(ctx, userID) { if recipientID == userID { continue } r.pushUserMessage(ctx, recipientID, "push user status", update) } } func (r *Router) onlinePresenceRecipientIDs(ctx context.Context, userID int64) []int64 { if userID == 0 { return nil } seen := map[int64]struct{}{} out := make([]int64, 0) add := func(ids []int64) { for _, id := range ids { if id == 0 || id == userID { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } } add(r.onlineContactIDs(ctx, userID)) add(r.onlinePrivateDialogPeerIDs(ctx, userID, seen)) return out } func (r *Router) onlineContactIDs(ctx context.Context, userID int64) []int64 { if r.deps.Contacts == nil || userID == 0 { return nil } ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0) if err != nil || notModified || len(ids) == 0 { return nil } candidates := make([]int64, 0, len(ids)) seen := make(map[int64]struct{}, len(ids)) for _, id := range ids { contactID := int64(id) if contactID == 0 || contactID == userID { continue } if _, ok := seen[contactID]; ok { continue } seen[contactID] = struct{}{} candidates = append(candidates, contactID) } if len(candidates) == 0 { return nil } if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok { return provider.OnlineUserIDsForCandidates(candidates, 0) } return candidates } func (r *Router) onlinePrivateDialogPeerIDs(ctx context.Context, userID int64, already map[int64]struct{}) []int64 { if r.deps.Dialogs == nil || userID == 0 { return nil } provider, ok := r.deps.Sessions.(OnlineUserProvider) if !ok { return nil } candidates := provider.OnlineUserIDs(presenceDialogFanoutCandidateLimit) if len(candidates) == 0 { return nil } peer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} out := make([]int64, 0, len(candidates)) for _, candidateID := range candidates { if candidateID == 0 || candidateID == userID { continue } if _, ok := already[candidateID]; ok { continue } list, err := r.deps.Dialogs.GetPeerDialogs(ctx, candidateID, []domain.Peer{peer}) if err != nil || !dialogListHasPeer(list, peer) { continue } out = append(out, candidateID) } return out } func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, userID int64) { peerIDs := r.onlineRelevantPeerIDs(ctx, userID) if len(peerIDs) == 0 { return } updates := make([]tg.UpdateClass, 0, len(peerIDs)) for _, peerID := range peerIDs { status := r.userPresenceStatus(peerID) if status.Kind != domain.UserStatusOnline { continue } updates = append(updates, &tg.UpdateUserStatus{ UserID: peerID, Status: tgUserStatus(status), }) } if len(updates) == 0 { return } r.pushCurrentSessionMessage(ctx, "push online peer statuses", &tg.Updates{ Updates: updates, Date: int(r.clock.Now().Unix()), Seq: 0, }) } func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int64 { if userID == 0 { return nil } provider, ok := r.deps.Sessions.(OnlineUserProvider) if !ok { return nil } seen := map[int64]struct{}{} candidates := make([]int64, 0) add := func(id int64) { if id == 0 || id == userID { return } if _, ok := seen[id]; ok { return } seen[id] = struct{}{} candidates = append(candidates, id) } if r.deps.Contacts != nil { ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0) if err == nil && !notModified { for _, id := range ids { add(int64(id)) } } } if r.deps.Dialogs != nil { list, err := r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{Limit: presenceDialogFanoutCandidateLimit}) if err == nil { for _, dialog := range list.Dialogs { if dialog.Peer.Type == domain.PeerTypeUser { add(dialog.Peer.ID) } } } } if len(candidates) == 0 { return nil } return provider.OnlineUserIDsForCandidates(candidates, 0) } func dialogListHasPeer(list domain.DialogList, peer domain.Peer) bool { for _, dialog := range list.Dialogs { if dialog.Peer != peer { continue } if dialog.TopMessage != 0 || dialog.TopMessageDate != 0 || dialog.ReadInboxMaxID != 0 || dialog.ReadOutboxMaxID != 0 || dialog.UnreadCount != 0 || dialog.UnreadMentions != 0 || dialog.UnreadReactions != 0 || dialog.Pinned || dialog.UnreadMark || dialog.Draft != nil { return true } for _, msg := range list.Messages { if msg.Peer == peer { return true } } } return false }