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:
@@ -90,6 +90,12 @@ type ReadyChannel struct {
|
||||
Slowmode int `json:"slowmode_seconds,omitempty"`
|
||||
CanSend bool `json:"can_send"`
|
||||
CanConnect bool `json:"can_connect"`
|
||||
// Поля личных бесед (type = dm): собеседник и последнее сообщение.
|
||||
RecipientID string `json:"recipient_id,omitempty"`
|
||||
IconFileID string `json:"icon_file_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
LastMessageID string `json:"last_message_id,omitempty"`
|
||||
LastMessageAt string `json:"last_message_at,omitempty"`
|
||||
}
|
||||
|
||||
type ReadyRole struct {
|
||||
@@ -290,10 +296,17 @@ func (s *Service) dispatchToChannel(ctx context.Context, channelID, exceptUserID
|
||||
}
|
||||
}
|
||||
|
||||
// canViewChannel проверяет видимость комнаты для сессии. Без калькулятора
|
||||
// прав событие доставляется всем: так работают тесты и режим без БД.
|
||||
// canViewChannel проверяет видимость комнаты для сессии. Для личных бесед
|
||||
// (без сервера) событие получают только участники (AGENT.md 7.8, 9.7).
|
||||
func (s *Service) canViewChannel(ctx context.Context, guildID, channelID uint64, session *clientSession) bool {
|
||||
if s.visibility == nil || guildID == 0 {
|
||||
if guildID == 0 {
|
||||
participant, err := s.store.IsDMParticipant(ctx, channelID, session.userID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return participant
|
||||
}
|
||||
if s.visibility == nil {
|
||||
return true
|
||||
}
|
||||
instanceAdmin := session.user != nil && session.user.IsInstanceAdmin
|
||||
|
||||
@@ -117,6 +117,31 @@ func send(t *testing.T, conn *websocket.Conn, op int, payload any) {
|
||||
}
|
||||
}
|
||||
|
||||
// joinMainGuildForTest добавляет пользователя в главный сервер: в продакшене
|
||||
// это делает принятие приглашения.
|
||||
func joinMainGuildForTest(t *testing.T, f *fixture, token string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
user, _, err := f.auth.ResolveSession(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSession: %v", err)
|
||||
}
|
||||
guild, err := f.store.GetMainGuild(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMainGuild: %v", err)
|
||||
}
|
||||
defaultRole, err := f.store.DefaultRole(ctx, guild.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRole: %v", err)
|
||||
}
|
||||
if _, err := f.store.AddGuildMember(ctx, guild.ID, user.ID, ""); err != nil {
|
||||
t.Fatalf("AddGuildMember: %v", err)
|
||||
}
|
||||
if err := f.store.AssignRole(ctx, guild.ID, user.ID, defaultRole.ID); err != nil {
|
||||
t.Fatalf("AssignRole: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerUser(t *testing.T, f *fixture, username, email string) string {
|
||||
t.Helper()
|
||||
_, token, _, err := f.auth.Register(context.Background(), auth.RegisterInput{
|
||||
@@ -131,6 +156,8 @@ func registerUser(t *testing.T, f *fixture, username, email string) string {
|
||||
func TestHelloAndReady(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
token := registerUser(t, f, "gateway_user", "gateway@example.com")
|
||||
// Автовступления больше нет: в сервер вступаем явно, как по приглашению.
|
||||
joinMainGuildForTest(t, f, token)
|
||||
|
||||
conn, hello := f.dial(t)
|
||||
var helloPayload struct {
|
||||
@@ -164,12 +191,9 @@ func TestHelloAndReady(t *testing.T) {
|
||||
t.Fatalf("READY user = %q", snapshot.User.Username)
|
||||
}
|
||||
if len(snapshot.Guilds) != 1 {
|
||||
t.Fatalf("new user must see exactly the main guild, got %d", len(snapshot.Guilds))
|
||||
t.Fatalf("user must see the guild they joined, got %d", len(snapshot.Guilds))
|
||||
}
|
||||
guild := snapshot.Guilds[0]
|
||||
if !guild.IsMain {
|
||||
t.Fatal("the main guild must be marked is_main")
|
||||
}
|
||||
if len(guild.Roles) != 2 {
|
||||
t.Fatalf("main guild roles = %d, want 2", len(guild.Roles))
|
||||
}
|
||||
@@ -216,6 +240,7 @@ func TestReadyHidesPrivateChannels(t *testing.T) {
|
||||
}
|
||||
|
||||
token := registerUser(t, f, "private_user", "private@example.com")
|
||||
joinMainGuildForTest(t, f, token)
|
||||
conn, _ := f.dial(t)
|
||||
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
|
||||
ready := readEnvelope(t, conn)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user