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
+22 -2
View File
@@ -31,6 +31,10 @@ type User struct {
DeletedAt *time.Time
// OnboardingCompletedAt заполняется после первичной настройки (AGENT.md 7.2).
OnboardingCompletedAt *time.Time
// LastSeenAt — время последней активности (для статусов друзей).
LastSeenAt *time.Time
// Timezone — часовой пояс пользователя (IANA, по умолчанию Europe/Moscow).
Timezone string
}
// CreateUserParams — данные новой учётной записи: шифрование и blind index
@@ -48,7 +52,7 @@ type CreateUserParams struct {
const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id,
banner_file_id, bio, status, custom_status, custom_status_emoji, flags,
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at,
onboarding_completed_at`
onboarding_completed_at, last_seen_at, timezone`
func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) {
if params.ID == 0 {
@@ -235,6 +239,14 @@ func (s *Store) MarkOnboardingCompleted(ctx context.Context, id uint64) error {
return nil
}
// ClearAvatar убирает ссылку на файл аватара.
func (s *Store) ClearAvatar(ctx context.Context, id uint64) error {
_, err := s.writer.ExecContext(ctx,
`UPDATE users SET avatar_file_id = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
s.Now(), int64(id))
return err
}
// ListUsers отдаёт страницу пользователей для админ-панели инстанса (AGENT.md 6.5).
func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error) {
if limit <= 0 || limit > 200 {
@@ -273,13 +285,14 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
updatedAt string
deletedAt sql.NullString
onboardingAt sql.NullString
lastSeenAt sql.NullString
emailEncrypted string
)
err := scanner.Scan(
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
&onboardingAt,
&onboardingAt, &lastSeenAt, &user.Timezone,
)
if err != nil {
return nil, mapError(err)
@@ -306,6 +319,13 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
value := parseTimestamp(onboardingAt.String)
user.OnboardingCompletedAt = &value
}
if lastSeenAt.Valid {
value := parseTimestamp(lastSeenAt.String)
user.LastSeenAt = &value
}
if user.Timezone == "" {
user.Timezone = "Europe/Moscow"
}
return &user, nil
}