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
+43
View File
@@ -76,6 +76,34 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
})
}
// Личные беседы: попадают в отдельный список, чтобы не смешиваться с
// серверами в интерфейсе (AGENT.md 7.8).
dmChannels, err := s.store.ListDMChannels(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("list direct channels: %w", err)
}
for _, summary := range dmChannels {
channel := ReadyChannel{
ID: formatID(summary.Channel.ID),
Type: string(store.ChannelDM),
Name: summary.RecipientName,
CanSend: true,
CanConnect: false,
RecipientID: formatID(summary.RecipientID),
Status: visibleStatus(summary.Status, summary.LastSeenAt),
}
if summary.AvatarFileID != nil {
channel.IconFileID = formatID(*summary.AvatarFileID)
}
if summary.LastMessageID != 0 {
channel.LastMessageID = formatID(summary.LastMessageID)
}
if summary.LastMessageAt != nil {
channel.LastMessageAt = summary.LastMessageAt.UTC().Format(time.RFC3339)
}
ready.DMChannels = append(ready.DMChannels, channel)
}
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("list user guilds: %w", err)
@@ -263,6 +291,21 @@ func (p *permissionSource) MemberTimeout(ctx context.Context, guildID, userID ui
return member.TimeoutUntil.After(time.Now().UTC()), nil
}
// visibleStatus вычисляет статус, который видят другие: «невидимка» выглядит
// как офлайн, как и пользователь без активности дольше двух минут.
func visibleStatus(status string, lastSeen *time.Time) string {
if status == "invisible" {
return "offline"
}
if status == "" {
status = "online"
}
if lastSeen != nil && time.Since(*lastSeen) > 2*time.Minute {
return "offline"
}
return status
}
func formatID(id uint64) string {
if id == 0 {
return ""