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:
@@ -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 != "" {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -27,6 +28,8 @@ const (
|
||||
maxMultipartOverhead = 1 << 20
|
||||
// maxMultipartMemory — сколько multipart держим в памяти, остальное — на диске.
|
||||
maxMultipartMemory = 8 << 20
|
||||
// maxAvatarSize — предел размера аватара (AGENT.md 7.2).
|
||||
maxAvatarSize = 8 << 20
|
||||
)
|
||||
|
||||
// uploadPayload — результат загрузки файла: метаданные для вложения.
|
||||
@@ -72,6 +75,115 @@ func (s *Server) registerFileRoutes(api huma.API, router chi.Router) {
|
||||
})
|
||||
|
||||
router.Post("/channels/{channel_id}/files", s.handleFileUpload)
|
||||
router.Post("/users/@me/avatar", s.handleAvatarUpload)
|
||||
router.Delete("/users/@me/avatar", s.handleAvatarDelete)
|
||||
}
|
||||
|
||||
// handleAvatarUpload принимает аватар пользователя (AGENT.md 7.2).
|
||||
func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAvatarSize+maxMultipartOverhead)
|
||||
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r.MultipartForm != nil {
|
||||
_ = r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if header.Size > maxAvatarSize {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large")
|
||||
return
|
||||
}
|
||||
// Аватар читаем в память (не больше 8 МБ): нужно проверить, что это
|
||||
// действительно изображение, а не переименованный файл (AGENT.md 9.2).
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1))
|
||||
if err != nil || int64(len(data)) > maxAvatarSize {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large")
|
||||
return
|
||||
}
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
contentType = http.DetectContentType(data)
|
||||
}
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "avatar must be an image")
|
||||
return
|
||||
}
|
||||
|
||||
// Старый аватар удаляем: файлы не должны копиться (AGENT.md 7.7).
|
||||
if currentUser.AvatarFileID != nil {
|
||||
s.deleteStoredFile(ctx, *currentUser.AvatarFileID)
|
||||
}
|
||||
stored, err := s.saveUpload(ctx, store.File{
|
||||
UploaderID: ¤tUser.ID,
|
||||
Filename: sanitizeFilename(header.Filename),
|
||||
ContentType: contentType,
|
||||
}, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
fileID := stored.ID
|
||||
updated, err := s.store.UpdateUser(ctx, currentUser.ID, store.UpdateUserParams{AvatarFileID: &fileID})
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
s.dispatchUserUpdate(updated)
|
||||
httpxWriteJSON(w, http.StatusOK, map[string]any{"user": s.profileFromUser(ctx, updated, true)})
|
||||
}
|
||||
|
||||
// handleAvatarDelete убирает аватар пользователя.
|
||||
func (s *Server) handleAvatarDelete(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if currentUser.AvatarFileID != nil {
|
||||
s.deleteStoredFile(ctx, *currentUser.AvatarFileID)
|
||||
if err := s.store.ClearAvatar(ctx, currentUser.ID); err != nil {
|
||||
writeHumaAPIError(w, humaError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
updated, err := s.store.GetUser(ctx, currentUser.ID)
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, humaError(err))
|
||||
return
|
||||
}
|
||||
s.dispatchUserUpdate(updated)
|
||||
httpxWriteJSON(w, http.StatusOK, map[string]any{"user": s.profileFromUser(ctx, updated, true)})
|
||||
}
|
||||
|
||||
// deleteStoredFile убирает запись и файл с диска, не ломая основную операцию.
|
||||
func (s *Server) deleteStoredFile(ctx context.Context, fileID uint64) {
|
||||
file, err := s.store.GetFile(ctx, fileID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if file.StoragePath != "" {
|
||||
if err := os.Remove(file.StoragePath); err != nil && !os.IsNotExist(err) {
|
||||
s.logger.WarnContext(ctx, "failed to remove file from disk",
|
||||
slog.String("file_id", formatSnowflake(fileID)), slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteFile(ctx, fileID); err != nil {
|
||||
s.logger.WarnContext(ctx, "failed to delete file record",
|
||||
slog.String("file_id", formatSnowflake(fileID)), slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// registerFileDownload вешает выдачу содержимого файла на корневой роутер:
|
||||
|
||||
@@ -539,8 +539,29 @@ func (s *Server) requireChannelPermission(ctx context.Context, rawChannelID stri
|
||||
return 0, permissions.Resolved{}, nil, humaError(err)
|
||||
}
|
||||
if channel.GuildID == nil {
|
||||
// Личные комнаты появятся в Фазе 4: сейчас их нет.
|
||||
return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found")
|
||||
// Личная беседа: участник получает права на переписку, посторонний
|
||||
// не видит канал вовсе (AGENT.md 7.8).
|
||||
if channel.Type != store.ChannelDM {
|
||||
return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found")
|
||||
}
|
||||
participant, err := s.store.IsDMParticipant(ctx, channelID, user.ID)
|
||||
if err != nil {
|
||||
return 0, permissions.Resolved{}, nil, humaError(err)
|
||||
}
|
||||
if !participant {
|
||||
return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found")
|
||||
}
|
||||
dmPermissions := permissions.ViewChannel | permissions.SendMessages |
|
||||
permissions.ReadMessageHistory | permissions.AttachFiles | permissions.AddReactions
|
||||
resolved := permissions.Resolved{
|
||||
Guild: dmPermissions,
|
||||
Channel: dmPermissions,
|
||||
IsMember: true,
|
||||
}
|
||||
if !resolved.Can(permission) {
|
||||
return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied")
|
||||
}
|
||||
return channelID, resolved, channel, nil
|
||||
}
|
||||
resolved, err := s.perms.Channel(ctx, *channel.GuildID, channelID, user.ID, user.IsInstanceAdmin)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// relationshipUser — профиль собеседника вместе с типом связи (AGENT.md 7.8).
|
||||
type relationshipUser struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CustomStatus string `json:"custom_status,omitempty"`
|
||||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||||
Badges []string `json:"badges"`
|
||||
// VisibleStatus — статус, который видят другие: «невидимка» выглядит как
|
||||
// офлайн, а без активности дольше двух минут показываем офлайн.
|
||||
VisibleStatus string `json:"visible_status"`
|
||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||
Timezone string `json:"timezone"`
|
||||
// Relationship: friend | incoming | outgoing | blocked | none
|
||||
Relationship string `json:"relationship"`
|
||||
}
|
||||
|
||||
type relationshipListOutput struct {
|
||||
Body struct {
|
||||
Friends []relationshipUser `json:"friends"`
|
||||
Incoming []relationshipUser `json:"incoming"`
|
||||
Outgoing []relationshipUser `json:"outgoing"`
|
||||
Blocked []relationshipUser `json:"blocked"`
|
||||
}
|
||||
}
|
||||
|
||||
type userSearchOutput struct {
|
||||
Body struct {
|
||||
Users []relationshipUser `json:"users"`
|
||||
}
|
||||
}
|
||||
|
||||
type dmChannelPayload struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CanSend bool `json:"can_send"`
|
||||
CanView bool `json:"can_view"`
|
||||
Recipient struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
VisibleStatus string `json:"visible_status"`
|
||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"recipient"`
|
||||
LastMessageID string `json:"last_message_id,omitempty"`
|
||||
LastMessageAt string `json:"last_message_at,omitempty"`
|
||||
}
|
||||
|
||||
type dmChannelListOutput struct {
|
||||
Body struct {
|
||||
Channels []dmChannelPayload `json:"channels"`
|
||||
}
|
||||
}
|
||||
|
||||
type dmChannelOutput struct {
|
||||
Body struct {
|
||||
Channel dmChannelPayload `json:"channel"`
|
||||
}
|
||||
}
|
||||
|
||||
// registerSocialRoutes описывает друзей, поиск пользователей и личные беседы
|
||||
// (AGENT.md 7.8; раздел запрошен пользователем вне очереди).
|
||||
func (s *Server) registerSocialRoutes(api huma.API) {
|
||||
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "searchUsers",
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/search",
|
||||
Summary: "Поиск пользователей по логину",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Query string `query:"q" minLength:"2" maxLength:"32"`
|
||||
Limit int `query:"limit" default:"20" minimum:"1" maximum:"50"`
|
||||
},
|
||||
) (*userSearchOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := s.store.SearchUsersByUsername(ctx, strings.TrimSpace(input.Query), user.ID, input.Limit)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &userSearchOutput{}
|
||||
output.Body.Users = make([]relationshipUser, 0, len(users))
|
||||
for i := range users {
|
||||
payload, err := s.relationshipUser(ctx, user.ID, &users[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output.Body.Users = append(output.Body.Users, payload)
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listRelationships",
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/@me/relationships",
|
||||
Summary: "Друзья, входящие и исходящие заявки",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, _ *struct{}) (*relationshipListOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := &relationshipListOutput{}
|
||||
output.Body.Friends, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipFriend)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output.Body.Incoming, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipIncoming); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output.Body.Outgoing, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipOutgoing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output.Body.Blocked, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipBlocked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "sendFriendRequest",
|
||||
Method: http.MethodPost,
|
||||
Path: "/users/@me/relationships",
|
||||
Summary: "Отправить заявку в друзья по логину",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Body struct {
|
||||
Username string `json:"username,omitempty" maxLength:"32"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
},
|
||||
) (*okOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, err := s.relationshipTarget(ctx, input.Body.UserID, input.Body.Username, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Если встречная заявка уже есть — сразу становимся друзьями.
|
||||
reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID)
|
||||
switch {
|
||||
case err == nil && reverse.Type == store.RelationshipIncoming:
|
||||
if err := s.makeFriends(ctx, user.ID, target.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newOKOutput(), nil
|
||||
case err == nil && reverse.Type == store.RelationshipFriend:
|
||||
return newOKOutput(), nil
|
||||
case err != nil && !errors.Is(err, store.ErrNotFound):
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if err := s.store.SetRelationship(ctx, user.ID, target.ID, store.RelationshipOutgoing); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if err := s.store.SetRelationship(ctx, target.ID, user.ID, store.RelationshipIncoming); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
s.dispatchRelationshipUpdate(user.ID, target.ID)
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "acceptFriendRequest",
|
||||
Method: http.MethodPost,
|
||||
Path: "/users/@me/relationships/{user_id}/accept",
|
||||
Summary: "Принять заявку в друзья",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
UserID string `path:"user_id"`
|
||||
},
|
||||
) (*okOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
incoming, err := s.store.GetRelationship(ctx, user.ID, targetID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if incoming.Type != store.RelationshipIncoming {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "no incoming friend request from this user")
|
||||
}
|
||||
if err := s.makeFriends(ctx, user.ID, targetID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "removeRelationship",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/users/@me/relationships/{user_id}",
|
||||
Summary: "Удалить из друзей, отклонить или отменить заявку",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
UserID string `path:"user_id"`
|
||||
},
|
||||
) (*okOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.RemoveRelationship(ctx, user.ID, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if err := s.store.RemoveRelationship(ctx, targetID, user.ID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
s.dispatchRelationshipUpdate(user.ID, targetID)
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "openDirectChannel",
|
||||
Method: http.MethodPost,
|
||||
Path: "/users/@me/channels",
|
||||
Summary: "Открыть личную беседу с пользователем",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Body struct {
|
||||
RecipientID string `json:"recipient_id" minLength:"1"`
|
||||
}
|
||||
},
|
||||
) (*dmChannelOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipientID, err := parseID("recipient_id", input.Body.RecipientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if recipientID == user.ID {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot open a direct channel with yourself")
|
||||
}
|
||||
recipient, err := s.store.GetUser(ctx, recipientID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Оба участника сразу видят беседу в списке (AGENT.md 8.3).
|
||||
for _, participant := range []uint64{user.ID, recipientID} {
|
||||
if s.gateway != nil {
|
||||
s.gateway.SendToUser(participant, "DM_CHANNEL_CREATE", map[string]any{
|
||||
"channel_id": formatSnowflake(channel.ID),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
|
||||
summary := store.DMChannelSummary{
|
||||
Channel: *channel,
|
||||
RecipientID: recipient.ID,
|
||||
RecipientName: recipient.DisplayName,
|
||||
RecipientLogin: recipient.Username,
|
||||
AvatarFileID: recipient.AvatarFileID,
|
||||
Status: recipient.Status,
|
||||
LastSeenAt: recipient.LastSeenAt,
|
||||
Timezone: recipient.Timezone,
|
||||
}
|
||||
output := &dmChannelOutput{}
|
||||
output.Body.Channel = s.dmChannelPayload(summary)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listDirectChannels",
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/@me/channels",
|
||||
Summary: "Личные беседы пользователя",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, _ *struct{}) (*dmChannelListOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channels, err := s.store.ListDMChannels(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &dmChannelListOutput{}
|
||||
output.Body.Channels = make([]dmChannelPayload, 0, len(channels))
|
||||
for _, summary := range channels {
|
||||
output.Body.Channels = append(output.Body.Channels, s.dmChannelPayload(summary))
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
}
|
||||
|
||||
// relationshipTarget находит пользователя по id или логину.
|
||||
func (s *Server) relationshipTarget(ctx context.Context, rawID, username string, selfID uint64) (*store.User, error) {
|
||||
if rawID != "" {
|
||||
targetID, err := parseID("user_id", rawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetID == selfID {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot add yourself as a friend")
|
||||
}
|
||||
target, err := s.store.GetUser(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "username or user_id is required")
|
||||
}
|
||||
target, err := s.store.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, humaErrorStatus(http.StatusNotFound, "user.not_found", "пользователь с таким логином не найден")
|
||||
}
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if target.ID == selfID {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot add yourself as a friend")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// makeFriends делает пользователей друзьями в обе стороны.
|
||||
func (s *Server) makeFriends(ctx context.Context, firstID, secondID uint64) error {
|
||||
if err := s.store.SetRelationship(ctx, firstID, secondID, store.RelationshipFriend); err != nil {
|
||||
return humaError(err)
|
||||
}
|
||||
if err := s.store.SetRelationship(ctx, secondID, firstID, store.RelationshipFriend); err != nil {
|
||||
return humaError(err)
|
||||
}
|
||||
s.dispatchRelationshipUpdate(firstID, secondID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatchRelationshipUpdate уведомляет обоих участников об изменении связи.
|
||||
func (s *Server) dispatchRelationshipUpdate(firstID, secondID uint64) {
|
||||
if s.gateway == nil {
|
||||
return
|
||||
}
|
||||
for _, userID := range []uint64{firstID, secondID} {
|
||||
s.gateway.SendToUser(userID, "RELATIONSHIP_UPDATE", map[string]any{
|
||||
"user_id": formatSnowflake(userID),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// relationshipProfiles собирает список связей указанного типа.
|
||||
func (s *Server) relationshipProfiles(ctx context.Context, userID uint64, kind store.RelationshipType) ([]relationshipUser, error) {
|
||||
profiles, err := s.store.ListRelationships(ctx, userID, kind)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
result := make([]relationshipUser, 0, len(profiles))
|
||||
for i := range profiles {
|
||||
result = append(result, s.relationshipPayload(userID, &profiles[i], string(kind)))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// relationshipUser собирает профиль пользователя со связью (для поиска).
|
||||
func (s *Server) relationshipUser(ctx context.Context, viewerID uint64, user *store.User) (relationshipUser, error) {
|
||||
kind := "none"
|
||||
relation, err := s.store.GetRelationship(ctx, viewerID, user.ID)
|
||||
switch {
|
||||
case err == nil:
|
||||
kind = string(relation.Type)
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
default:
|
||||
return relationshipUser{}, humaError(err)
|
||||
}
|
||||
profile := store.RelationshipProfile{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
AvatarFileID: user.AvatarFileID,
|
||||
Status: user.Status,
|
||||
CustomStatus: user.CustomStatus,
|
||||
Badges: user.Badges,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
LastSeenAt: user.LastSeenAt,
|
||||
Timezone: user.Timezone,
|
||||
}
|
||||
return s.relationshipPayload(viewerID, &profile, kind), nil
|
||||
}
|
||||
|
||||
// relationshipPayload переводит профиль в формат API, вычисляя видимый статус.
|
||||
func (s *Server) relationshipPayload(_ uint64, profile *store.RelationshipProfile, kind string) relationshipUser {
|
||||
payload := relationshipUser{
|
||||
UserID: formatSnowflake(profile.UserID),
|
||||
Username: profile.Username,
|
||||
DisplayName: profile.DisplayName,
|
||||
Status: profile.Status,
|
||||
CustomStatus: profile.CustomStatus,
|
||||
IsInstanceAdmin: profile.IsInstanceAdmin,
|
||||
Badges: profile.Badges,
|
||||
VisibleStatus: visibleStatus(profile.Status, profile.LastSeenAt),
|
||||
Timezone: profile.Timezone,
|
||||
Relationship: kind,
|
||||
}
|
||||
if payload.Badges == nil {
|
||||
payload.Badges = []string{}
|
||||
}
|
||||
if payload.Timezone == "" {
|
||||
payload.Timezone = "Europe/Moscow"
|
||||
}
|
||||
if profile.AvatarFileID != nil {
|
||||
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
|
||||
}
|
||||
if profile.LastSeenAt != nil {
|
||||
payload.LastSeenAt = profile.LastSeenAt.UTC().Format(timeLayout)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// dmChannelPayload собирает личную беседу для API.
|
||||
func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPayload {
|
||||
payload := dmChannelPayload{
|
||||
ID: formatSnowflake(summary.Channel.ID),
|
||||
Type: string(store.ChannelDM),
|
||||
CanSend: true,
|
||||
CanView: true,
|
||||
}
|
||||
payload.Recipient.UserID = formatSnowflake(summary.RecipientID)
|
||||
payload.Recipient.Username = summary.RecipientLogin
|
||||
payload.Recipient.DisplayName = summary.RecipientName
|
||||
payload.Recipient.Status = summary.Status
|
||||
payload.Recipient.Timezone = summary.Timezone
|
||||
if payload.Recipient.Timezone == "" {
|
||||
payload.Recipient.Timezone = "Europe/Moscow"
|
||||
}
|
||||
if summary.LastMessageAt != nil {
|
||||
payload.LastMessageAt = summary.LastMessageAt.UTC().Format(timeLayout)
|
||||
}
|
||||
if summary.LastMessageID != 0 {
|
||||
payload.LastMessageID = formatSnowflake(summary.LastMessageID)
|
||||
}
|
||||
if summary.AvatarFileID != nil {
|
||||
payload.Recipient.AvatarFileID = formatSnowflake(*summary.AvatarFileID)
|
||||
}
|
||||
if summary.LastSeenAt != nil {
|
||||
payload.Recipient.LastSeenAt = summary.LastSeenAt.UTC().Format(timeLayout)
|
||||
}
|
||||
payload.Recipient.VisibleStatus = visibleStatus(summary.Status, summary.LastSeenAt)
|
||||
return payload
|
||||
}
|
||||
|
||||
// timeLayout — единый формат времени в API.
|
||||
const timeLayout = "2006-01-02T15:04:05Z07:00"
|
||||
|
||||
// visibleStatus вычисляет статус, который видят другие пользователи:
|
||||
// «невидимка» отображается как офлайн, как и давно неактивный пользователь
|
||||
// (AGENT.md 7.2).
|
||||
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
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -30,6 +31,11 @@ type profilePayload struct {
|
||||
// TOTPEnabled — включена ли 2FA: клиенту нужно знать, требовать ли код
|
||||
// при step-up и показывать ли QR при настройке (AGENT.md 7.1).
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
// Timezone — часовой пояс пользователя (по умолчанию МСК): по нему клиент
|
||||
// показывает время последнего входа друзьям.
|
||||
Timezone string `json:"timezone"`
|
||||
// LastSeenAt — когда пользователя видели последний раз (для друзей).
|
||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||
}
|
||||
|
||||
// profileFromUser собирает профиль; состояние 2FA читается из сервиса
|
||||
@@ -68,6 +74,13 @@ func profileFromUser(user *store.User, includePrivate bool) profilePayload {
|
||||
if includePrivate {
|
||||
payload.Locale = user.Locale
|
||||
}
|
||||
payload.Timezone = user.Timezone
|
||||
if payload.Timezone == "" {
|
||||
payload.Timezone = "Europe/Moscow"
|
||||
}
|
||||
if user.LastSeenAt != nil {
|
||||
payload.LastSeenAt = user.LastSeenAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -103,6 +116,7 @@ type updateProfileInput struct {
|
||||
CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"`
|
||||
CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"`
|
||||
Locale *string `json:"locale,omitempty" enum:"ru,en"`
|
||||
Timezone *string `json:"timezone,omitempty" maxLength:"64"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +208,11 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
||||
CustomStatusEmoji: input.Body.CustomStatusEmoji,
|
||||
Locale: input.Body.Locale,
|
||||
}
|
||||
if input.Body.Timezone != nil {
|
||||
if _, err := time.LoadLocation(*input.Body.Timezone); err != nil {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown timezone")
|
||||
}
|
||||
}
|
||||
if input.Body.DisplayName != nil {
|
||||
trimmed := strings.TrimSpace(*input.Body.DisplayName)
|
||||
if trimmed == "" {
|
||||
@@ -205,8 +224,21 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3).
|
||||
if input.Body.Timezone != nil {
|
||||
if err := s.store.SetTimezone(ctx, user.ID, *input.Body.Timezone); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
updated, err = s.store.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
}
|
||||
// Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3),
|
||||
// а друзья — обновление присутствия (AGENT.md 7.16).
|
||||
s.dispatchUserUpdate(updated)
|
||||
if input.Body.Status != nil || input.Body.CustomStatus != nil {
|
||||
s.dispatchPresenceUpdate(ctx, updated)
|
||||
}
|
||||
output := &meOutput{}
|
||||
output.Body.User = s.profileFromUser(ctx, updated, true)
|
||||
return output, nil
|
||||
@@ -392,6 +424,30 @@ func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uin
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// dispatchPresenceUpdate рассылает статус пользователя его друзьям.
|
||||
func (s *Server) dispatchPresenceUpdate(ctx context.Context, user *store.User) {
|
||||
if s.gateway == nil {
|
||||
return
|
||||
}
|
||||
friends, err := s.store.ListRelationships(ctx, user.ID, store.RelationshipFriend)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "failed to list friends for presence", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"user_id": formatSnowflake(user.ID),
|
||||
"status": user.Status,
|
||||
"visible_status": visibleStatus(user.Status, user.LastSeenAt),
|
||||
"custom_status": user.CustomStatus,
|
||||
}
|
||||
if user.LastSeenAt != nil {
|
||||
payload["last_seen_at"] = user.LastSeenAt.UTC().Format(timeLayout)
|
||||
}
|
||||
for _, friend := range friends {
|
||||
s.gateway.SendToUser(friend.UserID, "PRESENCE_UPDATE", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchUserUpdate рассылает обновление профиля во все сессии пользователя.
|
||||
func (s *Server) dispatchUserUpdate(user *store.User) {
|
||||
if s.gateway == nil {
|
||||
|
||||
@@ -57,6 +57,9 @@ type Server struct {
|
||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||
slowmodeMu sync.Mutex
|
||||
slowmode map[string]time.Time
|
||||
// presence — время последнего обновления last_seen по пользователю.
|
||||
presenceMu sync.Mutex
|
||||
presence map[uint64]time.Time
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
@@ -82,6 +85,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
searchLimiter: httpx.NewRateLimiter(10, 10),
|
||||
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
||||
slowmode: map[string]time.Time{},
|
||||
presence: map[uint64]time.Time{},
|
||||
}
|
||||
switch {
|
||||
case deps.Permissions != nil:
|
||||
@@ -105,6 +109,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
s.registerMessageRoutes(s.api)
|
||||
s.registerInviteRoutes(s.api)
|
||||
s.registerFileRoutes(s.api, apiRouter)
|
||||
s.registerSocialRoutes(s.api)
|
||||
}
|
||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestFriendRequestFlow проверяет заявки в друзья и поиск пользователей.
|
||||
func TestFriendRequestFlow(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
aliceCookie := registerAndLogin(t, srv, "alice_friend", "alice-friend@example.com")
|
||||
bobCookie := registerAndLogin(t, srv, "bob_friend", "bob-friend@example.com")
|
||||
bob, err := srv.auth.UserByEmail(t.Context(), "bob-friend@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
|
||||
// Поиск по логину находит Боба и показывает состояние связи.
|
||||
search := doJSON(t, srv, http.MethodGet, "/api/v1/users/search?q=bob_fr", "", aliceCookie)
|
||||
if search.Code != http.StatusOK {
|
||||
t.Fatalf("search = %d, body = %s", search.Code, search.Body.String())
|
||||
}
|
||||
found := decodeResponse[struct {
|
||||
Users []struct {
|
||||
Username string `json:"username"`
|
||||
Relationship string `json:"relationship"`
|
||||
} `json:"users"`
|
||||
}](t, search)
|
||||
if len(found.Users) != 1 || found.Users[0].Username != "bob_friend" || found.Users[0].Relationship != "none" {
|
||||
t.Fatalf("search results = %+v", found.Users)
|
||||
}
|
||||
|
||||
// Алиса отправляет заявку: у неё outgoing, у Боба incoming.
|
||||
sent := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_friend"}`, aliceCookie)
|
||||
if sent.Code != http.StatusOK {
|
||||
t.Fatalf("send request = %d, body = %s", sent.Code, sent.Body.String())
|
||||
}
|
||||
bobList := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", bobCookie)
|
||||
bobPayload := decodeResponse[struct {
|
||||
Incoming []struct {
|
||||
Username string `json:"username"`
|
||||
Relationship string `json:"relationship"`
|
||||
} `json:"incoming"`
|
||||
Friends []struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"friends"`
|
||||
}](t, bobList)
|
||||
if len(bobPayload.Incoming) != 1 || bobPayload.Incoming[0].Username != "alice_friend" {
|
||||
t.Fatalf("incoming = %+v", bobPayload.Incoming)
|
||||
}
|
||||
if len(bobPayload.Friends) != 0 {
|
||||
t.Fatalf("friends must be empty before accept: %+v", bobPayload.Friends)
|
||||
}
|
||||
|
||||
// Боб принимает: оба видят друг друга в друзьях.
|
||||
accept := doJSON(t, srv, http.MethodPost,
|
||||
"/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-friend@example.com"))+"/accept",
|
||||
"", bobCookie)
|
||||
if accept.Code != http.StatusOK {
|
||||
t.Fatalf("accept = %d, body = %s", accept.Code, accept.Body.String())
|
||||
}
|
||||
aliceFriends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie)
|
||||
payload := decodeResponse[struct {
|
||||
Friends []struct {
|
||||
Username string `json:"username"`
|
||||
VisibleStatus string `json:"visible_status"`
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"friends"`
|
||||
}](t, aliceFriends)
|
||||
if len(payload.Friends) != 1 || payload.Friends[0].Username != "bob_friend" {
|
||||
t.Fatalf("friends = %+v", payload.Friends)
|
||||
}
|
||||
if payload.Friends[0].Timezone != "Europe/Moscow" {
|
||||
t.Fatalf("default timezone = %q, want Europe/Moscow", payload.Friends[0].Timezone)
|
||||
}
|
||||
|
||||
// Повторная заявка ничего не ломает.
|
||||
repeat := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"user_id":"`+formatSnowflake(bob.ID)+`"}`, aliceCookie)
|
||||
if repeat.Code != http.StatusOK {
|
||||
t.Fatalf("repeat request = %d, body = %s", repeat.Code, repeat.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectChannelMessaging проверяет личные беседы: доступ только участникам.
|
||||
func TestDirectChannelMessaging(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
aliceCookie := registerAndLogin(t, srv, "alice_dm", "alice-dm@example.com")
|
||||
bobCookie := registerAndLogin(t, srv, "bob_dm", "bob-dm@example.com")
|
||||
strangerCookie := registerAndLogin(t, srv, "eve_dm", "eve-dm@example.com")
|
||||
bobID := formatSnowflake(mustUserID(t, srv, "bob-dm@example.com"))
|
||||
|
||||
// Алиса открывает беседу с Бобом.
|
||||
opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
|
||||
`{"recipient_id":"`+bobID+`"}`, aliceCookie)
|
||||
if opened.Code != http.StatusOK {
|
||||
t.Fatalf("open dm = %d, body = %s", opened.Code, opened.Body.String())
|
||||
}
|
||||
channel := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CanSend bool `json:"can_send"`
|
||||
Recipient struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
VisibleStatus string `json:"visible_status"`
|
||||
} `json:"recipient"`
|
||||
} `json:"channel"`
|
||||
}](t, opened)
|
||||
if channel.Channel.Type != "dm" || !channel.Channel.CanSend {
|
||||
t.Fatalf("unexpected dm channel: %+v", channel.Channel)
|
||||
}
|
||||
if channel.Channel.Recipient.UserID != bobID {
|
||||
t.Fatalf("recipient = %q, want %q", channel.Channel.Recipient.UserID, bobID)
|
||||
}
|
||||
|
||||
// Повторное открытие возвращает ту же беседу.
|
||||
again := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
|
||||
`{"recipient_id":"`+bobID+`"}`, aliceCookie)
|
||||
reopened := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"channel"`
|
||||
}](t, again)
|
||||
if reopened.Channel.ID != channel.Channel.ID {
|
||||
t.Fatalf("dm channel changed: %s → %s", channel.Channel.ID, reopened.Channel.ID)
|
||||
}
|
||||
|
||||
// Сообщение из беседы и список бесед у обоих участников.
|
||||
sent := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages",
|
||||
`{"content":"привет в личке"}`, aliceCookie)
|
||||
if sent.Code != http.StatusOK {
|
||||
t.Fatalf("dm message = %d, body = %s", sent.Code, sent.Body.String())
|
||||
}
|
||||
list := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/channels", "", bobCookie)
|
||||
channels := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
ID string `json:"id"`
|
||||
LastMessageID string `json:"last_message_id"`
|
||||
} `json:"channels"`
|
||||
}](t, list)
|
||||
if len(channels.Channels) != 1 || channels.Channels[0].ID != channel.Channel.ID || channels.Channels[0].LastMessageID == "" {
|
||||
t.Fatalf("dm list = %+v", channels.Channels)
|
||||
}
|
||||
|
||||
// Посторонний не видит беседу и не может писать.
|
||||
forbidden := doJSON(t, srv, http.MethodGet, "/api/v1/channels/"+channel.Channel.ID+"/messages", "", strangerCookie)
|
||||
if forbidden.Code != http.StatusNotFound {
|
||||
t.Fatalf("stranger dm read = %d, want 404", forbidden.Code)
|
||||
}
|
||||
forbiddenSend := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages",
|
||||
`{"content":"я тут лишний"}`, strangerCookie)
|
||||
if forbiddenSend.Code != http.StatusNotFound {
|
||||
t.Fatalf("stranger dm write = %d, want 404", forbiddenSend.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvisibleStatusHiddenFromFriends проверяет, что «невидимка» виден как офлайн.
|
||||
func TestInvisibleStatusHiddenFromFriends(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
aliceCookie := registerAndLogin(t, srv, "alice_inv", "alice-inv@example.com")
|
||||
bobCookie := registerAndLogin(t, srv, "bob_inv", "bob-inv@example.com")
|
||||
|
||||
doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_inv"}`, aliceCookie)
|
||||
accept := doJSON(t, srv, http.MethodPost,
|
||||
"/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-inv@example.com"))+"/accept", "", bobCookie)
|
||||
if accept.Code != http.StatusOK {
|
||||
t.Fatalf("accept = %d", accept.Code)
|
||||
}
|
||||
|
||||
// Боб уходит в невидимку.
|
||||
hidden := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"invisible"}`, bobCookie)
|
||||
if hidden.Code != http.StatusOK {
|
||||
t.Fatalf("set invisible = %d, body = %s", hidden.Code, hidden.Body.String())
|
||||
}
|
||||
friends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie)
|
||||
payload := decodeResponse[struct {
|
||||
Friends []struct {
|
||||
Username string `json:"username"`
|
||||
Status string `json:"status"`
|
||||
VisibleStatus string `json:"visible_status"`
|
||||
} `json:"friends"`
|
||||
}](t, friends)
|
||||
if len(payload.Friends) != 1 {
|
||||
t.Fatalf("friends = %+v", payload.Friends)
|
||||
}
|
||||
if payload.Friends[0].VisibleStatus != "offline" {
|
||||
t.Fatalf("invisible user must look offline, got %q", payload.Friends[0].VisibleStatus)
|
||||
}
|
||||
|
||||
// Обычный статус виден как есть.
|
||||
doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"idle"}`, bobCookie)
|
||||
friends = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie)
|
||||
payload = decodeResponse[struct {
|
||||
Friends []struct {
|
||||
Username string `json:"username"`
|
||||
Status string `json:"status"`
|
||||
VisibleStatus string `json:"visible_status"`
|
||||
} `json:"friends"`
|
||||
}](t, friends)
|
||||
if payload.Friends[0].VisibleStatus != "idle" {
|
||||
t.Fatalf("idle status must be visible, got %q", payload.Friends[0].VisibleStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimezoneAndAvatarUpdate проверяет часовой пояс и загрузку аватара.
|
||||
func TestTimezoneAndAvatarUpdate(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
httpServer := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(httpServer.Close)
|
||||
cookie := registerAndLogin(t, srv, "tz_user", "tz-user@example.com")
|
||||
|
||||
// Часовой пояс по умолчанию — МСК, можно сменить.
|
||||
type mePayload struct {
|
||||
User struct {
|
||||
Timezone string `json:"timezone"`
|
||||
AvatarFileID string `json:"avatar_file_id"`
|
||||
} `json:"user"`
|
||||
}
|
||||
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
|
||||
profile := decodeResponse[mePayload](t, me)
|
||||
if profile.User.Timezone != "Europe/Moscow" {
|
||||
t.Fatalf("default timezone = %q", profile.User.Timezone)
|
||||
}
|
||||
updated := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"timezone":"Europe/Berlin"}`, cookie)
|
||||
if updated.Code != http.StatusOK {
|
||||
t.Fatalf("set timezone = %d, body = %s", updated.Code, updated.Body.String())
|
||||
}
|
||||
after := decodeResponse[mePayload](t, updated)
|
||||
if after.User.Timezone != "Europe/Berlin" {
|
||||
t.Fatalf("timezone = %q, want Europe/Berlin", after.User.Timezone)
|
||||
}
|
||||
bad := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"timezone":"Марс/Олимп"}`, cookie)
|
||||
if bad.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("invalid timezone = %d, want 422", bad.Code)
|
||||
}
|
||||
|
||||
// Аватар: загрузка картинки и удаление.
|
||||
fileID := uploadAvatar(t, httpServer, cookie, "me.png",
|
||||
append([]byte("\x89PNG\r\n\x1a\n"), []byte("аватар-данные")...))
|
||||
if fileID == "" {
|
||||
t.Fatal("avatar upload must return a file id")
|
||||
}
|
||||
me = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
|
||||
profile = decodeResponse[mePayload](t, me)
|
||||
if profile.User.AvatarFileID != fileID {
|
||||
t.Fatalf("avatar_file_id = %q, want %q", profile.User.AvatarFileID, fileID)
|
||||
}
|
||||
|
||||
removed := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/avatar", "", cookie)
|
||||
if removed.Code != http.StatusOK {
|
||||
t.Fatalf("delete avatar = %d, body = %s", removed.Code, removed.Body.String())
|
||||
}
|
||||
me = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
|
||||
profile = decodeResponse[mePayload](t, me)
|
||||
if profile.User.AvatarFileID != "" {
|
||||
t.Fatalf("avatar must be cleared, got %q", profile.User.AvatarFileID)
|
||||
}
|
||||
}
|
||||
|
||||
// mustUserID находит пользователя по email и возвращает его идентификатор.
|
||||
func mustUserID(t *testing.T, srv *Server, email string) uint64 {
|
||||
t.Helper()
|
||||
user, err := srv.auth.UserByEmail(t.Context(), email)
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail(%s): %v", email, err)
|
||||
}
|
||||
return user.ID
|
||||
}
|
||||
|
||||
// TestPresenceUpdateReachesFriends проверяет, что смена статуса видна другу
|
||||
// через Gateway без перезагрузки (AGENT.md 7.16).
|
||||
func TestPresenceUpdateReachesFriends(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
httpServer := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(httpServer.Close)
|
||||
|
||||
aliceCookie := registerAndLogin(t, srv, "alice_pres", "alice-pres@example.com")
|
||||
bobCookie := registerAndLogin(t, srv, "bob_pres", "bob-pres@example.com")
|
||||
doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_pres"}`, aliceCookie)
|
||||
doJSON(t, srv, http.MethodPost,
|
||||
"/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-pres@example.com"))+"/accept", "", bobCookie)
|
||||
|
||||
alice := dialGateway(t, httpServer, aliceCookie)
|
||||
doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"dnd"}`, bobCookie)
|
||||
|
||||
frame := alice.expectEvent("PRESENCE_UPDATE")
|
||||
var payload struct {
|
||||
Status string `json:"status"`
|
||||
VisibleStatus string `json:"visible_status"`
|
||||
}
|
||||
if err := json.Unmarshal(frame.D, &payload); err != nil {
|
||||
t.Fatalf("decode PRESENCE_UPDATE: %v", err)
|
||||
}
|
||||
if payload.Status != "dnd" || payload.VisibleStatus != "dnd" {
|
||||
t.Fatalf("presence payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectChannelEventsOnlyForParticipants проверяет фильтрацию событий DM.
|
||||
func TestDirectChannelEventsOnlyForParticipants(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
httpServer := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(httpServer.Close)
|
||||
|
||||
aliceCookie := registerAndLogin(t, srv, "alice_dmev", "alice-dmev@example.com")
|
||||
bobCookie := registerAndLogin(t, srv, "bob_dmev", "bob-dmev@example.com")
|
||||
eveCookie := registerAndLogin(t, srv, "eve_dmev", "eve-dmev@example.com")
|
||||
bobID := formatSnowflake(mustUserID(t, srv, "bob-dmev@example.com"))
|
||||
|
||||
opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+bobID+`"}`, aliceCookie)
|
||||
channel := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"channel"`
|
||||
}](t, opened)
|
||||
|
||||
bob := dialGateway(t, httpServer, bobCookie)
|
||||
eve := dialGateway(t, httpServer, eveCookie)
|
||||
|
||||
doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages",
|
||||
`{"content":"личное сообщение"}`, aliceCookie)
|
||||
bob.expectEvent("MESSAGE_CREATE")
|
||||
eve.expectNoEvent("MESSAGE_CREATE", 700*time.Millisecond)
|
||||
}
|
||||
|
||||
// uploadAvatar загружает аватар через multipart и возвращает file_id.
|
||||
func uploadAvatar(t *testing.T, server *httptest.Server, cookie *http.Cookie, filename string, content []byte) string {
|
||||
t.Helper()
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
partHeader := textproto.MIMEHeader{}
|
||||
partHeader.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`)
|
||||
partHeader.Set("Content-Type", "image/png")
|
||||
part, err := writer.CreatePart(partHeader)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePart: %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("write avatar: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
server.URL+"/api/v1/users/@me/avatar", bytes.NewReader(body.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.AddCookie(cookie)
|
||||
resp, err := server.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("upload avatar: %v", err)
|
||||
}
|
||||
payload, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("upload avatar = %d, body = %s", resp.StatusCode, payload)
|
||||
}
|
||||
var decoded struct {
|
||||
User struct {
|
||||
AvatarFileID string `json:"avatar_file_id"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode avatar response: %v", err)
|
||||
}
|
||||
return decoded.User.AvatarFileID
|
||||
}
|
||||
Reference in New Issue
Block a user