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
+30
View File
@@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/danielgtaylor/huma/v2"
@@ -39,11 +41,39 @@ func (s *Server) sessionContext(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
s.touchPresence(user.ID)
ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// touchPresence обновляет время последней активности пользователя, но не чаще
// раза в минуту: значение нужно списку друзей (статус и «был в сети»).
func (s *Server) touchPresence(userID uint64) {
s.presenceMu.Lock()
last, ok := s.presence[userID]
now := time.Now()
if ok && now.Sub(last) < time.Minute {
s.presenceMu.Unlock()
return
}
if len(s.presence) > 8192 {
for id, at := range s.presence {
if now.Sub(at) > time.Hour {
delete(s.presence, id)
}
}
}
s.presence[userID] = now
s.presenceMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.store.TouchLastSeen(ctx, userID); err != nil {
s.logger.DebugContext(ctx, "failed to update last seen", slog.Any("error", err))
}
}
// sessionToken читает токен сессии из cookie (браузер) или Bearer (desktop).
func sessionToken(r *http.Request) string {
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {