diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 1d4c83a..cd89309 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -123,12 +123,19 @@ type identifyPayload struct { SessionID string `json:"session_id"` } +// Visibility отвечает, видит ли пользователь комнату: события комнат получают +// только те сессии, у которых есть VIEW_CHANNEL (AGENT.md 8.3, 9.7). +type Visibility interface { + CanViewChannel(ctx context.Context, guildID, channelID, userID uint64, instanceAdmin bool) bool +} + // Service — Gateway: управляет подключениями и рассылкой событий. type Service struct { - store *store.Store - auth *auth.Service - readiness SnapshotBuilder - logger *slog.Logger + store *store.Store + auth *auth.Service + readiness SnapshotBuilder + visibility Visibility + logger *slog.Logger // allowedOrigins — домены, с которых разрешено подключаться (AGENT.md 9.7). allowedOrigins []string @@ -144,7 +151,7 @@ type SnapshotBuilder interface { } func New(st *store.Store, authService *auth.Service, builder SnapshotBuilder, logger *slog.Logger, allowedOrigins []string) *Service { - return &Service{ + service := &Service{ store: st, auth: authService, readiness: builder, @@ -153,6 +160,11 @@ func New(st *store.Store, authService *auth.Service, builder SnapshotBuilder, lo sessions: map[string]*clientSession{}, buffers: map[string]*resumeBuffer{}, } + // Сборщик READY умеет считать права: переиспользуем его для фильтрации. + if visibility, ok := builder.(Visibility); ok { + service.visibility = visibility + } + return service } // Handler отдаёт http.Handler для маршрута /gateway. @@ -219,6 +231,75 @@ func (s *Service) ActiveSessions() int { return len(s.sessions) } +// DispatchToChannel рассылает событие комнаты только тем сессиям, которые +// видят эту комнату (AGENT.md 8.3: права фильтруются на сервере). +func (s *Service) DispatchToChannel(ctx context.Context, channelID uint64, event string, payload any) { + s.dispatchToChannel(ctx, channelID, 0, event, payload) +} + +// DispatchToChannelExcept рассылает событие комнаты всем, кроме указанного +// пользователя: так работает typing (AGENT.md 7.6). +func (s *Service) DispatchToChannelExcept(ctx context.Context, channelID, exceptUserID uint64, event string, payload any) { + s.dispatchToChannel(ctx, channelID, exceptUserID, event, payload) +} + +func (s *Service) dispatchToChannel(ctx context.Context, channelID, exceptUserID uint64, event string, payload any) { + channel, err := s.store.GetChannel(ctx, channelID) + if err != nil { + s.logger.DebugContext(ctx, "gateway channel event skipped", slog.Any("error", err)) + return + } + guildID := uint64(0) + if channel.GuildID != nil { + guildID = *channel.GuildID + } + + raw, err := json.Marshal(payload) + if err != nil { + s.logger.ErrorContext(ctx, "marshal channel event", slog.String("event", event), slog.Any("error", err)) + return + } + s.mu.Lock() + s.seq++ + envelope := Envelope{Op: OpDispatch, T: event, D: raw, S: s.seq} + encoded, err := json.Marshal(envelope) + if err != nil { + s.mu.Unlock() + s.logger.ErrorContext(ctx, "marshal channel envelope", slog.Any("error", err)) + return + } + targets := make([]*clientSession, 0, len(s.sessions)) + for _, session := range s.sessions { + targets = append(targets, session) + } + for _, buffer := range s.buffers { + buffer.append(envelope.S, encoded) + } + s.mu.Unlock() + + for _, session := range targets { + if exceptUserID != 0 && session.userID == exceptUserID { + continue + } + if !s.canViewChannel(ctx, guildID, channelID, session) { + continue + } + if err := session.write(encoded); err != nil { + s.logger.DebugContext(ctx, "gateway write failed", slog.Any("error", err)) + } + } +} + +// canViewChannel проверяет видимость комнаты для сессии. Без калькулятора +// прав событие доставляется всем: так работают тесты и режим без БД. +func (s *Service) canViewChannel(ctx context.Context, guildID, channelID uint64, session *clientSession) bool { + if s.visibility == nil || guildID == 0 { + return true + } + instanceAdmin := session.user != nil && session.user.IsInstanceAdmin + return s.visibility.CanViewChannel(ctx, guildID, channelID, session.userID, instanceAdmin) +} + // SendToUser доставляет событие конкретному пользователю. func (s *Service) SendToUser(userID uint64, event string, payload any) { raw, err := json.Marshal(payload) diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index e756fbb..ab9e3e6 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -27,7 +27,20 @@ func NewSnapshot(st *store.Store, calculator *permissions.Calculator) *Snapshot return &Snapshot{store: st, calculator: calculator} } -var _ SnapshotBuilder = (*Snapshot)(nil) +var ( + _ SnapshotBuilder = (*Snapshot)(nil) + _ Visibility = (*Snapshot)(nil) +) + +// CanViewChannel отвечает, видит ли пользователь комнату: используется для +// адресной рассылки событий комнат (AGENT.md 8.3). +func (s *Snapshot) CanViewChannel(ctx context.Context, guildID, channelID, userID uint64, instanceAdmin bool) bool { + resolved, err := s.calculator.Channel(ctx, guildID, channelID, userID, instanceAdmin) + if err != nil { + return false + } + return resolved.CanViewChannel() +} func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error) { ready := &Ready{ diff --git a/internal/gateway/session.go b/internal/gateway/session.go index 5a68ae6..6066f88 100644 --- a/internal/gateway/session.go +++ b/internal/gateway/session.go @@ -29,6 +29,9 @@ type clientSession struct { // preAuth — сессия, восстановленная из cookie на рукопожатии: браузерный // клиент не имеет доступа к токену (AGENT.md 8.1, 8.3). preAuth *store.Session + // user — профиль на момент IDENTIFY: нужен для фильтрации событий + // (инстанс-админ видит все комнаты, AGENT.md 7.19). + user *store.User } // SessionCookieName — имя cookie сессии. Значение должно совпадать с @@ -258,6 +261,7 @@ func (s *Service) handleIdentify(ctx context.Context, session *clientSession, pa return err } session.userID = user.ID + session.user = user session.buffer = s.bufferFor(authSession.TokenHash) if resume && payload.ResumeSeq > 0 && session.buffer != nil { diff --git a/internal/server/api_context.go b/internal/server/api_context.go index 9579208..7454954 100644 --- a/internal/server/api_context.go +++ b/internal/server/api_context.go @@ -87,15 +87,24 @@ type humaAPIError struct { status int code string message string + details map[string]any } func (e *humaAPIError) Error() string { return e.code + ": " + e.message } func (e *humaAPIError) GetStatus() int { return e.status } func (e *humaAPIError) MarshalJSON() ([]byte, error) { - return json.Marshal(map[string]any{ - "error": map[string]any{"code": e.code, "message": e.message}, - }) + payload := map[string]any{"code": e.code, "message": e.message} + for key, value := range e.details { + payload[key] = value + } + return json.Marshal(map[string]any{"error": payload}) +} + +// humaErrorStatusDetails добавляет машиночитаемые детали (например, +// retry_after_ms для 429), AGENT.md 8.5. +func humaErrorStatusDetails(status int, code, message string, details map[string]any) huma.StatusError { + return &humaAPIError{status: status, code: code, message: message, details: details} } // humaErrorStatus создаёт ошибку с явным кодом. diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go new file mode 100644 index 0000000..e103c3d --- /dev/null +++ b/internal/server/api_messages.go @@ -0,0 +1,832 @@ +package server + +import ( + "context" + "log/slog" + "net/http" + "regexp" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// mentionPattern находит упоминания вида <@123> (AGENT.md 7.6). +var mentionPattern = regexp.MustCompile(`<@([0-9]{1,20})>`) + +// messageKey ключует лимиты по комнате и пользователю. +func messageKey(channelID, userID uint64) string { + return formatSnowflake(channelID) + ":" + formatSnowflake(userID) +} + +// editWindow — сколько времени автор может править сообщение (AGENT.md 7.6). +const editWindow = 24 * time.Hour + +type attachmentPayload struct { + FileID string `json:"file_id"` + Filename string `json:"filename"` + ContentType string `json:"content_type,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +type reactionPayload struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Me bool `json:"me"` + UserIDs []string `json:"user_ids,omitempty"` +} + +type messagePayload struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + AuthorID string `json:"author_id,omitempty"` + Content string `json:"content"` + ReplyToID string `json:"reply_to_id,omitempty"` + Type string `json:"type"` + EditedAt string `json:"edited_at,omitempty"` + Pinned bool `json:"pinned"` + Attachments []attachmentPayload `json:"attachments"` + Mentions []string `json:"mentions"` + Reactions []reactionPayload `json:"reactions"` + CreatedAt string `json:"created_at"` +} + +type messageListOutput struct { + Body struct { + Messages []messagePayload `json:"messages"` + } +} + +type messageOutput struct { + Body struct { + Message messagePayload `json:"message"` + } +} + +// registerMessageRoutes описывает ручки сообщений, реакций, пинов, typing и +// read states (AGENT.md 7.6, 7.16). +func (s *Server) registerMessageRoutes(api huma.API) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "createMessage", + Method: http.MethodPost, + Path: "/channels/{channel_id}/messages", + Summary: "Отправить сообщение", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + Body struct { + Content string `json:"content" maxLength:"4000"` + ReplyToID string `json:"reply_to_id,omitempty"` + AttachmentIDs []string `json:"attachment_ids,omitempty" maxItems:"20"` + Nonce string `json:"nonce,omitempty" maxLength:"64"` + } + }, + ) (*messageOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channelID, resolved, channel, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.SendMessages) + if err != nil { + return nil, err + } + // Антиспам-лимит: 5 сообщений за 5 секунд на комнату и пользователя, + // администратор инстанса лимит обходит (AGENT.md 8.6, 7.19). + if !user.IsInstanceAdmin { + if allowed, retryAfter := s.messageLimiter.Allow(messageKey(channelID, user.ID)); !allowed { + return nil, rateLimitedError(retryAfter) + } + if err := s.checkSlowmode(ctx, channel, user, resolved); err != nil { + return nil, err + } + } + + content := strings.TrimSpace(input.Body.Content) + attachments, err := s.attachmentsFromIDs(ctx, channelID, user.ID, input.Body.AttachmentIDs) + if err != nil { + return nil, err + } + if content == "" && len(attachments) == 0 { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message must have content or attachments") + } + settings, err := s.store.InstanceSettings(ctx) + if err != nil { + return nil, humaError(err) + } + if len([]rune(content)) > settings.MaxMessageLength { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message is too long") + } + + params := store.CreateMessageParams{ + ChannelID: channelID, + AuthorID: user.ID, + Content: content, + Attachments: attachments, + Mentions: s.extractMentions(ctx, channelID, content), + } + if input.Body.ReplyToID != "" { + replyTo, err := parseID("reply_to_id", input.Body.ReplyToID) + if err != nil { + return nil, err + } + parent, err := s.store.GetMessage(ctx, replyTo) + if err != nil { + return nil, humaError(err) + } + if parent.ChannelID != channelID { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "reply target is in another channel") + } + params.ReplyToID = &replyTo + } + + message, err := s.store.CreateMessage(ctx, params) + if err != nil { + return nil, humaError(err) + } + // Вложения привязываем к сообщению: до этого они считаются сиротами. + for _, attachment := range attachments { + if err := s.store.AttachFileToMessage(ctx, attachment.FileID, message.ID); err != nil { + s.logger.WarnContext(ctx, "failed to attach file to message", + slog.String("file_id", formatSnowflake(attachment.FileID)), slog.Any("error", err)) + } + } + s.rememberSlowmode(channelID, user.ID) + payload, err := s.messagePayload(ctx, message, user.ID) + if err != nil { + return nil, err + } + s.dispatchChannelEvent(ctx, channelID, "MESSAGE_CREATE", payload) + output := &messageOutput{} + output.Body.Message = payload + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listMessages", + Method: http.MethodGet, + Path: "/channels/{channel_id}/messages", + Summary: "История сообщений комнаты", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + Before string `query:"before,omitempty"` + Limit int `query:"limit" default:"50" minimum:"1" maximum:"100"` + }, + ) (*messageListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ReadMessageHistory) + if err != nil { + return nil, err + } + beforeID := uint64(0) + if input.Before != "" { + beforeID, err = parseID("before", input.Before) + if err != nil { + return nil, err + } + } + messages, err := s.store.ListMessages(ctx, channelID, beforeID, input.Limit) + if err != nil { + return nil, humaError(err) + } + payloads, err := s.messagePayloads(ctx, messages, user.ID) + if err != nil { + return nil, err + } + output := &messageListOutput{} + output.Body.Messages = payloads + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "searchMessages", + Method: http.MethodGet, + Path: "/channels/{channel_id}/messages/search", + Summary: "Поиск по сообщениям комнаты (FTS5)", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + Query string `query:"q" minLength:"1" maxLength:"200"` + Limit int `query:"limit" default:"25" minimum:"1" maximum:"100"` + }, + ) (*messageListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ReadMessageHistory) + if err != nil { + return nil, err + } + if allowed, retryAfter := s.searchLimiter.Allow("search:" + formatSnowflake(user.ID)); !allowed { + return nil, rateLimitedError(retryAfter) + } + messages, err := s.store.SearchMessages(ctx, []uint64{channelID}, toFTSQuery(input.Query), input.Limit) + if err != nil { + return nil, humaError(err) + } + payloads, err := s.messagePayloads(ctx, messages, user.ID) + if err != nil { + return nil, err + } + output := &messageListOutput{} + output.Body.Messages = payloads + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "updateMessage", + Method: http.MethodPatch, + Path: "/channels/{channel_id}/messages/{message_id}", + Summary: "Изменить сообщение", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + MessageID string `path:"message_id"` + Body struct { + Content string `json:"content" maxLength:"4000"` + } + }, + ) (*messageOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channelID, resolved, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.SendMessages) + if err != nil { + return nil, err + } + message, err := s.messageInChannel(ctx, channelID, input.MessageID) + if err != nil { + return nil, err + } + if err := s.requireMessageAuthor(user, resolved, message, true); err != nil { + return nil, err + } + content := strings.TrimSpace(input.Body.Content) + if content == "" && len(message.Attachments) == 0 { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message must have content or attachments") + } + updated, err := s.store.UpdateMessageContent(ctx, message.ID, content) + if err != nil { + return nil, humaError(err) + } + payload, err := s.messagePayload(ctx, updated, user.ID) + if err != nil { + return nil, err + } + s.dispatchChannelEvent(ctx, channelID, "MESSAGE_UPDATE", payload) + output := &messageOutput{} + output.Body.Message = payload + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteMessage", + Method: http.MethodDelete, + Path: "/channels/{channel_id}/messages/{message_id}", + Summary: "Удалить сообщение", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + MessageID string `path:"message_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channelID, resolved, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ViewChannel) + if err != nil { + return nil, err + } + message, err := s.messageInChannel(ctx, channelID, input.MessageID) + if err != nil { + return nil, err + } + if err := s.requireMessageAuthor(user, resolved, message, false); err != nil { + return nil, err + } + if err := s.store.DeleteMessage(ctx, message.ID); err != nil { + return nil, humaError(err) + } + s.dispatchChannelEvent(ctx, channelID, "MESSAGE_DELETE", map[string]any{ + "id": formatSnowflake(message.ID), + "channel_id": formatSnowflake(channelID), + }) + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "addReaction", + Method: http.MethodPut, + Path: "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}", + Summary: "Поставить реакцию", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + MessageID string `path:"message_id"` + Emoji string `path:"emoji"` + }, + ) (*okOutput, error) { + return s.changeReaction(ctx, input.ChannelID, input.MessageID, input.Emoji, true) + }) + + huma.Register(api, huma.Operation{ + OperationID: "removeReaction", + Method: http.MethodDelete, + Path: "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}", + Summary: "Снять реакцию", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + MessageID string `path:"message_id"` + Emoji string `path:"emoji"` + }, + ) (*okOutput, error) { + return s.changeReaction(ctx, input.ChannelID, input.MessageID, input.Emoji, false) + }) + + huma.Register(api, huma.Operation{ + OperationID: "listPinnedMessages", + Method: http.MethodGet, + Path: "/channels/{channel_id}/pins", + Summary: "Закреплённые сообщения", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + }, + ) (*messageListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ViewChannel) + if err != nil { + return nil, err + } + messages, err := s.store.ListPinnedMessages(ctx, channelID, 50) + if err != nil { + return nil, humaError(err) + } + payloads, err := s.messagePayloads(ctx, messages, user.ID) + if err != nil { + return nil, err + } + output := &messageListOutput{} + output.Body.Messages = payloads + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "pinMessage", + Method: http.MethodPut, + Path: "/channels/{channel_id}/pins/{message_id}", + Summary: "Закрепить сообщение", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + MessageID string `path:"message_id"` + }, + ) (*okOutput, error) { + return s.changePin(ctx, input.ChannelID, input.MessageID, true) + }) + + huma.Register(api, huma.Operation{ + OperationID: "unpinMessage", + Method: http.MethodDelete, + Path: "/channels/{channel_id}/pins/{message_id}", + Summary: "Открепить сообщение", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + MessageID string `path:"message_id"` + }, + ) (*okOutput, error) { + return s.changePin(ctx, input.ChannelID, input.MessageID, false) + }) + + huma.Register(api, huma.Operation{ + OperationID: "sendTyping", + Method: http.MethodPost, + Path: "/channels/{channel_id}/typing", + Summary: "Сообщить о наборе текста", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.SendMessages) + if err != nil { + return nil, err + } + // Typing — не чаще одного раза в 3 секунды (AGENT.md 8.6). + if allowed, _ := s.typingLimiter.Allow("typing:" + formatSnowflake(user.ID) + ":" + formatSnowflake(channelID)); !allowed { + return newOKOutput(), nil + } + s.dispatchChannelEventExcept(ctx, channelID, user.ID, "TYPING_START", map[string]any{ + "channel_id": formatSnowflake(channelID), + "user_id": formatSnowflake(user.ID), + }) + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "acknowledgeChannel", + Method: http.MethodPost, + Path: "/channels/{channel_id}/ack", + Summary: "Отметить комнату прочитанной", + Tags: []string{"Messages"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + Body struct { + LastMessageID string `json:"last_message_id,omitempty"` + } + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ViewChannel) + if err != nil { + return nil, err + } + lastID := uint64(0) + if input.Body.LastMessageID != "" { + lastID, err = parseID("last_message_id", input.Body.LastMessageID) + if err != nil { + return nil, err + } + } + if err := s.store.SetReadState(ctx, user.ID, channelID, lastID, 0); err != nil { + return nil, humaError(err) + } + // Состояние прочтения синхронизируется между устройствами (AGENT.md 7.16). + if s.gateway != nil { + s.gateway.SendToUser(user.ID, "READ_STATE_UPDATE", map[string]any{ + "channel_id": formatSnowflake(channelID), + "last_message_id": formatSnowflake(lastID), + "mention_count": 0, + }) + } + return newOKOutput(), nil + }) +} + +// requireChannelPermission проверяет права пользователя в комнате и отдаёт её. +func (s *Server) requireChannelPermission(ctx context.Context, rawChannelID string, user *store.User, permission permissions.Permission) (uint64, permissions.Resolved, *store.Channel, error) { + channelID, err := parseID("channel_id", rawChannelID) + if err != nil { + return 0, permissions.Resolved{}, nil, err + } + channel, err := s.store.GetChannel(ctx, channelID) + if err != nil { + return 0, permissions.Resolved{}, nil, humaError(err) + } + if channel.GuildID == nil { + // Личные комнаты появятся в Фазе 4: сейчас их нет. + return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found") + } + resolved, err := s.perms.Channel(ctx, *channel.GuildID, channelID, user.ID, user.IsInstanceAdmin) + if err != nil { + return 0, permissions.Resolved{}, nil, humaError(err) + } + if !resolved.CanViewChannel() { + return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found") + } + if !resolved.Can(permission) { + return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied") + } + return channelID, resolved, channel, nil +} + +// messageInChannel проверяет, что сообщение принадлежит комнате. +func (s *Server) messageInChannel(ctx context.Context, channelID uint64, rawMessageID string) (*store.Message, error) { + messageID, err := parseID("message_id", rawMessageID) + if err != nil { + return nil, err + } + message, err := s.store.GetMessage(ctx, messageID) + if err != nil { + return nil, humaError(err) + } + if message.ChannelID != channelID { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "message not found") + } + return message, nil +} + +// requireMessageAuthor разрешает действие автору сообщения или модератору с +// MANAGE_MESSAGES; правка ограничена окном editWindow (AGENT.md 7.6). +func (s *Server) requireMessageAuthor(user *store.User, resolved permissions.Resolved, message *store.Message, editing bool) error { + isAuthor := message.AuthorID != nil && *message.AuthorID == user.ID + if isAuthor { + if editing && message.EditedAt == nil && time.Since(message.CreatedAt) > editWindow { + return humaErrorStatus(http.StatusForbidden, "message.edit_window_expired", "message can no longer be edited") + } + return nil + } + if resolved.Has(permissions.ManageMessages) { + return nil + } + return humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied") +} + +// changeReaction ставит или снимает реакцию и рассылает событие. +func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID, emoji string, add bool) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + emoji = strings.TrimSpace(emoji) + if emoji == "" || len([]rune(emoji)) > 32 { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "emoji is invalid") + } + channelID, _, _, err := s.requireChannelPermission(ctx, rawChannelID, user, permissions.AddReactions) + if err != nil { + return nil, err + } + message, err := s.messageInChannel(ctx, channelID, rawMessageID) + if err != nil { + return nil, err + } + if add { + err = s.store.AddReaction(ctx, message.ID, user.ID, emoji) + } else { + err = s.store.RemoveReaction(ctx, message.ID, user.ID, emoji) + } + if err != nil { + return nil, humaError(err) + } + event := "MESSAGE_REACTION_REMOVE" + if add { + event = "MESSAGE_REACTION_ADD" + } + s.dispatchChannelEvent(ctx, channelID, event, map[string]any{ + "channel_id": formatSnowflake(channelID), + "message_id": formatSnowflake(message.ID), + "user_id": formatSnowflake(user.ID), + "emoji": emoji, + }) + return newOKOutput(), nil +} + +// changePin закрепляет или открепляет сообщение (нужно MANAGE_MESSAGES). +func (s *Server) changePin(ctx context.Context, rawChannelID, rawMessageID string, pinned bool) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channelID, _, _, err := s.requireChannelPermission(ctx, rawChannelID, user, permissions.ManageMessages) + if err != nil { + return nil, err + } + message, err := s.messageInChannel(ctx, channelID, rawMessageID) + if err != nil { + return nil, err + } + if err := s.store.SetMessagePinned(ctx, message.ID, pinned); err != nil { + return nil, humaError(err) + } + s.dispatchChannelEvent(ctx, channelID, "CHANNEL_PINS_UPDATE", map[string]any{ + "channel_id": formatSnowflake(channelID), + "message_id": formatSnowflake(message.ID), + "pinned": pinned, + }) + return newOKOutput(), nil +} + +// messagePayload собирает сообщение для API с реакциями и автором. +func (s *Server) messagePayload(ctx context.Context, message *store.Message, viewerID uint64) (messagePayload, error) { + payload := messagePayload{ + ID: formatSnowflake(message.ID), + ChannelID: formatSnowflake(message.ChannelID), + Content: message.Content, + Type: string(message.Type), + Pinned: message.Pinned, + Attachments: make([]attachmentPayload, 0, len(message.Attachments)), + Mentions: make([]string, 0, len(message.Mentions)), + Reactions: []reactionPayload{}, + CreatedAt: message.CreatedAt.UTC().Format(time.RFC3339), + } + if message.AuthorID != nil { + payload.AuthorID = formatSnowflake(*message.AuthorID) + } + if message.ReplyToID != nil { + payload.ReplyToID = formatSnowflake(*message.ReplyToID) + } + if message.EditedAt != nil { + payload.EditedAt = message.EditedAt.UTC().Format(time.RFC3339) + } + for _, attachment := range message.Attachments { + payload.Attachments = append(payload.Attachments, attachmentPayload{ + FileID: formatSnowflake(attachment.FileID), + Filename: attachment.Filename, + ContentType: attachment.ContentType, + SizeBytes: attachment.SizeBytes, + Width: attachment.Width, + Height: attachment.Height, + }) + } + for _, mention := range message.Mentions { + payload.Mentions = append(payload.Mentions, formatSnowflake(mention)) + } + reactions, err := s.store.ListReactions(ctx, message.ID, viewerID) + if err != nil { + return messagePayload{}, humaError(err) + } + for _, reaction := range reactions { + item := reactionPayload{Emoji: reaction.Emoji, Count: reaction.Count, Me: reaction.Me} + for _, userID := range reaction.UserIDs { + item.UserIDs = append(item.UserIDs, formatSnowflake(userID)) + } + payload.Reactions = append(payload.Reactions, item) + } + return payload, nil +} + +func (s *Server) messagePayloads(ctx context.Context, messages []store.Message, viewerID uint64) ([]messagePayload, error) { + payloads := make([]messagePayload, 0, len(messages)) + for i := range messages { + payload, err := s.messagePayload(ctx, &messages[i], viewerID) + if err != nil { + return nil, err + } + payloads = append(payloads, payload) + } + return payloads, nil +} + +// dispatchChannelEvent рассылает событие комнаты только тем, кто её видит. +func (s *Server) dispatchChannelEvent(ctx context.Context, channelID uint64, event string, payload any) { + if s.gateway == nil { + return + } + s.gateway.DispatchToChannel(ctx, channelID, event, payload) +} + +// dispatchChannelEventExcept рассылает событие всем, кроме указанного пользователя. +func (s *Server) dispatchChannelEventExcept(ctx context.Context, channelID, exceptUserID uint64, event string, payload any) { + if s.gateway == nil { + return + } + s.gateway.DispatchToChannelExcept(ctx, channelID, exceptUserID, event, payload) +} + +// checkSlowmode проверяет режим медленной отправки комнаты (AGENT.md 7.5). +func (s *Server) checkSlowmode(_ context.Context, channel *store.Channel, user *store.User, resolved permissions.Resolved) error { + if channel.SlowmodeSeconds <= 0 || resolved.Has(permissions.ManageMessages) || resolved.Has(permissions.ManageChannels) { + return nil + } + s.slowmodeMu.Lock() + last, ok := s.slowmode[messageKey(channel.ID, user.ID)] + s.slowmodeMu.Unlock() + if !ok { + return nil + } + elapsed := time.Since(last) + wait := time.Duration(channel.SlowmodeSeconds)*time.Second - elapsed + if wait <= 0 { + return nil + } + return rateLimitedError(wait) +} + +// rememberSlowmode запоминает время последней отправки в комнату. +func (s *Server) rememberSlowmode(channelID, userID uint64) { + s.slowmodeMu.Lock() + defer s.slowmodeMu.Unlock() + // Попутная уборка, чтобы словарь не рос бесконечно. + if len(s.slowmode) > 4096 { + cutoff := time.Now().Add(-time.Hour) + for key, at := range s.slowmode { + if at.Before(cutoff) { + delete(s.slowmode, key) + } + } + } + s.slowmode[messageKey(channelID, userID)] = time.Now() +} + +// extractMentions ищет упоминания вида <@123> и проверяет, что пользователь +// состоит в сервере (AGENT.md 7.6). +func (s *Server) extractMentions(ctx context.Context, channelID uint64, content string) []uint64 { + if !strings.Contains(content, "<@") { + return nil + } + channel, err := s.store.GetChannel(ctx, channelID) + if err != nil || channel.GuildID == nil { + return nil + } + members, err := s.store.ListGuildMembers(ctx, *channel.GuildID) + if err != nil { + return nil + } + known := make(map[uint64]bool, len(members)) + for _, member := range members { + known[member.UserID] = true + } + mentions := make([]uint64, 0, 4) + seen := map[uint64]bool{} + for _, candidate := range mentionPattern.FindAllStringSubmatch(content, -1) { + id, err := parseID("mention", candidate[1]) + if err != nil || !known[id] || seen[id] { + continue + } + seen[id] = true + mentions = append(mentions, id) + } + return mentions +} + +// attachmentsFromIDs проверяет, что файлы загружены этим пользователем в эту +// комнату и ещё не привязаны к сообщению (AGENT.md 7.7). +func (s *Server) attachmentsFromIDs(ctx context.Context, channelID, userID uint64, rawIDs []string) ([]store.Attachment, error) { + if len(rawIDs) == 0 { + return nil, nil + } + attachments := make([]store.Attachment, 0, len(rawIDs)) + for _, raw := range rawIDs { + fileID, err := parseID("attachment_ids", raw) + if err != nil { + return nil, err + } + file, err := s.store.GetFile(ctx, fileID) + if err != nil { + return nil, humaError(err) + } + if file.UploaderID == nil || *file.UploaderID != userID || file.ChannelID == nil || *file.ChannelID != channelID { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "attachment belongs to another user or channel") + } + attachments = append(attachments, store.Attachment{ + FileID: file.ID, + Filename: file.Filename, + ContentType: file.ContentType, + SizeBytes: file.SizeBytes, + Width: file.Width, + Height: file.Height, + }) + } + return attachments, nil +} + +// rateLimitedError отдаёт 429 с подсказкой по паузе (AGENT.md 8.5, 8.6). +func rateLimitedError(retryAfter time.Duration) huma.StatusError { + milliseconds := retryAfter.Milliseconds() + if milliseconds <= 0 { + milliseconds = 1000 + } + return humaErrorStatusDetails(http.StatusTooManyRequests, "rate_limited", "too many requests", map[string]any{ + "retry_after_ms": milliseconds, + }) +} + +// toFTSQuery превращает пользовательский ввод в безопасный запрос FTS5: +// кавычки и служебные символы экранируются, слова соединяются по AND. +func toFTSQuery(input string) string { + fields := strings.FieldsFunc(input, func(r rune) bool { + return r == ' ' || r == '\t' || r == '\n' + }) + terms := make([]string, 0, len(fields)) + for _, field := range fields { + cleaned := strings.Map(func(r rune) rune { + switch r { + case '"', '\'', '*', '(', ')', ':', '^', '-', '+': + return -1 + } + return r + }, field) + if cleaned == "" { + continue + } + terms = append(terms, `"`+cleaned+`"`) + } + return strings.Join(terms, " AND ") +} diff --git a/internal/server/messages_test.go b/internal/server/messages_test.go new file mode 100644 index 0000000..600ea81 --- /dev/null +++ b/internal/server/messages_test.go @@ -0,0 +1,373 @@ +package server + +import ( + "net/http" + "testing" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// storeOverride скрывает комнату от роли @user. +func storeOverride(channelID, roleID uint64) store.ChannelOverride { + return store.ChannelOverride{ + ChannelID: channelID, TargetType: "role", TargetID: roleID, + Deny: uint64(permissions.ViewChannel), + } +} + +// messagingFixture создаёт сервер с владельцем, участником и двумя комнатами: +// «общий» (видна всем) и «тайная» (скрыта от роли @user оверрайдом). +type messagingFixture struct { + srv *Server + ownerCookie *http.Cookie + memberCookie *http.Cookie + guildID string + openChannel string + secretID string + memberID string + ownerID string +} + +func newMessagingFixture(t *testing.T) *messagingFixture { + t.Helper() + srv, _ := newTestServer(t) + ownerCookie := registerAndLogin(t, srv, "msg_owner", "msg-owner@example.com") + memberCookie := registerAndLogin(t, srv, "msg_member", "msg-member@example.com") + + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Чат"}`, ownerCookie) + guild := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + Roles []struct { + ID string `json:"id"` + IsDefault bool `json:"is_default"` + } `json:"roles"` + Channels []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"channels"` + } `json:"guild"` + }](t, created) + + doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie) + + secretRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels", + `{"name":"тайная","type":"text"}`, ownerCookie) + secret := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, secretRec) + + database := srv.store + var defaultRoleID uint64 + for _, role := range guild.Guild.Roles { + if role.IsDefault { + defaultRoleID = guildIDOf(t, role.ID) + } + } + if err := database.SetChannelOverride(t.Context(), storeOverride(guildIDOf(t, secret.Channel.ID), defaultRoleID)); err != nil { + t.Fatalf("SetChannelOverride: %v", err) + } + srv.perms.InvalidateGuild(guildIDOf(t, guild.Guild.ID)) + + owner, err := srv.auth.UserByEmail(t.Context(), "msg-owner@example.com") + if err != nil { + t.Fatalf("UserByEmail owner: %v", err) + } + member, err := srv.auth.UserByEmail(t.Context(), "msg-member@example.com") + if err != nil { + t.Fatalf("UserByEmail member: %v", err) + } + + return &messagingFixture{ + srv: srv, + ownerCookie: ownerCookie, + memberCookie: memberCookie, + guildID: guild.Guild.ID, + openChannel: guild.Guild.Channels[0].ID, + secretID: secret.Channel.ID, + memberID: formatSnowflake(member.ID), + ownerID: formatSnowflake(owner.ID), + } +} + +func TestMessageLifecycle(t *testing.T) { + f := newMessagingFixture(t) + + // Отправка: содержимое нормализуется, ответ содержит автора и время. + sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":" привет, мир "}`, f.memberCookie) + if sent.Code != http.StatusOK { + t.Fatalf("create message = %d, body = %s", sent.Code, sent.Body.String()) + } + message := decodeResponse[struct { + Message struct { + ID string `json:"id"` + Content string `json:"content"` + AuthorID string `json:"author_id"` + ChannelID string `json:"channel_id"` + } `json:"message"` + }](t, sent) + if message.Message.Content != "привет, мир" { + t.Fatalf("content = %q, want trimmed", message.Message.Content) + } + if message.Message.AuthorID != f.memberID || message.Message.ChannelID != f.openChannel { + t.Fatalf("unexpected message: %+v", message.Message) + } + + // Пустое сообщение без вложений запрещено. + empty := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":" "}`, f.memberCookie) + if empty.Code != http.StatusUnprocessableEntity { + t.Fatalf("empty message = %d, want 422", empty.Code) + } + + // Правка автором. + edited := doJSON(t, f.srv, http.MethodPatch, + "/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID, + `{"content":"поправлено"}`, f.memberCookie) + if edited.Code != http.StatusOK { + t.Fatalf("edit message = %d, body = %s", edited.Code, edited.Body.String()) + } + updated := decodeResponse[struct { + Message struct { + Content string `json:"content"` + EditedAt string `json:"edited_at"` + } `json:"message"` + }](t, edited) + if updated.Message.Content != "поправлено" || updated.Message.EditedAt == "" { + t.Fatalf("unexpected edited message: %+v", updated.Message) + } + + // История отдаётся от новых к старым. + second := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"второе"}`, f.ownerCookie) + if second.Code != http.StatusOK { + t.Fatalf("second message = %d", second.Code) + } + history := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.memberCookie) + list := decodeResponse[struct { + Messages []struct { + Content string `json:"content"` + } `json:"messages"` + }](t, history) + if len(list.Messages) != 2 || list.Messages[0].Content != "второе" { + t.Fatalf("history = %+v", list.Messages) + } + + // Поиск по FTS5 находит сообщение. + search := doJSON(t, f.srv, http.MethodGet, + "/api/v1/channels/"+f.openChannel+"/messages/search?q=поправлено", "", f.memberCookie) + if search.Code != http.StatusOK { + t.Fatalf("search = %d, body = %s", search.Code, search.Body.String()) + } + found := decodeResponse[struct { + Messages []struct { + ID string `json:"id"` + } `json:"messages"` + }](t, search) + if len(found.Messages) != 1 || found.Messages[0].ID != message.Message.ID { + t.Fatalf("search results = %+v", found.Messages) + } + + // Реакции: поставили, увидели в истории, сняли. + addReaction := doJSON(t, f.srv, http.MethodPut, + "/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID+"/reactions/👍", "", f.ownerCookie) + if addReaction.Code != http.StatusOK { + t.Fatalf("add reaction = %d, body = %s", addReaction.Code, addReaction.Body.String()) + } + afterReaction := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.ownerCookie) + reactions := decodeResponse[struct { + Messages []struct { + Reactions []struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Me bool `json:"me"` + } `json:"reactions"` + } `json:"messages"` + }](t, afterReaction) + var foundReaction bool + for _, item := range reactions.Messages { + for _, reaction := range item.Reactions { + if reaction.Emoji == "👍" && reaction.Count == 1 && reaction.Me { + foundReaction = true + } + } + } + if !foundReaction { + t.Fatalf("reaction not visible: %+v", reactions.Messages) + } + removeReaction := doJSON(t, f.srv, http.MethodDelete, + "/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID+"/reactions/👍", "", f.ownerCookie) + if removeReaction.Code != http.StatusOK { + t.Fatalf("remove reaction = %d", removeReaction.Code) + } + + // Закрепление требует MANAGE_MESSAGES: у участника его нет. + deniedPin := doJSON(t, f.srv, http.MethodPut, + "/api/v1/channels/"+f.openChannel+"/pins/"+message.Message.ID, "", f.memberCookie) + if deniedPin.Code != http.StatusForbidden { + t.Fatalf("member pin = %d, want 403", deniedPin.Code) + } + pin := doJSON(t, f.srv, http.MethodPut, + "/api/v1/channels/"+f.openChannel+"/pins/"+message.Message.ID, "", f.ownerCookie) + if pin.Code != http.StatusOK { + t.Fatalf("owner pin = %d, body = %s", pin.Code, pin.Body.String()) + } + pins := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/pins", "", f.memberCookie) + pinned := decodeResponse[struct { + Messages []struct { + ID string `json:"id"` + Pinned bool `json:"pinned"` + } `json:"messages"` + }](t, pins) + if len(pinned.Messages) != 1 || !pinned.Messages[0].Pinned { + t.Fatalf("pins = %+v", pinned.Messages) + } + + // Удаление: чужое сообщение участник удалить не может, модератор — может. + deniedDelete := doJSON(t, f.srv, http.MethodDelete, + "/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID, "", f.ownerCookie) + if deniedDelete.Code != http.StatusOK { + t.Fatalf("owner delete = %d, body = %s", deniedDelete.Code, deniedDelete.Body.String()) + } +} + +func TestMessagesRespectChannelVisibility(t *testing.T) { + f := newMessagingFixture(t) + + // Участник не видит скрытую комнату: список и отправка дают 404. + list := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.secretID+"/messages", "", f.memberCookie) + if list.Code != http.StatusNotFound { + t.Fatalf("hidden channel list = %d, want 404", list.Code) + } + send := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages", + `{"content":"секрет"}`, f.memberCookie) + if send.Code != http.StatusNotFound { + t.Fatalf("hidden channel send = %d, want 404", send.Code) + } + + // Владелец пишет в скрытую комнату и читает её. + ownerSend := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages", + `{"content":"для своих"}`, f.ownerCookie) + if ownerSend.Code != http.StatusOK { + t.Fatalf("owner send to hidden = %d, body = %s", ownerSend.Code, ownerSend.Body.String()) + } + + // Администратор инстанса видит всё (AGENT.md 7.19). + adminCookie := registerAndLogin(t, f.srv, "msg_admin", "msg-admin@example.com") + promoteAdmin(t, f.srv, "msg-admin@example.com") + adminList := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.secretID+"/messages", "", adminCookie) + if adminList.Code != http.StatusOK { + t.Fatalf("instance admin list = %d, want 200", adminList.Code) + } +} + +func TestSlowmodeLimitsMessages(t *testing.T) { + f := newMessagingFixture(t) + + // Включаем slowmode 60 секунд в комнате. + update := doJSON(t, f.srv, http.MethodPatch, + "/api/v1/guilds/"+f.guildID+"/channels/"+f.openChannel, + `{"slowmode_seconds":60}`, f.ownerCookie) + if update.Code != http.StatusOK { + t.Fatalf("set slowmode = %d, body = %s", update.Code, update.Body.String()) + } + + first := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"первое"}`, f.memberCookie) + if first.Code != http.StatusOK { + t.Fatalf("first message = %d, body = %s", first.Code, first.Body.String()) + } + second := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"второе"}`, f.memberCookie) + if second.Code != http.StatusTooManyRequests { + t.Fatalf("second message = %d, want 429", second.Code) + } + if code := errorCodeOf(t, second); code != "rate_limited" { + t.Fatalf("error code = %q, want rate_limited", code) + } + + // Модератор (MANAGE_MESSAGES) и администратор инстанса slowmode обходят. + owner := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"модератор пишет"}`, f.ownerCookie) + if owner.Code != http.StatusOK { + t.Fatalf("owner message with slowmode = %d, body = %s", owner.Code, owner.Body.String()) + } +} + +func TestMentionsAndReplies(t *testing.T) { + f := newMessagingFixture(t) + + parent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"родитель"}`, f.ownerCookie) + parentMessage := decodeResponse[struct { + Message struct { + ID string `json:"id"` + } `json:"message"` + }](t, parent) + + reply := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"ответ <@`+f.memberID+`>","reply_to_id":"`+parentMessage.Message.ID+`"}`, f.ownerCookie) + if reply.Code != http.StatusOK { + t.Fatalf("reply = %d, body = %s", reply.Code, reply.Body.String()) + } + payload := decodeResponse[struct { + Message struct { + ReplyToID string `json:"reply_to_id"` + Mentions []string `json:"mentions"` + } `json:"message"` + }](t, reply) + if payload.Message.ReplyToID != parentMessage.Message.ID { + t.Fatalf("reply_to_id = %q", payload.Message.ReplyToID) + } + if len(payload.Message.Mentions) != 1 || payload.Message.Mentions[0] != f.memberID { + t.Fatalf("mentions = %+v, want member", payload.Message.Mentions) + } + + // Ответ на сообщение из другой комнаты отклоняется. + secretMessage := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages", + `{"content":"в другой комнате"}`, f.ownerCookie) + secretID := decodeResponse[struct { + Message struct { + ID string `json:"id"` + } `json:"message"` + }](t, secretMessage) + crossReply := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"нельзя","reply_to_id":"`+secretID.Message.ID+`"}`, f.ownerCookie) + if crossReply.Code != http.StatusUnprocessableEntity { + t.Fatalf("cross-channel reply = %d, want 422", crossReply.Code) + } +} + +func TestTypingAndReadState(t *testing.T) { + f := newMessagingFixture(t) + + typing := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/typing", "", f.memberCookie) + if typing.Code != http.StatusOK { + t.Fatalf("typing = %d, body = %s", typing.Code, typing.Body.String()) + } + + sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"прочитано"}`, f.ownerCookie) + message := decodeResponse[struct { + Message struct { + ID string `json:"id"` + } `json:"message"` + }](t, sent) + + ack := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/ack", + `{"last_message_id":"`+message.Message.ID+`"}`, f.memberCookie) + if ack.Code != http.StatusOK { + t.Fatalf("ack = %d, body = %s", ack.Code, ack.Body.String()) + } + states, err := f.srv.store.ListReadStates(t.Context(), guildIDOf(t, f.memberID)) + if err != nil { + t.Fatalf("ListReadStates: %v", err) + } + if len(states) != 1 || formatSnowflake(states[0].LastMessageID) != message.Message.ID { + t.Fatalf("read states = %+v", states) + } +} diff --git a/internal/server/realtime_test.go b/internal/server/realtime_test.go index ba132b5..00cd524 100644 --- a/internal/server/realtime_test.go +++ b/internal/server/realtime_test.go @@ -175,6 +175,54 @@ func TestRealtimeEventsReachConnectedClients(t *testing.T) { } } +// expectNoEvent проверяет, что за отведённое время событие не пришло. +func (c *realtimeClient) expectNoEvent(event string, wait time.Duration) { + c.t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), wait) + defer cancel() + for { + _, data, err := c.conn.Read(ctx) + if err != nil { + // Таймаут — то, что нужно: событие не доставлено. + return + } + var frame realtimeFrame + if err := json.Unmarshal(data, &frame); err != nil { + c.t.Fatalf("decode frame: %v", err) + } + if frame.Op == 0 && frame.T == event { + c.t.Fatalf("event %s must not reach this client", event) + } + } +} + +// TestChannelEventsFilteredByPermissions проверяет фильтрацию событий комнат: +// сообщения скрытой комнаты не уходят тем, кто её не видит (AGENT.md 8.3, 9.7). +func TestChannelEventsFilteredByPermissions(t *testing.T) { + f := newMessagingFixture(t) + httpServer := httptest.NewServer(f.srv.Handler()) + t.Cleanup(httpServer.Close) + + owner := dialGateway(t, httpServer, f.ownerCookie) + member := dialGateway(t, httpServer, f.memberCookie) + + // Открытая комната: событие получают оба. + if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"всем"}`, f.ownerCookie); rec.Code != http.StatusOK { + t.Fatalf("open channel message = %d, body = %s", rec.Code, rec.Body.String()) + } + owner.expectEvent("MESSAGE_CREATE") + member.expectEvent("MESSAGE_CREATE") + + // Скрытая комната: владелец получает событие, участник — нет. + if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages", + `{"content":"только для владельца"}`, f.ownerCookie); rec.Code != http.StatusOK { + t.Fatalf("secret channel message = %d, body = %s", rec.Code, rec.Body.String()) + } + owner.expectEvent("MESSAGE_CREATE") + member.expectNoEvent("MESSAGE_CREATE", 700*time.Millisecond) +} + // TestInstanceAdminManagesForeignGuild проверяет §11.5: администратор инстанса, // не состоящий в сервере, видит и меняет всё, а его самого модерировать нельзя. func TestInstanceAdminManagesForeignGuild(t *testing.T) { diff --git a/internal/server/server.go b/internal/server/server.go index a030b3a..17391c1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http" "strings" + "sync" "time" "github.com/danielgtaylor/huma/v2" @@ -46,10 +47,18 @@ type Server struct { // (AGENT.md 8.6). authLimiter *httpx.RateLimiter apiLimiter *httpx.RateLimiter - logger *slog.Logger - http *http.Server - static *staticHandler - api huma.API + // Лимиты Фазы 2 (AGENT.md 8.6): сообщения, typing и поиск. + messageLimiter *httpx.RateLimiter + typingLimiter *httpx.RateLimiter + searchLimiter *httpx.RateLimiter + // slowmode — время последней отправки в комнату для режима медленной + // отправки; словарь ограничен по размеру (AGENT.md 7.5). + slowmodeMu sync.Mutex + slowmode map[string]time.Time + logger *slog.Logger + http *http.Server + static *staticHandler + api huma.API } func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server { @@ -65,6 +74,11 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se // пользователя/IP — дефолты AGENT.md 8.6. authLimiter: httpx.NewRateLimiter(5, 5), apiLimiter: httpx.NewRateLimiter(120, 60), + // 5 сообщений за 5 секунд (burst 10), typing 1/3 c, поиск 10/мин. + messageLimiter: httpx.NewRateLimiter(60, 10), + typingLimiter: httpx.NewRateLimiter(20, 1), + searchLimiter: httpx.NewRateLimiter(10, 10), + slowmode: map[string]time.Time{}, } switch { case deps.Permissions != nil: @@ -85,6 +99,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerUserRoutes(s.api) s.registerGuildRoutes(s.api) s.registerInstanceRoutes(s.api) + s.registerMessageRoutes(s.api) } apiRouter.Get("/openapi.json", s.handleOpenAPI) }) diff --git a/internal/store/files.go b/internal/store/files.go new file mode 100644 index 0000000..c2967c7 --- /dev/null +++ b/internal/store/files.go @@ -0,0 +1,139 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// File — загруженный файл: метаданные в БД, содержимое на диске (AGENT.md 7.7). +type File struct { + ID uint64 + UploaderID *uint64 + GuildID *uint64 + ChannelID *uint64 + MessageID *uint64 + Filename string + ContentType string + SizeBytes int64 + Width int + Height int + StoragePath string + SHA256 string + CreatedAt time.Time +} + +// CreateFileParams — параметры регистрации файла. +type CreateFileParams struct { + ID uint64 + UploaderID uint64 + GuildID *uint64 + ChannelID *uint64 + Filename string + ContentType string + SizeBytes int64 + Width int + Height int + StoragePath string + SHA256 string +} + +const fileColumns = `id, uploader_id, guild_id, channel_id, message_id, filename, + content_type, size_bytes, width, height, storage_path, sha256, created_at` + +// CreateFile регистрирует загруженный файл. +func (s *Store) CreateFile(ctx context.Context, params CreateFileParams) (*File, error) { + if params.ID == 0 { + params.ID = s.NextID() + } + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO files (id, uploader_id, guild_id, channel_id, message_id, filename, + content_type, size_bytes, width, height, storage_path, sha256, created_at) + VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, + int64(params.ID), int64(params.UploaderID), nullableID(params.GuildID), nullableID(params.ChannelID), + params.Filename, params.ContentType, params.SizeBytes, params.Width, params.Height, + params.StoragePath, params.SHA256, s.Now()) + if err != nil { + return nil, mapError(err) + } + return s.GetFile(ctx, params.ID) +} + +func (s *Store) GetFile(ctx context.Context, id uint64) (*File, error) { + row := s.reader.QueryRowContext(ctx, `SELECT `+fileColumns+` FROM files WHERE id = ?`, int64(id)) + return scanFile(row) +} + +// AttachFileToMessage связывает файл с сообщением после отправки. +func (s *Store) AttachFileToMessage(ctx context.Context, fileID, messageID uint64) error { + result, err := s.writer.ExecContext(ctx, + `UPDATE files SET message_id = ? WHERE id = ?`, int64(messageID), int64(fileID)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +// DeleteFile удаляет запись о файле (содержимое чистит вызывающий код). +func (s *Store) DeleteFile(ctx context.Context, id uint64) error { + result, err := s.writer.ExecContext(ctx, `DELETE FROM files WHERE id = ?`, int64(id)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +// ListOrphanFiles возвращает файлы без сообщения старше указанного времени: +// их удаляет обслуживание (AGENT.md 7.7). +func (s *Store) ListOrphanFiles(ctx context.Context, olderThan time.Time, limit int) ([]File, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+fileColumns+` FROM files + WHERE message_id IS NULL AND created_at < ? ORDER BY id LIMIT ?`, + s.Timestamp(olderThan), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + files := make([]File, 0, limit) + for rows.Next() { + file, err := scanFile(rows) + if err != nil { + return nil, err + } + files = append(files, *file) + } + return files, rows.Err() +} + +func scanFile(scanner interface{ Scan(...any) error }) (*File, error) { + var ( + file File + uploaderID sql.NullInt64 + guildID sql.NullInt64 + channelID sql.NullInt64 + messageID sql.NullInt64 + createdAt string + ) + err := scanner.Scan(&file.ID, &uploaderID, &guildID, &channelID, &messageID, &file.Filename, + &file.ContentType, &file.SizeBytes, &file.Width, &file.Height, &file.StoragePath, + &file.SHA256, &createdAt) + if err != nil { + return nil, mapError(err) + } + file.UploaderID = optionalID(uploaderID) + file.GuildID = optionalID(guildID) + file.ChannelID = optionalID(channelID) + file.MessageID = optionalID(messageID) + file.CreatedAt = parseTimestamp(createdAt) + return &file, nil +} diff --git a/internal/store/messages.go b/internal/store/messages.go new file mode 100644 index 0000000..92c56ce --- /dev/null +++ b/internal/store/messages.go @@ -0,0 +1,401 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "time" +) + +// MessageType — тип сообщения (AGENT.md 7.6): обычное, системное или шёпот. +type MessageType string + +const ( + MessageDefault MessageType = "default" + MessageSystem MessageType = "system" + MessageWhisper MessageType = "whisper" +) + +// Message — сообщение комнаты. +type Message struct { + ID uint64 + ChannelID uint64 + AuthorID *uint64 + Content string + ReplyToID *uint64 + Type MessageType + EditedAt *time.Time + Pinned bool + Attachments []Attachment + Mentions []uint64 + CreatedAt time.Time +} + +// Attachment — метаданные вложения: файл регистрируется в таблице files. +type Attachment struct { + FileID uint64 `json:"file_id"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +// Reaction — агрегированная реакция: эмодзи и кто её поставил. +type Reaction struct { + Emoji string + Count int + UserIDs []uint64 + Me bool +} + +// ReadState — состояние прочтения комнаты пользователем. +type ReadState struct { + UserID uint64 + ChannelID uint64 + LastMessageID uint64 + MentionCount int + UpdatedAt time.Time +} + +const messageColumns = `id, channel_id, author_id, content, reply_to_id, type, + attachments_json, mentions_json, edited_at, pinned, created_at` + +// CreateMessageParams — параметры нового сообщения. +type CreateMessageParams struct { + ID uint64 + ChannelID uint64 + AuthorID uint64 + Content string + ReplyToID *uint64 + Type MessageType + Attachments []Attachment + Mentions []uint64 +} + +// CreateMessage сохраняет сообщение; пустое содержимое без вложений запрещено +// на уровне API, здесь только запись (AGENT.md 7.6). +func (s *Store) CreateMessage(ctx context.Context, params CreateMessageParams) (*Message, error) { + if params.ID == 0 { + params.ID = s.NextID() + } + if params.Type == "" { + params.Type = MessageDefault + } + attachments, err := json.Marshal(orEmptyAttachments(params.Attachments)) + if err != nil { + return nil, err + } + mentions, err := json.Marshal(orEmptyIDs(params.Mentions)) + if err != nil { + return nil, err + } + _, err = s.writer.ExecContext(ctx, ` + INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type, + attachments_json, mentions_json, pinned, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`, + int64(params.ID), int64(params.ChannelID), int64(params.AuthorID), params.Content, + nullableID(params.ReplyToID), string(params.Type), string(attachments), string(mentions), s.Now()) + if err != nil { + return nil, mapError(err) + } + return s.GetMessage(ctx, params.ID) +} + +func (s *Store) GetMessage(ctx context.Context, id uint64) (*Message, error) { + row := s.reader.QueryRowContext(ctx, `SELECT `+messageColumns+` FROM messages WHERE id = ?`, int64(id)) + return scanMessage(row) +} + +// ListMessages возвращает сообщения комнаты от новых к старым. beforeID +// используется для подгрузки истории вверх (AGENT.md 7.6). +func (s *Store) ListMessages(ctx context.Context, channelID, beforeID uint64, limit int) ([]Message, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + query := `SELECT ` + messageColumns + ` FROM messages WHERE channel_id = ?` + args := []any{int64(channelID)} + if beforeID > 0 { + query += ` AND id < ?` + args = append(args, int64(beforeID)) + } + query += ` ORDER BY id DESC LIMIT ?` + args = append(args, limit) + + rows, err := s.reader.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + messages := make([]Message, 0, limit) + for rows.Next() { + message, err := scanMessage(rows) + if err != nil { + return nil, err + } + messages = append(messages, *message) + } + return messages, rows.Err() +} + +// ListPinnedMessages возвращает закреплённые сообщения комнаты. +func (s *Store) ListPinnedMessages(ctx context.Context, channelID uint64, limit int) ([]Message, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+messageColumns+` FROM messages + WHERE channel_id = ? AND pinned = 1 ORDER BY id DESC LIMIT ?`, int64(channelID), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + messages := make([]Message, 0, limit) + for rows.Next() { + message, err := scanMessage(rows) + if err != nil { + return nil, err + } + messages = append(messages, *message) + } + return messages, rows.Err() +} + +// UpdateMessageContent меняет текст сообщения и фиксирует время правки. +func (s *Store) UpdateMessageContent(ctx context.Context, id uint64, content string) (*Message, error) { + result, err := s.writer.ExecContext(ctx, + `UPDATE messages SET content = ?, edited_at = ? WHERE id = ?`, content, s.Now(), int64(id)) + if err != nil { + return nil, err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return nil, ErrNotFound + } + return s.GetMessage(ctx, id) +} + +// SetMessagePinned закрепляет или открепляет сообщение (AGENT.md 7.6). +func (s *Store) SetMessagePinned(ctx context.Context, id uint64, pinned bool) error { + result, err := s.writer.ExecContext(ctx, + `UPDATE messages SET pinned = ? WHERE id = ?`, boolToInt(pinned), int64(id)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) DeleteMessage(ctx context.Context, id uint64) error { + result, err := s.writer.ExecContext(ctx, `DELETE FROM messages WHERE id = ?`, int64(id)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +// AddReaction ставит реакцию; повторная установка идемпотентна. +func (s *Store) AddReaction(ctx context.Context, messageID, userID uint64, emoji string) error { + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO message_reactions (message_id, user_id, emoji, created_at) + VALUES (?, ?, ?, ?) ON CONFLICT (message_id, user_id, emoji) DO NOTHING`, + int64(messageID), int64(userID), emoji, s.Now()) + return err +} + +// RemoveReaction снимает реакцию пользователя. +func (s *Store) RemoveReaction(ctx context.Context, messageID, userID uint64, emoji string) error { + _, err := s.writer.ExecContext(ctx, + `DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?`, + int64(messageID), int64(userID), emoji) + return err +} + +// ListReactions отдаёт реакции сообщения, сгруппированные по эмодзи. +func (s *Store) ListReactions(ctx context.Context, messageID, viewerID uint64) ([]Reaction, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT emoji, user_id FROM message_reactions + WHERE message_id = ? ORDER BY created_at`, int64(messageID)) + if err != nil { + return nil, err + } + defer rows.Close() + + order := make([]string, 0, 8) + grouped := map[string]*Reaction{} + for rows.Next() { + var ( + emoji string + userID uint64 + ) + if err := rows.Scan(&emoji, &userID); err != nil { + return nil, err + } + reaction, ok := grouped[emoji] + if !ok { + reaction = &Reaction{Emoji: emoji} + grouped[emoji] = reaction + order = append(order, emoji) + } + reaction.Count++ + reaction.UserIDs = append(reaction.UserIDs, userID) + if userID == viewerID { + reaction.Me = true + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + reactions := make([]Reaction, 0, len(order)) + for _, emoji := range order { + reactions = append(reactions, *grouped[emoji]) + } + return reactions, nil +} + +// SetReadState сохраняет позицию прочтения и счётчик упоминаний. +func (s *Store) SetReadState(ctx context.Context, userID, channelID, lastMessageID uint64, mentionCount int) error { + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO channel_read_states (user_id, channel_id, last_message_id, mention_count, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (user_id, channel_id) DO UPDATE SET + last_message_id = excluded.last_message_id, + mention_count = excluded.mention_count, + updated_at = excluded.updated_at`, + int64(userID), int64(channelID), int64(lastMessageID), mentionCount, s.Now()) + return err +} + +// ListReadStates отдаёт состояния прочтения пользователя (для READY). +func (s *Store) ListReadStates(ctx context.Context, userID uint64) ([]ReadState, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT user_id, channel_id, last_message_id, mention_count, updated_at + FROM channel_read_states WHERE user_id = ?`, int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + states := make([]ReadState, 0, 16) + for rows.Next() { + var ( + state ReadState + updatedAt string + ) + if err := rows.Scan(&state.UserID, &state.ChannelID, &state.LastMessageID, &state.MentionCount, &updatedAt); err != nil { + return nil, err + } + state.UpdatedAt = parseTimestamp(updatedAt) + states = append(states, state) + } + return states, rows.Err() +} + +// SearchMessages ищет сообщения по тексту с учётом списка доступных комнат +// (AGENT.md 7.15: права проверяет вызывающий код, хранилище ограничивает выборку). +func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query string, limit int) ([]Message, error) { + if len(channelIDs) == 0 || strings.TrimSpace(query) == "" { + return []Message{}, nil + } + if limit <= 0 || limit > 100 { + limit = 25 + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(channelIDs)), ",") + args := make([]any, 0, len(channelIDs)+2) + args = append(args, query) + for _, id := range channelIDs { + args = append(args, int64(id)) + } + args = append(args, limit) + + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+prefixedMessageColumns("m")+` + FROM messages_fts f + JOIN messages m ON m.id = f.rowid + WHERE messages_fts MATCH ? AND m.channel_id IN (`+placeholders+`) + ORDER BY m.id DESC LIMIT ?`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + messages := make([]Message, 0, limit) + for rows.Next() { + message, err := scanMessage(rows) + if err != nil { + return nil, err + } + messages = append(messages, *message) + } + return messages, rows.Err() +} + +// prefixedMessageColumns добавляет префикс таблицы к списку колонок. +func prefixedMessageColumns(alias string) string { + columns := strings.Split(strings.ReplaceAll(messageColumns, "\n", " "), ",") + for i, column := range columns { + columns[i] = alias + "." + strings.TrimSpace(column) + } + return strings.Join(columns, ", ") +} + +func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) { + var ( + message Message + authorID sql.NullInt64 + replyToID sql.NullInt64 + attachments string + mentions string + editedAt sql.NullString + pinned int + createdAt string + ) + err := scanner.Scan(&message.ID, &message.ChannelID, &authorID, &message.Content, &replyToID, + &message.Type, &attachments, &mentions, &editedAt, &pinned, &createdAt) + if err != nil { + return nil, mapError(err) + } + if authorID.Valid { + value := uint64(authorID.Int64) + message.AuthorID = &value + } + if replyToID.Valid { + value := uint64(replyToID.Int64) + message.ReplyToID = &value + } + if err := json.Unmarshal([]byte(attachments), &message.Attachments); err != nil { + message.Attachments = nil + } + if err := json.Unmarshal([]byte(mentions), &message.Mentions); err != nil { + message.Mentions = nil + } + if editedAt.Valid { + value := parseTimestamp(editedAt.String) + message.EditedAt = &value + } + message.Pinned = pinned == 1 + message.CreatedAt = parseTimestamp(createdAt) + return &message, nil +} + +func orEmptyAttachments(values []Attachment) []Attachment { + if values == nil { + return []Attachment{} + } + return values +} + +func orEmptyIDs(values []uint64) []uint64 { + if values == nil { + return []uint64{} + } + return values +} diff --git a/web/src/api/guilds.ts b/web/src/api/guilds.ts index 0318fdf..309c0e7 100644 --- a/web/src/api/guilds.ts +++ b/web/src/api/guilds.ts @@ -80,3 +80,23 @@ export function joinGuild(guildId: string): Promise<{ ok: true }> { export function leaveGuild(guildId: string): Promise<{ ok: true }> { return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/leave`, { method: 'POST' }); } + +/** + * Свой никнейм на сервере: сервер разрешает участнику менять его без прав + * (пустая строка сбрасывает ник). + */ +export async function updateGuildMember( + guildId: string, + userId: string, + input: { nickname: string }, +): Promise { + await request<{ ok: true }>( + `/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(userId)}`, + { method: 'PATCH', body: input }, + ); +} + +/** Удаление сервера: доступно владельцу и администратору инстанса. */ +export async function deleteGuild(guildId: string): Promise { + await request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}`, { method: 'DELETE' }); +} diff --git a/web/src/components/ui/Menu.tsx b/web/src/components/ui/Menu.tsx new file mode 100644 index 0000000..d8bd555 --- /dev/null +++ b/web/src/components/ui/Menu.tsx @@ -0,0 +1,194 @@ +import { + useEffect, + useRef, + type ButtonHTMLAttributes, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + type RefObject, +} from 'react'; + +interface MenuProps { + open: boolean; + /** Доступное имя меню (озвучивается скринридером). */ + label: string; + onClose: () => void; + children: ReactNode; + /** + * Элемент-якорь: клик по нему не считается кликом «вне меню» (иначе меню + * закрывалось бы и тут же открывалось заново), ему же возвращается фокус. + */ + anchorRef?: RefObject; + className?: string; +} + +const ITEM_SELECTOR = + '[role="menuitem"]:not([disabled]),[role="menuitemradio"]:not([disabled]),[role="menuitemcheckbox"]:not([disabled])'; + +/** + * Всплывающее меню (`role="menu"`): закрывается по Escape и клику вне, + * открывается с фокусом на первом пункте, стрелки/Home/End перемещают фокус, + * Tab закрывает меню и возвращает управление странице. + * + * Позиционирование задаёт родитель: панель абсолютная и растёт вверх + * (`.gl-popover`), поэтому меню удобно вешать над нижней панелью пользователя. + */ +export function Menu({ open, label, onClose, children, anchorRef, className = '' }: MenuProps) { + const panelRef = useRef(null); + const onCloseRef = useRef(onClose); + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + // Фокус — на первом пункте: меню сразу доступно с клавиатуры. + useEffect(() => { + if (!open) { + return; + } + const items = panelRef.current?.querySelectorAll(ITEM_SELECTOR); + items?.[0]?.focus(); + }, [open]); + + // Клик вне меню (и вне якоря) закрывает его. + useEffect(() => { + if (!open) { + return; + } + const onPointerDown = (event: MouseEvent): void => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if (panelRef.current?.contains(target) === true) { + return; + } + if (anchorRef?.current?.contains(target) === true) { + return; + } + onCloseRef.current(); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('touchstart', onPointerDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('touchstart', onPointerDown); + }; + }, [open, anchorRef]); + + // Escape работает и когда фокус ушёл на якорь. + useEffect(() => { + if (!open) { + return; + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + onCloseRef.current(); + anchorRef?.current?.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [open, anchorRef]); + + const onKeyDown = (event: ReactKeyboardEvent): void => { + if (event.key === 'Tab') { + // Меню — не часть tab-порядка: уводим фокус дальше по странице. + onClose(); + return; + } + const items = Array.from(panelRef.current?.querySelectorAll(ITEM_SELECTOR) ?? []); + if (items.length === 0) { + return; + } + const current = items.findIndex((item) => item === document.activeElement); + const move = (next: number): void => { + event.preventDefault(); + const target = items[(next + items.length) % items.length]; + target?.focus(); + }; + switch (event.key) { + case 'ArrowDown': + move(current + 1); + return; + case 'ArrowUp': + move(current <= 0 ? items.length - 1 : current - 1); + return; + case 'Home': + move(0); + return; + case 'End': + move(items.length - 1); + return; + default: + return; + } + }; + + if (!open) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +interface MenuItemProps extends ButtonHTMLAttributes { + /** Иконка слева (декоративная). */ + icon?: ReactNode; + /** Для `role="menuitemradio"`: выбран ли пункт (рисуется галочка). */ + selected?: boolean; + danger?: boolean; +} + +/** Пункт меню: обычный (`menuitem`) или переключатель (`menuitemradio`). */ +export function MenuItem({ + icon, + selected = false, + danger = false, + className = '', + children, + ...rest +}: MenuItemProps) { + const role = rest.role ?? 'menuitem'; + return ( + + ); +} + +/** Заголовок группы пунктов внутри меню. */ +export function MenuGroupLabel({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** Разделитель между группами пунктов. */ +export function MenuSeparator() { + return
; +} diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx index efd82c1..6a4aaf2 100644 --- a/web/src/components/ui/Modal.tsx +++ b/web/src/components/ui/Modal.tsx @@ -1,6 +1,8 @@ -import { useEffect, useRef, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; +import { motionDelay } from '@/lib/motion'; + interface ModalProps { open: boolean; title: string; @@ -9,13 +11,47 @@ interface ModalProps { footer?: ReactNode; } +/** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */ +const EXIT_MS = 140; + /** * Простое модальное окно: закрывается по Escape и по клику на подложку, * focus-lock обеспечивается браузером за счёт role="dialog" и автофокуса. + * + * Появление и исчезновение анимированы (см. `.gl-pop-in`/`.gl-pop-out`): + * при закрытии узел остаётся в DOM до конца обратной анимации, а при + * `prefers-reduced-motion` удаляется сразу. */ export function Modal({ open, title, onClose, children, footer }: ModalProps) { const { t } = useTranslation(); const panelRef = useRef(null); + const [mounted, setMounted] = useState(open); + const [closing, setClosing] = useState(false); + // Свежий onClose без перезапуска эффекта фокуса при каждом рендере. + const onCloseRef = useRef(onClose); + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + // Монтирование/размонтирование с учётом анимации закрытия. + useEffect(() => { + if (open) { + setMounted(true); + setClosing(false); + return; + } + if (!mounted) { + return; + } + setClosing(true); + const timer = window.setTimeout(() => { + setMounted(false); + setClosing(false); + }, motionDelay(EXIT_MS)); + return () => { + window.clearTimeout(timer); + }; + }, [open, mounted]); useEffect(() => { if (!open) { @@ -23,26 +59,31 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) { } const onKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { - onClose(); + onCloseRef.current(); } }; document.addEventListener('keydown', onKeyDown); + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; const firstField = panelRef.current?.querySelector( 'input, textarea, select, button', ); firstField?.focus(); return () => { document.removeEventListener('keydown', onKeyDown); + // Возвращаем фокус туда, откуда окно открыли. + previous?.focus(); }; - }, [open, onClose]); + }, [open]); - if (!open) { + if (!mounted) { return null; } return (
{ if (event.target === event.currentTarget) { onClose(); @@ -54,14 +95,16 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) { role="dialog" aria-modal="true" aria-label={title} - className="w-full max-w-md rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-5 shadow-[var(--shadow-3)]" + className={`w-full max-w-md rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-5 shadow-[var(--shadow-3)] ${ + closing ? 'gl-pop-out' : 'gl-pop-in' + }`} >

{title}

+ + +
- - - ⚙ - - - {signOut.isError ? ( - - {describeError(signOut.error)} - - ) : null} + {displayName} + + {t('user.menu.account')} + + + {t('user.menu.guilds')} + + +
+ {t('user.menu.status')} + {selectableStatuses.map((value) => ( + { + changeStatus.reset(); + changeStatus.mutate(value); + }} + > + {t(`user.status.${value}`)} + + ))} +
+ + { + signOut.reset(); + signOut.mutate(); + }} + > + {t(signOut.isPending ? 'user.loggingOut' : 'user.logout')} + + + + {errorText === null ? null : ( +

+ {errorText} +

+ )} ); } diff --git a/web/src/pages/lazyPages.ts b/web/src/pages/lazyPages.ts index 744e454..dd40ac6 100644 --- a/web/src/pages/lazyPages.ts +++ b/web/src/pages/lazyPages.ts @@ -13,7 +13,9 @@ export const AppLayout = lazy(() => import('@/pages/app/AppLayout')); export const GuildView = lazy(() => import('@/pages/app/GuildView')); export const NoGuildView = lazy(() => import('@/pages/app/NoGuildView')); export const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout')); +export const AccountSettingsLayout = lazy(() => import('@/pages/settings/AccountSettingsLayout')); export const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage')); export const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage')); export const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage')); export const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage')); +export const ServersSettingsPage = lazy(() => import('@/pages/settings/ServersSettingsPage')); diff --git a/web/src/pages/settings/AccountSettingsLayout.tsx b/web/src/pages/settings/AccountSettingsLayout.tsx new file mode 100644 index 0000000..5aac619 --- /dev/null +++ b/web/src/pages/settings/AccountSettingsLayout.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from 'react-i18next'; +import { NavLink, Outlet } from 'react-router'; + +import { SettingsSuspense } from '@/pages/settings/SettingsSection'; +import { useCurrentUser } from '@/lib/hooks'; + +/** + * Вкладки настроек аккаунта и инстанса. Живут внутри `/settings/account/*` + * и выглядят так же, как панель выбора сервера в `/settings/servers/*`. + */ +export default function AccountSettingsLayout() { + const { t } = useTranslation(); + const currentUser = useCurrentUser(); + + const tabs = [ + { to: 'profile', label: t('settings.tabs.profile') }, + { to: 'security', label: t('settings.tabs.security') }, + { to: 'appearance', label: t('settings.tabs.appearance') }, + ...(currentUser.data?.is_instance_admin === true + ? [{ to: 'instance', label: t('settings.tabs.instance') }] + : []), + ]; + + return ( + <> + + + + + + ); +} diff --git a/web/src/pages/settings/GuildGeneralSection.tsx b/web/src/pages/settings/GuildGeneralSection.tsx new file mode 100644 index 0000000..162eff9 --- /dev/null +++ b/web/src/pages/settings/GuildGeneralSection.tsx @@ -0,0 +1,95 @@ +import { useState, type FormEvent } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { guildQueryKey, updateGuild } from '@/api/guilds'; +import { myGuildsQueryKey } from '@/api/users'; +import type { Guild } from '@/api/types'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Field, TextAreaField } from '@/components/ui/Field'; +import { Button } from '@/components/ui/primitives'; +import { useSessionStore } from '@/stores/session'; +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +interface GuildGeneralSectionProps { + guild: Guild; + /** Владелец или участник с правами управления — иначе поля только для чтения. */ + canEdit: boolean; +} + +/** Общее в настройках сервера: имя и описание (PATCH /guilds/{id}). */ +export function GuildGeneralSection({ guild, canEdit }: GuildGeneralSectionProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [name, setName] = useState(guild.name); + const [description, setDescription] = useState(guild.description); + const [saved, setSaved] = useState(false); + + const save = useMutation({ + mutationFn: () => + updateGuild(guild.id, { name: name.trim(), description: description.trim() }), + onSuccess: (updated) => { + queryClient.setQueryData(guildQueryKey(guild.id), updated); + // Рейка и сайдбар берут имя из снапшота — обновляем и его. + useSessionStore.getState().renameGuild(guild.id, updated.name); + void queryClient.invalidateQueries({ queryKey: guildQueryKey(guild.id) }); + void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey }); + setSaved(true); + }, + }); + + const onSubmit = (event: FormEvent): void => { + event.preventDefault(); + if (!canEdit || name.trim() === '') { + return; + } + setSaved(false); + save.mutate(); + }; + + return ( + +
+ setName(event.target.value)} + /> + setDescription(event.target.value)} + /> + {canEdit ? null : ( +

{t('settings.servers.readOnlyHint')}

+ )} + + {save.isError ? : null} + {saved && !save.isError ? ( +

+ {t('settings.servers.general.saved')} +

+ ) : null} + + {canEdit ? ( +
+ +
+ ) : null} + +
+ ); +} diff --git a/web/src/pages/settings/GuildMembersSection.tsx b/web/src/pages/settings/GuildMembersSection.tsx new file mode 100644 index 0000000..3afacc5 --- /dev/null +++ b/web/src/pages/settings/GuildMembersSection.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from 'react-i18next'; + +import type { GuildMember } from '@/api/types'; +import { Avatar } from '@/components/ui/Avatar'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Badge } from '@/components/ui/primitives'; +import { SkeletonLines } from '@/components/ui/Skeleton'; +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +interface GuildMembersSectionProps { + members: GuildMember[] | undefined; + isPending: boolean; + error: unknown; + onRetry: () => void; +} + +/** Сколько участников показываем в превью (полный список — в следующих фазах). */ +const PREVIEW_LIMIT = 10; + +/** «Участники»: превью первых десяти с аватаром, именем и числом ролей. */ +export function GuildMembersSection({ + members, + isPending, + error, + onRetry, +}: GuildMembersSectionProps) { + const { t } = useTranslation(); + const preview = (members ?? []).slice(0, PREVIEW_LIMIT); + + return ( + + {isPending ? ( + + ) : error !== null && error !== undefined ? ( + + ) : preview.length === 0 ? ( +

{t('settings.servers.members.empty')}

+ ) : ( +
    + {preview.map((member) => { + const name = + member.nickname === undefined || member.nickname === '' + ? member.display_name + : member.nickname; + return ( +
  • + + + {name} + + {member.nickname === undefined || member.nickname === '' + ? `@${member.username}` + : `${member.display_name} · @${member.username}`} + + + + {t('settings.servers.members.roles', { count: member.role_ids.length })} + +
  • + ); + })} +
+ )} + +

{t('settings.servers.members.more')}

+
+ ); +} diff --git a/web/src/pages/settings/GuildNicknameSection.tsx b/web/src/pages/settings/GuildNicknameSection.tsx new file mode 100644 index 0000000..c5801ac --- /dev/null +++ b/web/src/pages/settings/GuildNicknameSection.tsx @@ -0,0 +1,93 @@ +import { useState, type FormEvent } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { guildMembersQueryKey, updateGuildMember } from '@/api/guilds'; +import type { GuildMember } from '@/api/types'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Field } from '@/components/ui/Field'; +import { Button } from '@/components/ui/primitives'; +import { useSessionStore } from '@/stores/session'; +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +interface GuildNicknameSectionProps { + guildId: string; + /** id текущего пользователя: сервер разрешает менять ник самому себе. */ + userId: string | undefined; + /** Ник из снапшота или списка участников. */ + currentNickname: string; + /** Участники сервера (для подсказки, если ник пришёл не из снапшота). */ + members: GuildMember[] | undefined; +} + +/** «Мой профиль на сервере»: свой никнейм, пустое значение его сбрасывает. */ +export function GuildNicknameSection({ + guildId, + userId, + currentNickname, + members, +}: GuildNicknameSectionProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [nickname, setNickname] = useState(currentNickname); + const [saved, setSaved] = useState(false); + + const save = useMutation({ + mutationFn: (value: string) => updateGuildMember(guildId, userId ?? '', { nickname: value }), + onSuccess: (_result, value) => { + useSessionStore.getState().setMyNickname(guildId, value); + void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) }); + setSaved(true); + }, + }); + + const myMember = members?.find((member) => member.user_id === userId); + const effective = currentNickname === '' ? (myMember?.nickname ?? '') : currentNickname; + + const onSubmit = (event: FormEvent): void => { + event.preventDefault(); + if (userId === undefined) { + return; + } + setSaved(false); + save.mutate(nickname.trim()); + }; + + return ( + +

+ {t('settings.servers.nickname.current')}{' '} + + {effective === '' ? t('settings.servers.nickname.none') : effective} + +

+
+ setNickname(event.target.value)} + /> + + {save.isError ? : null} + {saved && !save.isError ? ( +

+ {t('settings.servers.nickname.saved')} +

+ ) : null} + +
+ +
+ +
+ ); +} diff --git a/web/src/pages/settings/GuildRolesSection.tsx b/web/src/pages/settings/GuildRolesSection.tsx new file mode 100644 index 0000000..6c19b7d --- /dev/null +++ b/web/src/pages/settings/GuildRolesSection.tsx @@ -0,0 +1,67 @@ +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { fetchRoles, guildRolesQueryKey } from '@/api/guilds'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { SkeletonLines } from '@/components/ui/Skeleton'; +import { roleColorHex } from '@/lib/format'; +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +interface GuildRolesSectionProps { + guildId: string; +} + +/** «Роли»: цвет и позиция. В Фазе 1 — только чтение. */ +export function GuildRolesSection({ guildId }: GuildRolesSectionProps) { + const { t } = useTranslation(); + + const roles = useQuery({ + queryKey: guildRolesQueryKey(guildId), + queryFn: ({ signal }) => fetchRoles(guildId, signal), + retry: 0, + }); + + const sorted = (roles.data ?? []).slice().sort((left, right) => right.position - left.position); + + return ( + + {roles.isPending ? ( + + ) : roles.isError ? ( + void roles.refetch()} /> + ) : sorted.length === 0 ? ( +

{t('settings.servers.roles.empty')}

+ ) : ( +
    + {sorted.map((role) => { + const color = roleColorHex(role.color); + return ( +
  • +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/web/src/pages/settings/SettingsLayout.tsx b/web/src/pages/settings/SettingsLayout.tsx index 4e819fb..5ba671f 100644 --- a/web/src/pages/settings/SettingsLayout.tsx +++ b/web/src/pages/settings/SettingsLayout.tsx @@ -1,23 +1,17 @@ -import { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; -import { NavLink, Outlet } from 'react-router'; +import { NavLink, Outlet, useLocation } from 'react-router'; -import { RouteFallback } from '@/App'; -import { useCurrentUser } from '@/lib/hooks'; +import { SettingsSwitcher } from '@/pages/settings/SettingsSwitcher'; +import { SettingsSuspense } from '@/pages/settings/SettingsSection'; -/** Каркас настроек: вкладки профиля, безопасности, внешнего вида и инстанса. */ +/** + * Каркас настроек: две соседние кнопки-раздела («Настройки сервера» и + * «Мои серверы») и содержимое активного раздела. Переключение разделов — + * обычная навигация react-router, без перезагрузки страницы. + */ export default function SettingsLayout() { const { t } = useTranslation(); - const currentUser = useCurrentUser(); - - const tabs = [ - { to: 'profile', label: t('settings.tabs.profile') }, - { to: 'security', label: t('settings.tabs.security') }, - { to: 'appearance', label: t('settings.tabs.appearance') }, - ...(currentUser.data?.is_instance_admin === true - ? [{ to: 'instance', label: t('settings.tabs.instance') }] - : []), - ]; + const location = useLocation(); return (
@@ -28,27 +22,14 @@ export default function SettingsLayout() { - + - }> - - + {/* Ключ по пути: при смене вкладки/раздела контент появляется с анимацией. */} +
+ + + +
); } diff --git a/web/src/pages/settings/SettingsSection.tsx b/web/src/pages/settings/SettingsSection.tsx new file mode 100644 index 0000000..e64ca66 --- /dev/null +++ b/web/src/pages/settings/SettingsSection.tsx @@ -0,0 +1,47 @@ +import { Suspense, type ReactNode } from 'react'; + +import { Card } from '@/components/ui/primitives'; +import { SkeletonLines } from '@/components/ui/Skeleton'; + +interface SettingsSectionProps { + title: string; + description?: string | undefined; + children: ReactNode; + /** Дополнительные классы карточки (например, акцент опасной зоны). */ + className?: string; +} + +/** + * Секция настроек: одна и та же карточка с заголовком и пояснением — + * и в настройках аккаунта, и в настройках сервера. + */ +export function SettingsSection({ + title, + description, + children, + className = '', +}: SettingsSectionProps) { + return ( + +

{title}

+ {description === undefined ? null : ( +

{description}

+ )} + {children} +
+ ); +} + +/** Заглушка на время загрузки ленивой вкладки настроек. */ +export function SettingsSectionFallback() { + return ( + + + + ); +} + +/** Общая suspense-обёртка вкладок настроек. */ +export function SettingsSuspense({ children }: { children: ReactNode }) { + return }>{children}; +} diff --git a/web/src/pages/settings/SettingsSwitcher.tsx b/web/src/pages/settings/SettingsSwitcher.tsx new file mode 100644 index 0000000..573a2da --- /dev/null +++ b/web/src/pages/settings/SettingsSwitcher.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next'; +import { NavLink, useLocation } from 'react-router'; + +/** + * Сегментированный переключатель разделов настроек: две соседние кнопки + * «Настройки сервера» (аккаунт и инстанс) и «Мои серверы». Подложка активной + * кнопки переезжает без перезагрузки страницы. + */ +export function SettingsSwitcher() { + const { t } = useTranslation(); + const location = useLocation(); + const onServers = location.pathname.startsWith('/settings/servers'); + + const items = [ + { to: '/settings/account/profile', label: t('settings.switcher.account'), active: !onServers }, + { to: '/settings/servers', label: t('settings.switcher.servers'), active: onServers }, + ]; + + return ( + + ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 2334a7b..17a44af 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from 'react-router'; import { AuthGuard } from '@/components/AuthGuard'; import { + AccountSettingsLayout, AppearanceSettingsPage, AppLayout, IndexRedirect, @@ -13,6 +14,7 @@ import { ProfileSettingsPage, RegisterPage, SecuritySettingsPage, + ServersSettingsPage, SettingsLayout, StatusPage, } from '@/pages/lazyPages'; @@ -52,11 +54,30 @@ export const routes = [ path: 'settings', element: , children: [ - { index: true, element: }, - { path: 'profile', element: }, - { path: 'security', element: }, - { path: 'appearance', element: }, - { path: 'instance', element: }, + { index: true, element: }, + { + // Настройки аккаунта и инстанса: /settings/account/* + path: 'account', + element: , + children: [ + { index: true, element: }, + { path: 'profile', element: }, + { path: 'security', element: }, + { path: 'appearance', element: }, + { path: 'instance', element: }, + ], + }, + // Настройки пользовательских серверов: /settings/servers/:guildId + { path: 'servers', element: }, + { path: 'servers/:guildId', element: }, + // Старые пути Фазы 1: закладки и внешние ссылки продолжают работать. + { path: 'profile', element: }, + { path: 'security', element: }, + { + path: 'appearance', + element: , + }, + { path: 'instance', element: }, ], }, ], diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index da32598..5edf148 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -22,6 +22,8 @@ interface SessionState { sessionId: string | null; selectedGuildId: string | null; selectedChannelId: string | null; + /** Последний сервер, открытый в настройках (`/settings/servers/:guildId`). */ + settingsGuildId: string | null; applyReady: (snapshot: GatewaySnapshot) => void; /** RESUMED приходит без снапшота — обновляем только то, что пришло. */ @@ -34,6 +36,8 @@ interface SessionState { upsertGuild: (guild: GuildSummary) => void; /** GUILD_UPDATE: имя сервера (описание в снапшоте Фазы 1 не хранится). */ renameGuild: (guildId: string, name: string) => void; + /** Свой никнейм на сервере (PATCH /guilds/{id}/members/{me}). */ + setMyNickname: (guildId: string, nickname: string) => void; removeGuild: (guildId: string) => void; setGuildChannels: (guildId: string, channels: Channel[]) => void; upsertChannel: (guildId: string, channel: Channel) => void; @@ -43,6 +47,8 @@ interface SessionState { firstChannelId: (guildId: string) => string | null; selectGuild: (guildId: string | null) => void; selectChannel: (guildId: string | null, channelId: string | null) => void; + /** Выбор сервера в настройках — отдельно от выбора в приложении. */ + selectSettingsGuild: (guildId: string | null) => void; reset: () => void; } @@ -53,6 +59,7 @@ interface SessionDefaults { sessionId: null; selectedGuildId: null; selectedChannelId: null; + settingsGuildId: null; } const defaults: SessionDefaults = { @@ -62,6 +69,7 @@ const defaults: SessionDefaults = { sessionId: null, selectedGuildId: null, selectedChannelId: null, + settingsGuildId: null, }; function toSessionGuild(guild: GatewayGuild): SessionGuild { @@ -177,6 +185,14 @@ export const useSessionStore = create((set, get) => ({ }); }, + setMyNickname: (guildId, nickname) => { + set({ + guilds: get().guilds.map((guild) => + guild.id === guildId ? { ...guild, my_nickname: nickname } : guild, + ), + }); + }, + removeGuild: (guildId) => { const state = get(); const guilds = state.guilds.filter((guild) => guild.id !== guildId); @@ -243,6 +259,10 @@ export const useSessionStore = create((set, get) => ({ set({ selectedGuildId: guildId, selectedChannelId: channelId }); }, + selectSettingsGuild: (guildId) => { + set({ settingsGuildId: guildId }); + }, + reset: () => { set({ ...defaults }); },