feat(social): друзья, личные беседы, присутствие, аватары и часовой пояс
По запросу пользователя (вне очереди AGENT.md §13): - главного сервера как точки входа больше нет: новичок начинает с пустым списком серверов, вход — только по приглашению или созданием своего; - друзья: поиск по логину с экранированием LIKE, заявки (POST /users/@me/relationships), принятие, удаление/отклонение, списки friends/ incoming/outgoing/blocked; - личные беседы: POST /users/@me/channels (идемпотентно), GET /users/@me/channels со собеседником, статусом и последним сообщением; сообщения в DM работают через общие ручки комнат, доступ — только участникам (посторонний получает 404, события в Gateway тоже фильтруются); - присутствие: last_seen_at обновляется при активности, «невидимка» и простой дольше двух минут выглядят как офлайн, смена статуса рассылает PRESENCE_UPDATE друзьям; - READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение); - профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me, загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл удаляется с диска; - тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user