package server import ( "context" "errors" "net/http" "strings" "time" "github.com/danielgtaylor/huma/v2" "glchat/internal/store" ) // relationshipUser — профиль собеседника вместе с типом связи (AGENT.md 7.8). type relationshipUser struct { UserID string `json:"user_id"` Username string `json:"username"` DisplayName string `json:"display_name"` AvatarFileID string `json:"avatar_file_id,omitempty"` Status string `json:"status"` CustomStatus string `json:"custom_status,omitempty"` IsInstanceAdmin bool `json:"is_instance_admin"` Badges []string `json:"badges"` // VisibleStatus — статус, который видят другие: «невидимка» выглядит как // офлайн, а без активности дольше двух минут показываем офлайн. VisibleStatus string `json:"visible_status"` LastSeenAt string `json:"last_seen_at,omitempty"` Timezone string `json:"timezone"` // Relationship: friend | incoming | outgoing | blocked | none Relationship string `json:"relationship"` } type relationshipListOutput struct { Body struct { Friends []relationshipUser `json:"friends"` Incoming []relationshipUser `json:"incoming"` Outgoing []relationshipUser `json:"outgoing"` Blocked []relationshipUser `json:"blocked"` } } type userSearchOutput struct { Body struct { Users []relationshipUser `json:"users"` } } type dmChannelPayload struct { ID string `json:"id"` Type string `json:"type"` CanSend bool `json:"can_send"` CanView bool `json:"can_view"` Recipient struct { UserID string `json:"user_id"` Username string `json:"username"` DisplayName string `json:"display_name"` AvatarFileID string `json:"avatar_file_id,omitempty"` Status string `json:"status"` VisibleStatus string `json:"visible_status"` LastSeenAt string `json:"last_seen_at,omitempty"` Timezone string `json:"timezone"` } `json:"recipient"` LastMessageID string `json:"last_message_id,omitempty"` LastMessageAt string `json:"last_message_at,omitempty"` } type dmChannelListOutput struct { Body struct { Channels []dmChannelPayload `json:"channels"` } } type dmChannelOutput struct { Body struct { Channel dmChannelPayload `json:"channel"` } } // registerSocialRoutes описывает друзей, поиск пользователей и личные беседы // (AGENT.md 7.8; раздел запрошен пользователем вне очереди). func (s *Server) registerSocialRoutes(api huma.API) { security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} huma.Register(api, huma.Operation{ OperationID: "searchUsers", Method: http.MethodGet, Path: "/users/search", Summary: "Поиск пользователей по логину", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, input *struct { Query string `query:"q" minLength:"2" maxLength:"32"` Limit int `query:"limit" default:"20" minimum:"1" maximum:"50"` }, ) (*userSearchOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } users, err := s.store.SearchUsersByUsername(ctx, strings.TrimSpace(input.Query), user.ID, input.Limit) if err != nil { return nil, humaError(err) } output := &userSearchOutput{} output.Body.Users = make([]relationshipUser, 0, len(users)) for i := range users { payload, err := s.relationshipUser(ctx, user.ID, &users[i]) if err != nil { return nil, err } output.Body.Users = append(output.Body.Users, payload) } return output, nil }) huma.Register(api, huma.Operation{ OperationID: "listRelationships", Method: http.MethodGet, Path: "/users/@me/relationships", Summary: "Друзья, входящие и исходящие заявки", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, _ *struct{}) (*relationshipListOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } output := &relationshipListOutput{} output.Body.Friends, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipFriend) if err != nil { return nil, err } if output.Body.Incoming, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipIncoming); err != nil { return nil, err } if output.Body.Outgoing, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipOutgoing); err != nil { return nil, err } if output.Body.Blocked, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipBlocked); err != nil { return nil, err } return output, nil }) huma.Register(api, huma.Operation{ OperationID: "sendFriendRequest", Method: http.MethodPost, Path: "/users/@me/relationships", Summary: "Отправить заявку в друзья по логину", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, input *struct { Body struct { Username string `json:"username,omitempty" maxLength:"32"` UserID string `json:"user_id,omitempty"` } }, ) (*okOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } target, err := s.relationshipTarget(ctx, input.Body.UserID, input.Body.Username, user.ID) if err != nil { return nil, err } // Если встречная заявка уже есть — сразу становимся друзьями. reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID) switch { case err == nil && reverse.Type == store.RelationshipIncoming: if err := s.makeFriends(ctx, user.ID, target.ID); err != nil { return nil, err } return newOKOutput(), nil case err == nil && reverse.Type == store.RelationshipFriend: return newOKOutput(), nil case err != nil && !errors.Is(err, store.ErrNotFound): return nil, humaError(err) } if err := s.store.SetRelationship(ctx, user.ID, target.ID, store.RelationshipOutgoing); err != nil { return nil, humaError(err) } if err := s.store.SetRelationship(ctx, target.ID, user.ID, store.RelationshipIncoming); err != nil { return nil, humaError(err) } s.dispatchRelationshipUpdate(user.ID, target.ID) return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "acceptFriendRequest", Method: http.MethodPost, Path: "/users/@me/relationships/{user_id}/accept", Summary: "Принять заявку в друзья", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` }, ) (*okOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } incoming, err := s.store.GetRelationship(ctx, user.ID, targetID) if err != nil { return nil, humaError(err) } if incoming.Type != store.RelationshipIncoming { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "no incoming friend request from this user") } if err := s.makeFriends(ctx, user.ID, targetID); err != nil { return nil, err } return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "removeRelationship", Method: http.MethodDelete, Path: "/users/@me/relationships/{user_id}", Summary: "Удалить из друзей, отклонить или отменить заявку", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, input *struct { UserID string `path:"user_id"` }, ) (*okOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if err := s.store.RemoveRelationship(ctx, user.ID, targetID); err != nil { return nil, humaError(err) } if err := s.store.RemoveRelationship(ctx, targetID, user.ID); err != nil { return nil, humaError(err) } s.dispatchRelationshipUpdate(user.ID, targetID) return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "openDirectChannel", Method: http.MethodPost, Path: "/users/@me/channels", Summary: "Открыть личную беседу с пользователем", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, input *struct { Body struct { RecipientID string `json:"recipient_id" minLength:"1"` } }, ) (*dmChannelOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } recipientID, err := parseID("recipient_id", input.Body.RecipientID) if err != nil { return nil, err } if recipientID == user.ID { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot open a direct channel with yourself") } recipient, err := s.store.GetUser(ctx, recipientID) if err != nil { return nil, humaError(err) } channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID) if errors.Is(err, store.ErrNotFound) { channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID) if err != nil { return nil, humaError(err) } // Оба участника сразу видят беседу в списке (AGENT.md 8.3). for _, participant := range []uint64{user.ID, recipientID} { if s.gateway != nil { s.gateway.SendToUser(participant, "DM_CHANNEL_CREATE", map[string]any{ "channel_id": formatSnowflake(channel.ID), }) } } } else if err != nil { return nil, humaError(err) } summary := store.DMChannelSummary{ Channel: *channel, RecipientID: recipient.ID, RecipientName: recipient.DisplayName, RecipientLogin: recipient.Username, AvatarFileID: recipient.AvatarFileID, Status: recipient.Status, LastSeenAt: recipient.LastSeenAt, Timezone: recipient.Timezone, } output := &dmChannelOutput{} output.Body.Channel = s.dmChannelPayload(summary) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "listDirectChannels", Method: http.MethodGet, Path: "/users/@me/channels", Summary: "Личные беседы пользователя", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, _ *struct{}) (*dmChannelListOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channels, err := s.store.ListDMChannels(ctx, user.ID) if err != nil { return nil, humaError(err) } output := &dmChannelListOutput{} output.Body.Channels = make([]dmChannelPayload, 0, len(channels)) for _, summary := range channels { output.Body.Channels = append(output.Body.Channels, s.dmChannelPayload(summary)) } return output, nil }) } // relationshipTarget находит пользователя по id или логину. func (s *Server) relationshipTarget(ctx context.Context, rawID, username string, selfID uint64) (*store.User, error) { if rawID != "" { targetID, err := parseID("user_id", rawID) if err != nil { return nil, err } if targetID == selfID { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot add yourself as a friend") } target, err := s.store.GetUser(ctx, targetID) if err != nil { return nil, humaError(err) } return target, nil } username = strings.TrimSpace(username) if username == "" { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "username or user_id is required") } target, err := s.store.GetUserByUsername(ctx, username) if err != nil { if errors.Is(err, store.ErrNotFound) { return nil, humaErrorStatus(http.StatusNotFound, "user.not_found", "пользователь с таким логином не найден") } return nil, humaError(err) } if target.ID == selfID { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot add yourself as a friend") } return target, nil } // makeFriends делает пользователей друзьями в обе стороны. func (s *Server) makeFriends(ctx context.Context, firstID, secondID uint64) error { if err := s.store.SetRelationship(ctx, firstID, secondID, store.RelationshipFriend); err != nil { return humaError(err) } if err := s.store.SetRelationship(ctx, secondID, firstID, store.RelationshipFriend); err != nil { return humaError(err) } s.dispatchRelationshipUpdate(firstID, secondID) return nil } // dispatchRelationshipUpdate уведомляет обоих участников об изменении связи. func (s *Server) dispatchRelationshipUpdate(firstID, secondID uint64) { if s.gateway == nil { return } for _, userID := range []uint64{firstID, secondID} { s.gateway.SendToUser(userID, "RELATIONSHIP_UPDATE", map[string]any{ "user_id": formatSnowflake(userID), }) } } // relationshipProfiles собирает список связей указанного типа. func (s *Server) relationshipProfiles(ctx context.Context, userID uint64, kind store.RelationshipType) ([]relationshipUser, error) { profiles, err := s.store.ListRelationships(ctx, userID, kind) if err != nil { return nil, humaError(err) } result := make([]relationshipUser, 0, len(profiles)) for i := range profiles { result = append(result, s.relationshipPayload(userID, &profiles[i], string(kind))) } return result, nil } // relationshipUser собирает профиль пользователя со связью (для поиска). func (s *Server) relationshipUser(ctx context.Context, viewerID uint64, user *store.User) (relationshipUser, error) { kind := "none" relation, err := s.store.GetRelationship(ctx, viewerID, user.ID) switch { case err == nil: kind = string(relation.Type) case errors.Is(err, store.ErrNotFound): default: return relationshipUser{}, humaError(err) } profile := store.RelationshipProfile{ UserID: user.ID, Username: user.Username, DisplayName: user.DisplayName, AvatarFileID: user.AvatarFileID, Status: user.Status, CustomStatus: user.CustomStatus, Badges: user.Badges, IsInstanceAdmin: user.IsInstanceAdmin, LastSeenAt: user.LastSeenAt, Timezone: user.Timezone, } return s.relationshipPayload(viewerID, &profile, kind), nil } // relationshipPayload переводит профиль в формат API, вычисляя видимый статус. func (s *Server) relationshipPayload(_ uint64, profile *store.RelationshipProfile, kind string) relationshipUser { payload := relationshipUser{ UserID: formatSnowflake(profile.UserID), Username: profile.Username, DisplayName: profile.DisplayName, Status: profile.Status, CustomStatus: profile.CustomStatus, IsInstanceAdmin: profile.IsInstanceAdmin, Badges: profile.Badges, VisibleStatus: visibleStatus(profile.Status, profile.LastSeenAt), Timezone: profile.Timezone, Relationship: kind, } if payload.Badges == nil { payload.Badges = []string{} } if payload.Timezone == "" { payload.Timezone = "Europe/Moscow" } if profile.AvatarFileID != nil { payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID) } if profile.LastSeenAt != nil { payload.LastSeenAt = profile.LastSeenAt.UTC().Format(timeLayout) } return payload } // dmChannelPayload собирает личную беседу для API. func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPayload { payload := dmChannelPayload{ ID: formatSnowflake(summary.Channel.ID), Type: string(store.ChannelDM), CanSend: true, CanView: true, } payload.Recipient.UserID = formatSnowflake(summary.RecipientID) payload.Recipient.Username = summary.RecipientLogin payload.Recipient.DisplayName = summary.RecipientName payload.Recipient.Status = summary.Status payload.Recipient.Timezone = summary.Timezone if payload.Recipient.Timezone == "" { payload.Recipient.Timezone = "Europe/Moscow" } if summary.LastMessageAt != nil { payload.LastMessageAt = summary.LastMessageAt.UTC().Format(timeLayout) } if summary.LastMessageID != 0 { payload.LastMessageID = formatSnowflake(summary.LastMessageID) } if summary.AvatarFileID != nil { payload.Recipient.AvatarFileID = formatSnowflake(*summary.AvatarFileID) } if summary.LastSeenAt != nil { payload.Recipient.LastSeenAt = summary.LastSeenAt.UTC().Format(timeLayout) } payload.Recipient.VisibleStatus = visibleStatus(summary.Status, summary.LastSeenAt) return payload } // timeLayout — единый формат времени в API. const timeLayout = "2006-01-02T15:04:05Z07:00" // visibleStatus вычисляет статус, который видят другие пользователи: // «невидимка» отображается как офлайн, как и давно неактивный пользователь // (AGENT.md 7.2). func visibleStatus(status string, lastSeen *time.Time) string { if status == "invisible" { return "offline" } if status == "" { status = "online" } if lastSeen != nil && time.Since(*lastSeen) > 2*time.Minute { return "offline" } return status }