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
+2 -24
View File
@@ -199,9 +199,8 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User,
return nil, "", nil, fmt.Errorf("create user: %w", err)
}
if err := s.joinMainGuild(ctx, user.ID); err != nil {
s.logger.WarnContext(ctx, "failed to join main guild", slog.Any("error", err))
}
// Новый пользователь попадает в пустой список серверов: вход только по
// приглашению или созданием своего сервера (решение пользователя, 2026-09-20).
token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP)
if err != nil {
@@ -475,27 +474,6 @@ func (s *Service) createSession(ctx context.Context, userID uint64, userAgent, i
return token, session, nil
}
// joinMainGuild добавляет нового пользователя на главный сервер с ролью
// «Пользователь» (AGENT.md 7.3).
func (s *Service) joinMainGuild(ctx context.Context, userID uint64) error {
settings, err := s.settings.InstanceSettings(ctx)
if err != nil || settings.MainGuildID == 0 {
return err
}
if _, err := s.store.AddGuildMember(ctx, settings.MainGuildID, userID, ""); err != nil {
return err
}
defaultRole, err := s.store.DefaultRole(ctx, settings.MainGuildID)
if err != nil {
// Роль по умолчанию может отсутствовать (сервер без ролей) — это не мешает
// автовступлению, поэтому ошибку не возвращаем, но фиксируем в логе.
s.logger.WarnContext(ctx, "main guild has no default role",
slog.Uint64("guild_id", settings.MainGuildID), slog.Any("error", err))
return nil
}
return s.store.AssignRole(ctx, settings.MainGuildID, userID, defaultRole.ID)
}
// checkLockout защищает от перебора: серия неудач блокирует попытки на окно.
func (s *Service) checkLockout(ctx context.Context, userID uint64) error {
events, err := s.store.ListSecurityEvents(ctx, userID, 20)
+4 -12
View File
@@ -114,10 +114,9 @@ func TestRegisterAndLogin(t *testing.T) {
if err != nil {
t.Fatalf("Register: %v", err)
}
// Главный сервер создаётся установщиком (владелец — уже существующий админ).
// Сервер существует (создан установщиком), но новый пользователь в него
// не попадает: вход только по приглашению (решение пользователя).
bootstrapMainGuild(t, st, user.ID)
// Новый пользователь регистрируется после появления главного сервера,
// чтобы проверить автовступление.
second, _, _, err := service.Register(ctx, auth.RegisterInput{
Username: "newcomer", Email: "newcomer@example.com", Password: "correct-horse-battery",
})
@@ -128,15 +127,8 @@ func TestRegisterAndLogin(t *testing.T) {
if err != nil {
t.Fatalf("ListGuildsForUser(newcomer): %v", err)
}
if len(newcomerGuilds) != 1 || !newcomerGuilds[0].IsMain {
t.Fatalf("newcomer must join the main guild, got %+v", newcomerGuilds)
}
newcomerRoles, err := st.MemberRoles(ctx, newcomerGuilds[0].ID, second.ID)
if err != nil {
t.Fatalf("MemberRoles(newcomer): %v", err)
}
if len(newcomerRoles) != 1 || !newcomerRoles[0].IsDefault {
t.Fatalf("newcomer must receive the default role, got %+v", newcomerRoles)
if len(newcomerGuilds) != 0 {
t.Fatalf("newcomer must start without guilds, got %+v", newcomerGuilds)
}
if user.ID == 0 || token == "" || session.ID == 0 {
t.Fatal("registration must return user, token and session")