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:
2026-09-20 01:11:04 +03:00
parent b01eb1f34c
commit 33dc1fec96
19 changed files with 1738 additions and 82 deletions
+57 -1
View File
@@ -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 {