package server import ( "context" "errors" "fmt" "log/slog" "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"` // Cosmetics — оформление «для друзей»: в DM и списке друзей применяется // именно оно (AGENT.md 7.2). Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"` } 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"` // Групповая беседа (AGENT.md 7.8): имя, участники и владелец. У 1:1 // is_group = false, а данные собеседника лежат в recipient. IsGroup bool `json:"is_group"` Name string `json:"name,omitempty"` MemberCount int `json:"member_count,omitempty"` OwnerID string `json:"owner_id,omitempty"` Recipients []dmRecipientPayload `json:"recipients,omitempty"` } // dmRecipientPayload — участник групповой беседы. type dmRecipientPayload 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"` IsOwner bool `json:"is_owner"` IsSelf bool `json:"is_self"` } 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 } // Блокировка сильнее заявки: переписка и дружба с ней невозможны. if blocked, err := s.store.IsBlocked(ctx, user.ID, target.ID); err != nil { return nil, humaError(err) } else if blocked { return nil, humaErrorStatus(http.StatusForbidden, "relationship.blocked", "user is blocked") } // Если встречная заявка уже есть — сразу становимся друзьями. 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: "blockUser", Method: http.MethodPut, Path: "/users/@me/blocks/{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 targetID == user.ID { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot block yourself") } if _, err := s.store.GetUser(ctx, targetID); err != nil { return nil, humaError(err) } // Блокировка снимает дружбу и заявки в обе стороны (AGENT.md 7.2). if err := s.store.BlockUser(ctx, user.ID, targetID); err != nil { return nil, humaError(err) } s.dispatchRelationshipUpdate(user.ID, targetID) return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "unblockUser", Method: http.MethodDelete, Path: "/users/@me/blocks/{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.UnblockUser(ctx, user.ID, targetID); 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) } // С заблокированным беседа не открывается (AGENT.md 7.2). if blocked, err := s.store.IsBlocked(ctx, user.ID, recipientID); err != nil { return nil, humaError(err) } else if blocked { return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked") } 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 }) // Групповые личные беседы (AGENT.md 7.8, Фаза 7): создание, добавление и // выход участников, переименование. Права простые и явные: добавлять может // любой участник, удалять других и переименовывать — владелец, выйти может // каждый сам. huma.Register(api, huma.Operation{ OperationID: "createGroupDirectChannel", Method: http.MethodPost, Path: "/users/@me/channels/group", Summary: "Создать групповую беседу", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, input *struct { Body struct { Name string `json:"name,omitempty" maxLength:"64"` RecipientIDs []string `json:"recipient_ids"` } }, ) (*dmChannelOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } members, err := s.groupDMMembers(ctx, user.ID, input.Body.RecipientIDs) if err != nil { return nil, err } // Группа — это минимум три участника: двое и меньше остаются обычной // личной беседой (см. store.RemoveDMParticipant). if len(members) < 2 { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "a group conversation needs at least two other members") } name := strings.TrimSpace(input.Body.Name) if name == "" { name = s.defaultGroupDMName(ctx, members) } if len([]rune(name)) > maxGroupDMNameLength { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "group name is too long") } channel, err := s.store.CreateGroupDMChannel(ctx, user.ID, name, members) if err != nil { return nil, humaError(err) } s.dispatchDMCreate(channel.ID, append([]uint64{user.ID}, members...)) summary, err := s.groupDMSummary(ctx, channel, user.ID) if err != nil { return nil, err } output := &dmChannelOutput{} output.Body.Channel = s.dmChannelPayload(summary) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "addDirectChannelRecipient", Method: http.MethodPut, Path: "/channels/{channel_id}/recipients/{user_id}", Summary: "Добавить участника в групповую беседу", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` UserID string `path:"user_id"` }, ) (*dmChannelOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) if err != nil { return nil, err } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if targetID == user.ID { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you are already a member") } if _, err := s.store.GetUser(ctx, targetID); err != nil { return nil, humaError(err) } // Блокировка запрещает и добавление в общую беседу (AGENT.md 7.2). if blocked, err := s.store.IsBlocked(ctx, user.ID, targetID); err != nil { return nil, humaError(err) } else if blocked { return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked") } count, err := s.store.DMParticipantCount(ctx, channel.ID) if err != nil { return nil, humaError(err) } if count >= maxGroupDMMembers { return nil, humaErrorStatus(http.StatusConflict, "dm.members_limit", "group conversation is full") } if err := s.store.AddDMParticipant(ctx, channel.ID, targetID); err != nil { return nil, humaError(err) } // Новый участник получает событие создания беседы, остальные — обновление // состава (AGENT.md 8.3). if s.gateway != nil { s.gateway.SendToUser(targetID, "DM_CHANNEL_CREATE", map[string]any{ "channel_id": formatSnowflake(channel.ID), }) } s.dispatchDMUpdate(channel, []uint64{}, targetID) summary, err := s.groupDMSummary(ctx, channel, user.ID) if err != nil { return nil, err } output := &dmChannelOutput{} output.Body.Channel = s.dmChannelPayload(summary) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "removeDirectChannelRecipient", Method: http.MethodDelete, Path: "/channels/{channel_id}/recipients/{user_id}", Summary: "Выйти из групповой беседы или удалить участника", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` UserID string `path:"user_id"` }, ) (*okOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) if err != nil { return nil, err } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if targetID != user.ID { // Удалять других может только владелец беседы. if channel.DMOwnerID == nil || *channel.DMOwnerID != user.ID { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the group owner can remove other members") } member, err := s.store.IsDMParticipant(ctx, channel.ID, targetID) if err != nil { return nil, humaError(err) } if !member { return nil, humaErrorStatus(http.StatusNotFound, "not_found", "user is not a member of this conversation") } } if err := s.store.RemoveDMParticipant(ctx, channel.ID, targetID); err != nil { return nil, humaError(err) } // Удалённый (или вышедший) теряет доступ: ему уходит CHANNEL_DELETE, // остальным — обновление состава. if s.gateway != nil { s.gateway.SendToUser(targetID, "DM_CHANNEL_DELETE", map[string]any{ "channel_id": formatSnowflake(channel.ID), }) } s.dispatchDMUpdate(channel, []uint64{targetID}, 0) return newOKOutput(), nil }) huma.Register(api, huma.Operation{ OperationID: "renameDirectChannel", Method: http.MethodPatch, Path: "/channels/{channel_id}", Summary: "Переименовать групповую беседу (владелец)", Tags: []string{"Friends"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` Body struct { Name string `json:"name" maxLength:"64"` } }, ) (*dmChannelOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) if err != nil { return nil, err } if channel.DMOwnerID == nil || *channel.DMOwnerID != user.ID { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the group owner can rename the conversation") } name := strings.TrimSpace(input.Body.Name) if name == "" || len([]rune(name)) > maxGroupDMNameLength { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "group name must be 1-64 characters") } if err := s.store.RenameDMChannel(ctx, channel.ID, name); err != nil { return nil, humaError(err) } channel.Name = name s.dispatchDMUpdate(channel, []uint64{}, 0) summary, err := s.groupDMSummary(ctx, channel, user.ID) if err != nil { return nil, err } output := &dmChannelOutput{} output.Body.Channel = s.dmChannelPayload(summary) return output, nil }) } // Пределы групповой беседы (AGENT.md 7.8): не больше десяти участников и // 64 символа в названии — группа не должна превращаться в сервер. const ( maxGroupDMMembers = 10 maxGroupDMNameLength = 64 ) // groupDMMembers проверяет список приглашённых: пользователи существуют, это // не сам приглашающий, никто не заблокирован. func (s *Server) groupDMMembers(ctx context.Context, selfID uint64, rawIDs []string) ([]uint64, error) { if len(rawIDs) > maxGroupDMMembers-1 { return nil, humaErrorStatus(http.StatusConflict, "dm.members_limit", "group conversation is full") } seen := map[uint64]bool{} members := make([]uint64, 0, len(rawIDs)) for _, rawID := range rawIDs { targetID, err := parseID("recipient_ids", rawID) if err != nil { return nil, err } if targetID == selfID || seen[targetID] { continue } if _, err := s.store.GetUser(ctx, targetID); err != nil { return nil, humaError(err) } if blocked, err := s.store.IsBlocked(ctx, selfID, targetID); err != nil { return nil, humaError(err) } else if blocked { return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked") } seen[targetID] = true members = append(members, targetID) } return members, nil } // defaultGroupDMName собирает имя по участникам: «Алиса, Боб и ещё 2». func (s *Server) defaultGroupDMName(ctx context.Context, members []uint64) string { names := make([]string, 0, len(members)) for _, memberID := range members { member, err := s.store.GetUser(ctx, memberID) if err != nil { continue } name := member.DisplayName if name == "" { name = member.Username } names = append(names, name) } if len(names) == 0 { return "Групповая беседа" } // В имя попадает не больше двух имён: длинный список обрезается. if len(names) > 2 { return fmt.Sprintf("%s, %s и ещё %d", names[0], names[1], len(names)-2) } return strings.Join(names, ", ") } // groupDMForMember проверяет, что канал — личная беседа, а пользователь в ней // состоит. Посторонним отвечаем 404: существование чужой беседы не подтверждаем. func (s *Server) groupDMForMember(ctx context.Context, rawChannelID string, userID uint64) (*store.Channel, error) { channelID, err := parseID("channel_id", rawChannelID) if err != nil { return nil, err } channel, err := s.store.GetChannel(ctx, channelID) if err != nil { return nil, humaError(err) } if channel.Type != store.ChannelDM || channel.GuildID != nil { return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found") } participant, err := s.store.IsDMParticipant(ctx, channelID, userID) if err != nil { return nil, humaError(err) } if !participant { return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found") } return channel, nil } // groupDMSummary собирает карточку групповой беседы для ответа API. func (s *Server) groupDMSummary(ctx context.Context, channel *store.Channel, viewerID uint64) (store.DMChannelSummary, error) { participants, err := s.store.DMParticipantProfiles(ctx, channel.ID, viewerID) if err != nil { return store.DMChannelSummary{}, humaError(err) } ownerID := channel.DMOwnerID return store.DMChannelSummary{ Channel: *channel, IsGroup: true, GroupName: channel.Name, MemberCount: len(participants), Participants: participants, OwnerID: ownerID, }, nil } // dispatchDMCreate сообщает участникам о новой беседе (AGENT.md 8.3). func (s *Server) dispatchDMCreate(channelID uint64, userIDs []uint64) { if s.gateway == nil { return } for _, userID := range userIDs { s.gateway.SendToUser(userID, "DM_CHANNEL_CREATE", map[string]any{ "channel_id": formatSnowflake(channelID), }) } } // dispatchDMUpdate сообщает участникам об изменении состава или имени: сам // канал отдаётся в событии, чтобы клиент не делал лишний запрос. func (s *Server) dispatchDMUpdate(channel *store.Channel, except []uint64, include uint64) { if s.gateway == nil { return } participants, err := s.store.DMParticipants(context.Background(), channel.ID) if err != nil { s.logger.WarnContext(context.Background(), "не удалось прочитать участников беседы", slog.Any("error", err)) return } skip := map[uint64]bool{} for _, userID := range except { skip[userID] = true } recipients := make([]uint64, 0, len(participants)+1) for _, userID := range participants { if !skip[userID] { recipients = append(recipients, userID) } } if include != 0 { recipients = append(recipients, include) } payload := map[string]any{"channel_id": formatSnowflake(channel.ID)} for _, userID := range recipients { s.gateway.SendToUser(userID, "DM_CHANNEL_UPDATE", payload) } } // 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) } // Оформление «для друзей» (AGENT.md 7.2) — одним запросом на весь список. ids := make([]uint64, 0, len(profiles)) for i := range profiles { ids = append(ids, profiles[i].UserID) } cosmetics, err := s.store.EffectiveCosmeticsForUsers(ctx, ids) if err != nil { return nil, humaError(err) } result := make([]relationshipUser, 0, len(profiles)) for i := range profiles { payload := s.relationshipPayload(userID, &profiles[i], string(kind)) payload.Cosmetics = cosmeticsFromEffective(cosmetics[profiles[i].UserID]) result = append(result, payload) } 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, } if summary.IsGroup { payload.IsGroup = true payload.Name = summary.GroupName payload.MemberCount = summary.MemberCount if summary.OwnerID != nil { payload.OwnerID = formatSnowflake(*summary.OwnerID) } payload.Recipients = make([]dmRecipientPayload, 0, len(summary.Participants)) for _, participant := range summary.Participants { item := dmRecipientPayload{ UserID: formatSnowflake(participant.UserID), Username: participant.Username, DisplayName: participant.DisplayName, Status: participant.Status, VisibleStatus: visibleStatus(participant.Status, participant.LastSeenAt), IsOwner: participant.IsOwner, IsSelf: participant.IsCurrentUser, } if participant.AvatarFileID != nil { item.AvatarFileID = formatSnowflake(*participant.AvatarFileID) } payload.Recipients = append(payload.Recipients, item) } if summary.LastMessageAt != nil { payload.LastMessageAt = summary.LastMessageAt.UTC().Format(timeLayout) } if summary.LastMessageID != 0 { payload.LastMessageID = formatSnowflake(summary.LastMessageID) } return payload } 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 }