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:
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -30,6 +31,11 @@ type profilePayload struct {
|
||||
// TOTPEnabled — включена ли 2FA: клиенту нужно знать, требовать ли код
|
||||
// при step-up и показывать ли QR при настройке (AGENT.md 7.1).
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
// Timezone — часовой пояс пользователя (по умолчанию МСК): по нему клиент
|
||||
// показывает время последнего входа друзьям.
|
||||
Timezone string `json:"timezone"`
|
||||
// LastSeenAt — когда пользователя видели последний раз (для друзей).
|
||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||
}
|
||||
|
||||
// profileFromUser собирает профиль; состояние 2FA читается из сервиса
|
||||
@@ -68,6 +74,13 @@ func profileFromUser(user *store.User, includePrivate bool) profilePayload {
|
||||
if includePrivate {
|
||||
payload.Locale = user.Locale
|
||||
}
|
||||
payload.Timezone = user.Timezone
|
||||
if payload.Timezone == "" {
|
||||
payload.Timezone = "Europe/Moscow"
|
||||
}
|
||||
if user.LastSeenAt != nil {
|
||||
payload.LastSeenAt = user.LastSeenAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -103,6 +116,7 @@ type updateProfileInput struct {
|
||||
CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"`
|
||||
CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"`
|
||||
Locale *string `json:"locale,omitempty" enum:"ru,en"`
|
||||
Timezone *string `json:"timezone,omitempty" maxLength:"64"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +208,11 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
||||
CustomStatusEmoji: input.Body.CustomStatusEmoji,
|
||||
Locale: input.Body.Locale,
|
||||
}
|
||||
if input.Body.Timezone != nil {
|
||||
if _, err := time.LoadLocation(*input.Body.Timezone); err != nil {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown timezone")
|
||||
}
|
||||
}
|
||||
if input.Body.DisplayName != nil {
|
||||
trimmed := strings.TrimSpace(*input.Body.DisplayName)
|
||||
if trimmed == "" {
|
||||
@@ -205,8 +224,21 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3).
|
||||
if input.Body.Timezone != nil {
|
||||
if err := s.store.SetTimezone(ctx, user.ID, *input.Body.Timezone); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
updated, err = s.store.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
}
|
||||
// Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3),
|
||||
// а друзья — обновление присутствия (AGENT.md 7.16).
|
||||
s.dispatchUserUpdate(updated)
|
||||
if input.Body.Status != nil || input.Body.CustomStatus != nil {
|
||||
s.dispatchPresenceUpdate(ctx, updated)
|
||||
}
|
||||
output := &meOutput{}
|
||||
output.Body.User = s.profileFromUser(ctx, updated, true)
|
||||
return output, nil
|
||||
@@ -392,6 +424,30 @@ func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uin
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// dispatchPresenceUpdate рассылает статус пользователя его друзьям.
|
||||
func (s *Server) dispatchPresenceUpdate(ctx context.Context, user *store.User) {
|
||||
if s.gateway == nil {
|
||||
return
|
||||
}
|
||||
friends, err := s.store.ListRelationships(ctx, user.ID, store.RelationshipFriend)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "failed to list friends for presence", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"user_id": formatSnowflake(user.ID),
|
||||
"status": user.Status,
|
||||
"visible_status": visibleStatus(user.Status, user.LastSeenAt),
|
||||
"custom_status": user.CustomStatus,
|
||||
}
|
||||
if user.LastSeenAt != nil {
|
||||
payload["last_seen_at"] = user.LastSeenAt.UTC().Format(timeLayout)
|
||||
}
|
||||
for _, friend := range friends {
|
||||
s.gateway.SendToUser(friend.UserID, "PRESENCE_UPDATE", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchUserUpdate рассылает обновление профиля во все сессии пользователя.
|
||||
func (s *Server) dispatchUserUpdate(user *store.User) {
|
||||
if s.gateway == nil {
|
||||
|
||||
Reference in New Issue
Block a user