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:
@@ -199,9 +199,8 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User,
|
|||||||
return nil, "", nil, fmt.Errorf("create user: %w", err)
|
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)
|
token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -475,27 +474,6 @@ func (s *Service) createSession(ctx context.Context, userID uint64, userAgent, i
|
|||||||
return token, session, nil
|
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 защищает от перебора: серия неудач блокирует попытки на окно.
|
// checkLockout защищает от перебора: серия неудач блокирует попытки на окно.
|
||||||
func (s *Service) checkLockout(ctx context.Context, userID uint64) error {
|
func (s *Service) checkLockout(ctx context.Context, userID uint64) error {
|
||||||
events, err := s.store.ListSecurityEvents(ctx, userID, 20)
|
events, err := s.store.ListSecurityEvents(ctx, userID, 20)
|
||||||
|
|||||||
@@ -114,10 +114,9 @@ func TestRegisterAndLogin(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Register: %v", err)
|
t.Fatalf("Register: %v", err)
|
||||||
}
|
}
|
||||||
// Главный сервер создаётся установщиком (владелец — уже существующий админ).
|
// Сервер существует (создан установщиком), но новый пользователь в него
|
||||||
|
// не попадает: вход только по приглашению (решение пользователя).
|
||||||
bootstrapMainGuild(t, st, user.ID)
|
bootstrapMainGuild(t, st, user.ID)
|
||||||
// Новый пользователь регистрируется после появления главного сервера,
|
|
||||||
// чтобы проверить автовступление.
|
|
||||||
second, _, _, err := service.Register(ctx, auth.RegisterInput{
|
second, _, _, err := service.Register(ctx, auth.RegisterInput{
|
||||||
Username: "newcomer", Email: "newcomer@example.com", Password: "correct-horse-battery",
|
Username: "newcomer", Email: "newcomer@example.com", Password: "correct-horse-battery",
|
||||||
})
|
})
|
||||||
@@ -128,15 +127,8 @@ func TestRegisterAndLogin(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListGuildsForUser(newcomer): %v", err)
|
t.Fatalf("ListGuildsForUser(newcomer): %v", err)
|
||||||
}
|
}
|
||||||
if len(newcomerGuilds) != 1 || !newcomerGuilds[0].IsMain {
|
if len(newcomerGuilds) != 0 {
|
||||||
t.Fatalf("newcomer must join the main guild, got %+v", newcomerGuilds)
|
t.Fatalf("newcomer must start without guilds, 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 user.ID == 0 || token == "" || session.ID == 0 {
|
if user.ID == 0 || token == "" || session.ID == 0 {
|
||||||
t.Fatal("registration must return user, token and session")
|
t.Fatal("registration must return user, token and session")
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ func TestBootstrapIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewUserJoinsMainGuild(t *testing.T) {
|
// TestNewcomerStartsWithoutGuilds фиксирует решение пользователя: главного
|
||||||
|
// сервера как точки автовхода нет — новичок начинает с пустого списка, а вход
|
||||||
|
// в сервер возможен только по приглашению или созданием своего.
|
||||||
|
func TestNewcomerStartsWithoutGuilds(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
runner, st, authService := newRunner(t)
|
runner, st, authService := newRunner(t)
|
||||||
result, err := runner.Run(ctx, bootstrap.Options{Email: "owner@example.com", Password: "correct-horse-battery"})
|
result, err := runner.Run(ctx, bootstrap.Options{Email: "owner@example.com", Password: "correct-horse-battery"})
|
||||||
@@ -174,17 +177,16 @@ func TestNewUserJoinsMainGuild(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListGuildsForUser: %v", err)
|
t.Fatalf("ListGuildsForUser: %v", err)
|
||||||
}
|
}
|
||||||
if len(guilds) != 1 || guilds[0].ID != result.GuildID {
|
if len(guilds) != 0 {
|
||||||
t.Fatalf("newcomer must join the main guild: %+v", guilds)
|
t.Fatalf("newcomer must start without guilds: %+v", guilds)
|
||||||
}
|
}
|
||||||
roles, err := st.MemberRoles(ctx, result.GuildID, user.ID)
|
|
||||||
|
// Сервер установщика остаётся рабочим: владелец в нём с ролью администратора.
|
||||||
|
ownerRoles, err := st.MemberRoles(ctx, result.GuildID, result.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("MemberRoles: %v", err)
|
t.Fatalf("MemberRoles(owner): %v", err)
|
||||||
}
|
}
|
||||||
if len(roles) != 1 || !roles[0].IsDefault {
|
if len(ownerRoles) != 1 || !permissions.Permission(ownerRoles[0].Permissions).Has(permissions.Administrator) {
|
||||||
t.Fatalf("newcomer must get the default role: %+v", roles)
|
t.Fatalf("owner must keep the administrator role: %+v", ownerRoles)
|
||||||
}
|
|
||||||
if !permissions.Permission(roles[0].Permissions).Has(permissions.ViewGuild) {
|
|
||||||
t.Fatal("default role must allow viewing the guild")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Фаза 4 (по запросу): друзья, личные беседы, присутствие и часовой пояс.
|
||||||
|
|
||||||
|
CREATE TABLE relationships (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
target_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
-- friend | outgoing (заявка отправлена) | incoming (заявка получена) | blocked
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
PRIMARY KEY (user_id, target_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX relationships_type_idx ON relationships (user_id, type);
|
||||||
|
CREATE INDEX relationships_target_idx ON relationships (target_id, type);
|
||||||
|
|
||||||
|
-- Участники личной беседы: канал типа dm без сервера (AGENT.md 7.8).
|
||||||
|
CREATE TABLE dm_participants (
|
||||||
|
channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
PRIMARY KEY (channel_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX dm_participants_user_idx ON dm_participants (user_id);
|
||||||
|
|
||||||
|
-- Присутствие: когда пользователя видели в последний раз и его часовой пояс
|
||||||
|
-- для показа времени друзьям.
|
||||||
|
ALTER TABLE users ADD COLUMN last_seen_at TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'Europe/Moscow';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE users DROP COLUMN timezone;
|
||||||
|
ALTER TABLE users DROP COLUMN last_seen_at;
|
||||||
|
DROP TABLE dm_participants;
|
||||||
|
DROP TABLE relationships;
|
||||||
@@ -90,6 +90,12 @@ type ReadyChannel struct {
|
|||||||
Slowmode int `json:"slowmode_seconds,omitempty"`
|
Slowmode int `json:"slowmode_seconds,omitempty"`
|
||||||
CanSend bool `json:"can_send"`
|
CanSend bool `json:"can_send"`
|
||||||
CanConnect bool `json:"can_connect"`
|
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 {
|
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 {
|
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
|
return true
|
||||||
}
|
}
|
||||||
instanceAdmin := session.user != nil && session.user.IsInstanceAdmin
|
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 {
|
func registerUser(t *testing.T, f *fixture, username, email string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, token, _, err := f.auth.Register(context.Background(), auth.RegisterInput{
|
_, 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) {
|
func TestHelloAndReady(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
token := registerUser(t, f, "gateway_user", "gateway@example.com")
|
token := registerUser(t, f, "gateway_user", "gateway@example.com")
|
||||||
|
// Автовступления больше нет: в сервер вступаем явно, как по приглашению.
|
||||||
|
joinMainGuildForTest(t, f, token)
|
||||||
|
|
||||||
conn, hello := f.dial(t)
|
conn, hello := f.dial(t)
|
||||||
var helloPayload struct {
|
var helloPayload struct {
|
||||||
@@ -164,12 +191,9 @@ func TestHelloAndReady(t *testing.T) {
|
|||||||
t.Fatalf("READY user = %q", snapshot.User.Username)
|
t.Fatalf("READY user = %q", snapshot.User.Username)
|
||||||
}
|
}
|
||||||
if len(snapshot.Guilds) != 1 {
|
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]
|
guild := snapshot.Guilds[0]
|
||||||
if !guild.IsMain {
|
|
||||||
t.Fatal("the main guild must be marked is_main")
|
|
||||||
}
|
|
||||||
if len(guild.Roles) != 2 {
|
if len(guild.Roles) != 2 {
|
||||||
t.Fatalf("main guild roles = %d, want 2", len(guild.Roles))
|
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")
|
token := registerUser(t, f, "private_user", "private@example.com")
|
||||||
|
joinMainGuildForTest(t, f, token)
|
||||||
conn, _ := f.dial(t)
|
conn, _ := f.dial(t)
|
||||||
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
|
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
|
||||||
ready := readEnvelope(t, conn)
|
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)
|
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list user guilds: %w", err)
|
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
|
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 {
|
func formatID(id uint64) string {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
|
||||||
@@ -39,11 +41,39 @@ func (s *Server) sessionContext(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.touchPresence(user.ID)
|
||||||
ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session})
|
ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session})
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
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).
|
// sessionToken читает токен сессии из cookie (браузер) или Bearer (desktop).
|
||||||
func sessionToken(r *http.Request) string {
|
func sessionToken(r *http.Request) string {
|
||||||
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
|
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -27,6 +28,8 @@ const (
|
|||||||
maxMultipartOverhead = 1 << 20
|
maxMultipartOverhead = 1 << 20
|
||||||
// maxMultipartMemory — сколько multipart держим в памяти, остальное — на диске.
|
// maxMultipartMemory — сколько multipart держим в памяти, остальное — на диске.
|
||||||
maxMultipartMemory = 8 << 20
|
maxMultipartMemory = 8 << 20
|
||||||
|
// maxAvatarSize — предел размера аватара (AGENT.md 7.2).
|
||||||
|
maxAvatarSize = 8 << 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// uploadPayload — результат загрузки файла: метаданные для вложения.
|
// 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("/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 вешает выдачу содержимого файла на корневой роутер:
|
// registerFileDownload вешает выдачу содержимого файла на корневой роутер:
|
||||||
|
|||||||
@@ -539,9 +539,30 @@ func (s *Server) requireChannelPermission(ctx context.Context, rawChannelID stri
|
|||||||
return 0, permissions.Resolved{}, nil, humaError(err)
|
return 0, permissions.Resolved{}, nil, humaError(err)
|
||||||
}
|
}
|
||||||
if channel.GuildID == nil {
|
if channel.GuildID == nil {
|
||||||
// Личные комнаты появятся в Фазе 4: сейчас их нет.
|
// Личная беседа: участник получает права на переписку, посторонний
|
||||||
|
// не видит канал вовсе (AGENT.md 7.8).
|
||||||
|
if channel.Type != store.ChannelDM {
|
||||||
return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found")
|
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)
|
resolved, err := s.perms.Channel(ctx, *channel.GuildID, channelID, user.ID, user.IsInstanceAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, permissions.Resolved{}, nil, humaError(err)
|
return 0, permissions.Resolved{}, nil, humaError(err)
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,6 +31,11 @@ type profilePayload struct {
|
|||||||
// TOTPEnabled — включена ли 2FA: клиенту нужно знать, требовать ли код
|
// TOTPEnabled — включена ли 2FA: клиенту нужно знать, требовать ли код
|
||||||
// при step-up и показывать ли QR при настройке (AGENT.md 7.1).
|
// при step-up и показывать ли QR при настройке (AGENT.md 7.1).
|
||||||
TOTPEnabled bool `json:"totp_enabled"`
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
|
// Timezone — часовой пояс пользователя (по умолчанию МСК): по нему клиент
|
||||||
|
// показывает время последнего входа друзьям.
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
// LastSeenAt — когда пользователя видели последний раз (для друзей).
|
||||||
|
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// profileFromUser собирает профиль; состояние 2FA читается из сервиса
|
// profileFromUser собирает профиль; состояние 2FA читается из сервиса
|
||||||
@@ -68,6 +74,13 @@ func profileFromUser(user *store.User, includePrivate bool) profilePayload {
|
|||||||
if includePrivate {
|
if includePrivate {
|
||||||
payload.Locale = user.Locale
|
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
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +116,7 @@ type updateProfileInput struct {
|
|||||||
CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"`
|
CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"`
|
||||||
CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"`
|
CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"`
|
||||||
Locale *string `json:"locale,omitempty" enum:"ru,en"`
|
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,
|
CustomStatusEmoji: input.Body.CustomStatusEmoji,
|
||||||
Locale: input.Body.Locale,
|
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 {
|
if input.Body.DisplayName != nil {
|
||||||
trimmed := strings.TrimSpace(*input.Body.DisplayName)
|
trimmed := strings.TrimSpace(*input.Body.DisplayName)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -205,8 +224,21 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
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)
|
s.dispatchUserUpdate(updated)
|
||||||
|
if input.Body.Status != nil || input.Body.CustomStatus != nil {
|
||||||
|
s.dispatchPresenceUpdate(ctx, updated)
|
||||||
|
}
|
||||||
output := &meOutput{}
|
output := &meOutput{}
|
||||||
output.Body.User = s.profileFromUser(ctx, updated, true)
|
output.Body.User = s.profileFromUser(ctx, updated, true)
|
||||||
return output, nil
|
return output, nil
|
||||||
@@ -392,6 +424,30 @@ func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uin
|
|||||||
return summary, nil
|
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 рассылает обновление профиля во все сессии пользователя.
|
// dispatchUserUpdate рассылает обновление профиля во все сессии пользователя.
|
||||||
func (s *Server) dispatchUserUpdate(user *store.User) {
|
func (s *Server) dispatchUserUpdate(user *store.User) {
|
||||||
if s.gateway == nil {
|
if s.gateway == nil {
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ type Server struct {
|
|||||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||||
slowmodeMu sync.Mutex
|
slowmodeMu sync.Mutex
|
||||||
slowmode map[string]time.Time
|
slowmode map[string]time.Time
|
||||||
|
// presence — время последнего обновления last_seen по пользователю.
|
||||||
|
presenceMu sync.Mutex
|
||||||
|
presence map[uint64]time.Time
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
http *http.Server
|
http *http.Server
|
||||||
static *staticHandler
|
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),
|
searchLimiter: httpx.NewRateLimiter(10, 10),
|
||||||
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
||||||
slowmode: map[string]time.Time{},
|
slowmode: map[string]time.Time{},
|
||||||
|
presence: map[uint64]time.Time{},
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case deps.Permissions != nil:
|
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.registerMessageRoutes(s.api)
|
||||||
s.registerInviteRoutes(s.api)
|
s.registerInviteRoutes(s.api)
|
||||||
s.registerFileRoutes(s.api, apiRouter)
|
s.registerFileRoutes(s.api, apiRouter)
|
||||||
|
s.registerSocialRoutes(s.api)
|
||||||
}
|
}
|
||||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelationshipType — тип связи между пользователями (AGENT.md 7.8).
|
||||||
|
type RelationshipType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RelationshipFriend RelationshipType = "friend"
|
||||||
|
RelationshipOutgoing RelationshipType = "outgoing"
|
||||||
|
RelationshipIncoming RelationshipType = "incoming"
|
||||||
|
RelationshipBlocked RelationshipType = "blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Relationship — связь «пользователь → другой пользователь».
|
||||||
|
type Relationship struct {
|
||||||
|
UserID uint64
|
||||||
|
TargetID uint64
|
||||||
|
Type RelationshipType
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRelationship записывает связь в одну сторону.
|
||||||
|
func (s *Store) SetRelationship(ctx context.Context, userID, targetID uint64, kind RelationshipType) error {
|
||||||
|
_, err := s.writer.ExecContext(ctx, `
|
||||||
|
INSERT INTO relationships (user_id, target_id, type, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (user_id, target_id) DO UPDATE SET type = excluded.type, updated_at = excluded.updated_at`,
|
||||||
|
int64(userID), int64(targetID), string(kind), s.Now(), s.Now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRelationship удаляет связь в одну сторону.
|
||||||
|
func (s *Store) RemoveRelationship(ctx context.Context, userID, targetID uint64) error {
|
||||||
|
_, err := s.writer.ExecContext(ctx,
|
||||||
|
`DELETE FROM relationships WHERE user_id = ? AND target_id = ?`, int64(userID), int64(targetID))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRelationship возвращает связь или ErrNotFound.
|
||||||
|
func (s *Store) GetRelationship(ctx context.Context, userID, targetID uint64) (*Relationship, error) {
|
||||||
|
var (
|
||||||
|
relation Relationship
|
||||||
|
createdAt string
|
||||||
|
)
|
||||||
|
err := s.reader.QueryRowContext(ctx, `
|
||||||
|
SELECT user_id, target_id, type, created_at FROM relationships
|
||||||
|
WHERE user_id = ? AND target_id = ?`, int64(userID), int64(targetID)).
|
||||||
|
Scan(&relation.UserID, &relation.TargetID, &relation.Type, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
relation.CreatedAt = parseTimestamp(createdAt)
|
||||||
|
return &relation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRelationships возвращает связи пользователя указанного типа вместе с
|
||||||
|
// профилями собеседников (для списка друзей).
|
||||||
|
func (s *Store) ListRelationships(ctx context.Context, userID uint64, kind RelationshipType) ([]RelationshipProfile, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
|
SELECT r.target_id, r.created_at, u.username, u.display_name, u.avatar_file_id,
|
||||||
|
u.status, u.custom_status, u.badges_json, u.last_seen_at, u.timezone, u.is_instance_admin
|
||||||
|
FROM relationships r
|
||||||
|
JOIN users u ON u.id = r.target_id
|
||||||
|
WHERE r.user_id = ? AND r.type = ? AND u.deleted_at IS NULL
|
||||||
|
ORDER BY u.username`, int64(userID), string(kind))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
profiles := make([]RelationshipProfile, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
profile, err := scanRelationshipProfile(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
profiles = append(profiles, *profile)
|
||||||
|
}
|
||||||
|
return profiles, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationshipProfile — связь вместе с публичным профилем собеседника.
|
||||||
|
type RelationshipProfile struct {
|
||||||
|
UserID uint64
|
||||||
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
AvatarFileID *uint64
|
||||||
|
Status string
|
||||||
|
CustomStatus string
|
||||||
|
Badges []string
|
||||||
|
IsInstanceAdmin bool
|
||||||
|
LastSeenAt *time.Time
|
||||||
|
Timezone string
|
||||||
|
Since time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountFriends возвращает число друзей пользователя.
|
||||||
|
func (s *Store) CountFriends(ctx context.Context, userID uint64) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM relationships WHERE user_id = ? AND type = 'friend'`,
|
||||||
|
int64(userID)).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMParticipants возвращает участников личной беседы.
|
||||||
|
func (s *Store) DMParticipants(ctx context.Context, channelID uint64) ([]uint64, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx,
|
||||||
|
`SELECT user_id FROM dm_participants WHERE channel_id = ? ORDER BY joined_at`, int64(channelID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
users := make([]uint64, 0, 2)
|
||||||
|
for rows.Next() {
|
||||||
|
var userID uint64
|
||||||
|
if err := rows.Scan(&userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users = append(users, userID)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDMParticipant проверяет участие пользователя в личной беседе.
|
||||||
|
func (s *Store) IsDMParticipant(ctx context.Context, channelID, userID uint64) (bool, error) {
|
||||||
|
var exists int
|
||||||
|
err := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT 1 FROM dm_participants WHERE channel_id = ? AND user_id = ?`,
|
||||||
|
int64(channelID), int64(userID)).Scan(&exists)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDMChannel создаёт личную беседу двух пользователей.
|
||||||
|
func (s *Store) CreateDMChannel(ctx context.Context, firstID, secondID uint64) (*Channel, error) {
|
||||||
|
channelID := s.NextID()
|
||||||
|
if err := s.InTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO channels (id, guild_id, type, name, position, created_at)
|
||||||
|
VALUES (?, NULL, 'dm', '', 0, ?)`, int64(channelID), s.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, userID := range []uint64{firstID, secondID} {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO dm_participants (channel_id, user_id, joined_at) VALUES (?, ?, ?)`,
|
||||||
|
int64(channelID), int64(userID), s.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetChannel(ctx, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindDMChannel ищет существующую личную беседу двух пользователей.
|
||||||
|
func (s *Store) FindDMChannel(ctx context.Context, firstID, secondID uint64) (*Channel, error) {
|
||||||
|
var channelID uint64
|
||||||
|
err := s.reader.QueryRowContext(ctx, `
|
||||||
|
SELECT p1.channel_id
|
||||||
|
FROM dm_participants p1
|
||||||
|
JOIN dm_participants p2 ON p2.channel_id = p1.channel_id
|
||||||
|
JOIN channels c ON c.id = p1.channel_id
|
||||||
|
WHERE p1.user_id = ? AND p2.user_id = ? AND c.type = 'dm'
|
||||||
|
ORDER BY p1.channel_id LIMIT 1`, int64(firstID), int64(secondID)).Scan(&channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetChannel(ctx, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMChannelSummary — личная беседа со собеседником и последним сообщением.
|
||||||
|
type DMChannelSummary struct {
|
||||||
|
Channel Channel
|
||||||
|
RecipientID uint64
|
||||||
|
RecipientName string
|
||||||
|
RecipientLogin string
|
||||||
|
AvatarFileID *uint64
|
||||||
|
Status string
|
||||||
|
LastSeenAt *time.Time
|
||||||
|
Timezone string
|
||||||
|
LastMessageID uint64
|
||||||
|
LastMessageAt *time.Time
|
||||||
|
LastAuthorID *uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDMChannels возвращает личные беседы пользователя.
|
||||||
|
func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
|
SELECT c.id, c.created_at, u.id, u.display_name, u.username, u.avatar_file_id, u.status,
|
||||||
|
u.last_seen_at, u.timezone,
|
||||||
|
(SELECT m.id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
|
||||||
|
(SELECT m.created_at FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
|
||||||
|
(SELECT m.author_id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1)
|
||||||
|
FROM dm_participants p
|
||||||
|
JOIN channels c ON c.id = p.channel_id AND c.type = 'dm'
|
||||||
|
JOIN dm_participants other ON other.channel_id = c.id AND other.user_id <> p.user_id
|
||||||
|
JOIN users u ON u.id = other.user_id AND u.deleted_at IS NULL
|
||||||
|
WHERE p.user_id = ?
|
||||||
|
ORDER BY c.id DESC`, int64(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
channels := make([]DMChannelSummary, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
summary DMChannelSummary
|
||||||
|
createdAt string
|
||||||
|
avatarID sql.NullInt64
|
||||||
|
lastSeenAt sql.NullString
|
||||||
|
lastMessageID sql.NullInt64
|
||||||
|
lastMessageAt sql.NullString
|
||||||
|
lastAuthorID sql.NullInt64
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&summary.Channel.ID, &createdAt, &summary.RecipientID, &summary.RecipientName,
|
||||||
|
&summary.RecipientLogin, &avatarID, &summary.Status, &lastSeenAt, &summary.Timezone,
|
||||||
|
&lastMessageID, &lastMessageAt, &lastAuthorID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastSeenAt.Valid {
|
||||||
|
value := parseTimestamp(lastSeenAt.String)
|
||||||
|
summary.LastSeenAt = &value
|
||||||
|
}
|
||||||
|
summary.Channel.Type = ChannelDM
|
||||||
|
summary.Channel.CreatedAt = parseTimestamp(createdAt)
|
||||||
|
summary.AvatarFileID = optionalID(avatarID)
|
||||||
|
if lastMessageID.Valid {
|
||||||
|
summary.LastMessageID = uint64(lastMessageID.Int64)
|
||||||
|
}
|
||||||
|
if lastMessageAt.Valid {
|
||||||
|
value := parseTimestamp(lastMessageAt.String)
|
||||||
|
summary.LastMessageAt = &value
|
||||||
|
}
|
||||||
|
summary.LastAuthorID = optionalID(lastAuthorID)
|
||||||
|
channels = append(channels, summary)
|
||||||
|
}
|
||||||
|
return channels, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchLastSeen обновляет время последней активности пользователя.
|
||||||
|
func (s *Store) TouchLastSeen(ctx context.Context, userID uint64) error {
|
||||||
|
_, err := s.writer.ExecContext(ctx,
|
||||||
|
`UPDATE users SET last_seen_at = ? WHERE id = ? AND deleted_at IS NULL`, s.Now(), int64(userID))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTimezone сохраняет выбранный часовой пояс пользователя.
|
||||||
|
func (s *Store) SetTimezone(ctx context.Context, userID uint64, timezone string) error {
|
||||||
|
_, err := s.writer.ExecContext(ctx,
|
||||||
|
`UPDATE users SET timezone = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
timezone, s.Now(), int64(userID))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsersByUsername ищет пользователей по началу username (для поиска друзей).
|
||||||
|
func (s *Store) SearchUsersByUsername(ctx context.Context, query string, excludeID uint64, limit int) ([]User, error) {
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
// ESCAPE обязателен: логины содержат «_», который в LIKE — подстановочный
|
||||||
|
// символ, поэтому экранируем его и сообщаем об этом SQLite.
|
||||||
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
|
SELECT `+userColumns+` FROM users
|
||||||
|
WHERE deleted_at IS NULL AND id <> ? AND username_lower LIKE ? ESCAPE '\'
|
||||||
|
ORDER BY username_lower LIMIT ?`,
|
||||||
|
int64(excludeID), escapeLike(strings.ToLower(query))+"%", limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
users := make([]User, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
user, err := scanUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users = append(users, *user)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeLike экранирует служебные символы LIKE, чтобы поиск не превращался в шаблон.
|
||||||
|
func escapeLike(value string) string {
|
||||||
|
replacer := []struct{ from, to string }{
|
||||||
|
{"\\", "\\\\"},
|
||||||
|
{"%", "\\%"},
|
||||||
|
{"_", "\\_"},
|
||||||
|
}
|
||||||
|
result := value
|
||||||
|
for _, rule := range replacer {
|
||||||
|
result = strings.ReplaceAll(result, rule.from, rule.to)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRelationshipProfile(scanner interface{ Scan(...any) error }) (*RelationshipProfile, error) {
|
||||||
|
var (
|
||||||
|
profile RelationshipProfile
|
||||||
|
avatarID sql.NullInt64
|
||||||
|
badges string
|
||||||
|
lastSeen sql.NullString
|
||||||
|
isAdmin int
|
||||||
|
createdAt string
|
||||||
|
)
|
||||||
|
err := scanner.Scan(&profile.UserID, &createdAt, &profile.Username, &profile.DisplayName, &avatarID,
|
||||||
|
&profile.Status, &profile.CustomStatus, &badges, &lastSeen, &profile.Timezone, &isAdmin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
profile.AvatarFileID = optionalID(avatarID)
|
||||||
|
profile.IsInstanceAdmin = isAdmin == 1
|
||||||
|
if err := json.Unmarshal([]byte(badges), &profile.Badges); err != nil {
|
||||||
|
profile.Badges = nil
|
||||||
|
}
|
||||||
|
if lastSeen.Valid {
|
||||||
|
value := parseTimestamp(lastSeen.String)
|
||||||
|
profile.LastSeenAt = &value
|
||||||
|
}
|
||||||
|
profile.Since = parseTimestamp(createdAt)
|
||||||
|
return &profile, nil
|
||||||
|
}
|
||||||
+22
-2
@@ -31,6 +31,10 @@ type User struct {
|
|||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
// OnboardingCompletedAt заполняется после первичной настройки (AGENT.md 7.2).
|
// OnboardingCompletedAt заполняется после первичной настройки (AGENT.md 7.2).
|
||||||
OnboardingCompletedAt *time.Time
|
OnboardingCompletedAt *time.Time
|
||||||
|
// LastSeenAt — время последней активности (для статусов друзей).
|
||||||
|
LastSeenAt *time.Time
|
||||||
|
// Timezone — часовой пояс пользователя (IANA, по умолчанию Europe/Moscow).
|
||||||
|
Timezone string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUserParams — данные новой учётной записи: шифрование и blind index
|
// CreateUserParams — данные новой учётной записи: шифрование и blind index
|
||||||
@@ -48,7 +52,7 @@ type CreateUserParams struct {
|
|||||||
const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id,
|
const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id,
|
||||||
banner_file_id, bio, status, custom_status, custom_status_emoji, flags,
|
banner_file_id, bio, status, custom_status, custom_status_emoji, flags,
|
||||||
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at,
|
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) {
|
func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) {
|
||||||
if params.ID == 0 {
|
if params.ID == 0 {
|
||||||
@@ -235,6 +239,14 @@ func (s *Store) MarkOnboardingCompleted(ctx context.Context, id uint64) error {
|
|||||||
return nil
|
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).
|
// ListUsers отдаёт страницу пользователей для админ-панели инстанса (AGENT.md 6.5).
|
||||||
func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error) {
|
func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error) {
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
@@ -273,13 +285,14 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
|||||||
updatedAt string
|
updatedAt string
|
||||||
deletedAt sql.NullString
|
deletedAt sql.NullString
|
||||||
onboardingAt sql.NullString
|
onboardingAt sql.NullString
|
||||||
|
lastSeenAt sql.NullString
|
||||||
emailEncrypted string
|
emailEncrypted string
|
||||||
)
|
)
|
||||||
err := scanner.Scan(
|
err := scanner.Scan(
|
||||||
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
|
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
|
||||||
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
|
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
|
||||||
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
|
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
|
||||||
&onboardingAt,
|
&onboardingAt, &lastSeenAt, &user.Timezone,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
@@ -306,6 +319,13 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
|||||||
value := parseTimestamp(onboardingAt.String)
|
value := parseTimestamp(onboardingAt.String)
|
||||||
user.OnboardingCompletedAt = &value
|
user.OnboardingCompletedAt = &value
|
||||||
}
|
}
|
||||||
|
if lastSeenAt.Valid {
|
||||||
|
value := parseTimestamp(lastSeenAt.String)
|
||||||
|
user.LastSeenAt = &value
|
||||||
|
}
|
||||||
|
if user.Timezone == "" {
|
||||||
|
user.Timezone = "Europe/Moscow"
|
||||||
|
}
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { Fragment, type ReactNode } from 'react';
|
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Лёгкий безопасный рендер разметки сообщения.
|
* Лёгкий безопасный рендер разметки сообщения.
|
||||||
*
|
*
|
||||||
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
|
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
|
||||||
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
|
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
|
||||||
* `*курсив*`, `` `код` ``, ```блок кода```, `~~зачёркнутый~~`, `> цитата`,
|
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
|
||||||
* ссылки `http(s)://…` и упоминания `<@id>`.
|
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
|
||||||
|
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`.
|
||||||
|
*
|
||||||
|
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
|
||||||
|
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface MessageContentProps {
|
export interface MessageContentProps {
|
||||||
@@ -16,8 +23,13 @@ export interface MessageContentProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
|
||||||
|
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
|
||||||
|
* ссылкой. Подчёркивания внутри слова (`foo_bar_baz`) курсивом не выделяются.
|
||||||
|
*/
|
||||||
const INLINE_PATTERN =
|
const INLINE_PATTERN =
|
||||||
/(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(~~[^~\n]+~~)|(<@\d+>)|(https?:\/\/[^\s<>()]+)/gu;
|
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|(\*[^*\n]+\*)|((?<![\p{L}\p{N}_])_[^_\n]+_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
|
||||||
|
|
||||||
/** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */
|
/** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */
|
||||||
function splitLink(raw: string): { href: string; trailing: string } {
|
function splitLink(raw: string): { href: string; trailing: string } {
|
||||||
@@ -32,9 +44,33 @@ function splitLink(raw: string): { href: string; trailing: string } {
|
|||||||
|
|
||||||
type MentionResolver = MessageContentProps['resolveMention'];
|
type MentionResolver = MessageContentProps['resolveMention'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Спойлер: текст скрыт до клика. Это кнопка, поэтому состояние живёт внутри
|
||||||
|
* узла, а сам текст остаётся в DOM (скринридеру он тоже доступен).
|
||||||
|
*/
|
||||||
|
function Spoiler({ children }: { children: ReactNode }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={revealed}
|
||||||
|
aria-label={t(revealed ? 'chat.spoiler.hide' : 'chat.spoiler.show')}
|
||||||
|
onClick={() => setRevealed((value) => !value)}
|
||||||
|
className={`rounded-[var(--radius-sm)] px-0.5 text-left transition-colors duration-[var(--duration-fast)] ${
|
||||||
|
revealed ? 'bg-surface-3/60' : 'bg-fg-muted/40 text-transparent hover:bg-fg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderInline(
|
function renderInline(
|
||||||
text: string,
|
text: string,
|
||||||
resolveMention: MentionResolver,
|
resolveMention: MentionResolver,
|
||||||
|
channels: ReadonlySet<string>,
|
||||||
keyPrefix: string,
|
keyPrefix: string,
|
||||||
): ReactNode[] {
|
): ReactNode[] {
|
||||||
const nodes: ReactNode[] = [];
|
const nodes: ReactNode[] = [];
|
||||||
@@ -59,20 +95,42 @@ function renderInline(
|
|||||||
{token.slice(1, -1)}
|
{token.slice(1, -1)}
|
||||||
</code>,
|
</code>,
|
||||||
);
|
);
|
||||||
|
} else if (token.startsWith('***')) {
|
||||||
|
nodes.push(
|
||||||
|
<strong key={key} className="font-semibold">
|
||||||
|
<em>{token.slice(3, -3)}</em>
|
||||||
|
</strong>,
|
||||||
|
);
|
||||||
} else if (token.startsWith('**')) {
|
} else if (token.startsWith('**')) {
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<strong key={key} className="font-semibold">
|
<strong key={key} className="font-semibold">
|
||||||
{token.slice(2, -2)}
|
{token.slice(2, -2)}
|
||||||
</strong>,
|
</strong>,
|
||||||
);
|
);
|
||||||
|
} else if (token.startsWith('__')) {
|
||||||
|
nodes.push(
|
||||||
|
<strong key={key} className="font-semibold">
|
||||||
|
{token.slice(2, -2)}
|
||||||
|
</strong>,
|
||||||
|
);
|
||||||
} else if (token.startsWith('~~')) {
|
} else if (token.startsWith('~~')) {
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<s key={key} className="opacity-70">
|
<s key={key} className="opacity-70">
|
||||||
{token.slice(2, -2)}
|
{token.slice(2, -2)}
|
||||||
</s>,
|
</s>,
|
||||||
);
|
);
|
||||||
|
} else if (token.startsWith('||')) {
|
||||||
|
nodes.push(<Spoiler key={key}>{token.slice(2, -2)}</Spoiler>);
|
||||||
} else if (token.startsWith('*')) {
|
} else if (token.startsWith('*')) {
|
||||||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
|
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||||
|
} else if (token.startsWith('_')) {
|
||||||
|
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||||
|
} else if (token.startsWith('==')) {
|
||||||
|
nodes.push(
|
||||||
|
<mark key={key} className="rounded-[var(--radius-sm)] bg-accent/30 px-0.5 text-fg">
|
||||||
|
{token.slice(2, -2)}
|
||||||
|
</mark>,
|
||||||
|
);
|
||||||
} else if (token.startsWith('<@')) {
|
} else if (token.startsWith('<@')) {
|
||||||
const userId = token.slice(2, -1);
|
const userId = token.slice(2, -1);
|
||||||
const mention = resolveMention?.(userId) ?? null;
|
const mention = resolveMention?.(userId) ?? null;
|
||||||
@@ -86,7 +144,7 @@ function renderInline(
|
|||||||
@{mention?.name ?? userId}
|
@{mention?.name ?? userId}
|
||||||
</span>,
|
</span>,
|
||||||
);
|
);
|
||||||
} else {
|
} else if (token.startsWith('http')) {
|
||||||
const { href, trailing } = splitLink(token);
|
const { href, trailing } = splitLink(token);
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<Fragment key={key}>
|
<Fragment key={key}>
|
||||||
@@ -101,6 +159,19 @@ function renderInline(
|
|||||||
{trailing}
|
{trailing}
|
||||||
</Fragment>,
|
</Fragment>,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
|
||||||
|
const name = token.slice(1);
|
||||||
|
const known = channels.has(name.toLowerCase());
|
||||||
|
nodes.push(
|
||||||
|
known ? (
|
||||||
|
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
|
||||||
|
{token}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
token
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
lastIndex = start + token.length;
|
lastIndex = start + token.length;
|
||||||
}
|
}
|
||||||
@@ -162,6 +233,17 @@ function toBlocks(content: string): Block[] {
|
|||||||
|
|
||||||
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
|
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
|
||||||
const blocks = toBlocks(content);
|
const blocks = toBlocks(content);
|
||||||
|
// Имена каналов снапшота: `#канал` подсвечиваем только для них.
|
||||||
|
const guilds = useSessionStore((state) => state.guilds);
|
||||||
|
const channels = useMemo(() => {
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const guild of guilds) {
|
||||||
|
for (const channel of guild.channels) {
|
||||||
|
names.add(channel.name.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}, [guilds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`whitespace-pre-wrap break-words ${className}`}>
|
<div className={`whitespace-pre-wrap break-words ${className}`}>
|
||||||
@@ -180,13 +262,13 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
|
|||||||
if (block.kind === 'quote') {
|
if (block.kind === 'quote') {
|
||||||
return (
|
return (
|
||||||
<blockquote key={key} className="my-1 border-l-2 border-border pl-2 text-fg-muted">
|
<blockquote key={key} className="my-1 border-l-2 border-border pl-2 text-fg-muted">
|
||||||
{renderInline(block.lines.join('\n'), resolveMention, key)}
|
{renderInline(block.lines.join('\n'), resolveMention, channels, key)}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<p key={key} className="min-w-0">
|
<p key={key} className="min-w-0">
|
||||||
{renderInline(block.lines.join('\n'), resolveMention, key)}
|
{renderInline(block.lines.join('\n'), resolveMention, channels, key)}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ interface LocationState {
|
|||||||
from?: string;
|
from?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Форма входа: поле кода 2FA появляется при ответе `auth.2fa_required`. */
|
/**
|
||||||
|
* Форма входа. Поле кода 2FA показывается всегда: сервер принимает и код из
|
||||||
|
* приложения (6 цифр), и резервный код вида `a8eh-pshp-t8st`, а при попытке
|
||||||
|
* входа без кода отвечает `auth.2fa_required` — тогда подсказываем про код.
|
||||||
|
*/
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -30,8 +34,11 @@ export default function LoginPage() {
|
|||||||
const from = (location.state as LocationState | null)?.from ?? '/app';
|
const from = (location.state as LocationState | null)?.from ?? '/app';
|
||||||
|
|
||||||
const submit = useMutation({
|
const submit = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => {
|
||||||
login(needsTotp ? { email, password, totp_code: totpCode } : { email, password }),
|
// Пустое поле — вход без кода: сервер сам скажет, нужен ли он.
|
||||||
|
const code = totpCode.trim();
|
||||||
|
return login(code === '' ? { email, password } : { email, password, totp_code: code });
|
||||||
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
// Профиль перечитываем заново: cookie уже выставлена сервером.
|
// Профиль перечитываем заново: cookie уже выставлена сервером.
|
||||||
await getQueryClient().invalidateQueries();
|
await getQueryClient().invalidateQueries();
|
||||||
@@ -39,6 +46,7 @@ export default function LoginPage() {
|
|||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
if (errorCode(error) === 'auth.2fa_required') {
|
if (errorCode(error) === 'auth.2fa_required') {
|
||||||
|
// Логин, пароль и уже введённый код остаются в форме.
|
||||||
setNeedsTotp(true);
|
setNeedsTotp(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -46,6 +54,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setNeedsTotp(false);
|
||||||
submit.mutate();
|
submit.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,22 +88,24 @@ export default function LoginPage() {
|
|||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{needsTotp ? (
|
|
||||||
<Field
|
<Field
|
||||||
label={t('auth.login.totp')}
|
label={t('auth.login.totp')}
|
||||||
hint={t('auth.login.totpHint')}
|
hint={t('auth.login.totpHint')}
|
||||||
name="totp_code"
|
name="totp_code"
|
||||||
inputMode="numeric"
|
inputMode="text"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
pattern="[0-9]*"
|
maxLength={32}
|
||||||
maxLength={8}
|
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(event) => setTotpCode(event.target.value)}
|
onChange={(event) => setTotpCode(event.target.value)}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
|
{needsTotp ? (
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
{t('auth.login.totpRequired')}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{submit.isError ? <ErrorNotice error={submit.error} /> : null}
|
{/* При `auth.2fa_required` подсказка уже показана у поля — не дублируем. */}
|
||||||
|
{submit.isError && !needsTotp ? <ErrorNotice error={submit.error} /> : null}
|
||||||
|
|
||||||
<Button type="submit" disabled={submit.isPending}>
|
<Button type="submit" disabled={submit.isPending}>
|
||||||
{t(submit.isPending ? 'auth.login.submitting' : 'auth.login.submit')}
|
{t(submit.isPending ? 'auth.login.submitting' : 'auth.login.submit')}
|
||||||
|
|||||||
@@ -148,8 +148,11 @@ export function TwoFactorSection({
|
|||||||
className="mt-2 grid grid-cols-2 gap-1 font-mono text-xs"
|
className="mt-2 grid grid-cols-2 gap-1 font-mono text-xs"
|
||||||
data-testid="recovery-codes"
|
data-testid="recovery-codes"
|
||||||
>
|
>
|
||||||
|
{/* Коды вида `a8eh-pshp-t8st` не обрезаем: переносим по символам. */}
|
||||||
{recoveryCodes.map((recoveryCode) => (
|
{recoveryCodes.map((recoveryCode) => (
|
||||||
<li key={recoveryCode}>{recoveryCode}</li>
|
<li key={recoveryCode} className="min-w-0 break-all">
|
||||||
|
{recoveryCode}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-3 flex items-center gap-3">
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user