diff --git a/internal/database/migrations/00020_phase7_group_dm.sql b/internal/database/migrations/00020_phase7_group_dm.sql new file mode 100644 index 0000000..aa8cdee --- /dev/null +++ b/internal/database/migrations/00020_phase7_group_dm.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Фаза 7: групповые личные беседы (AGENT.md 7.8). +-- +-- Личная беседа на несколько участников живёт в тех же таблицах, что и 1:1 +-- (channels.type = 'dm' + dm_participants), поэтому нужен только владелец: +-- он добавляет и удаляет участников и переименовывает беседу. Когда +-- участников остаётся двое, владелец сбрасывается — беседа снова обычная +-- личная (см. internal/store/dm_groups.go). + +ALTER TABLE channels ADD COLUMN dm_owner_id INTEGER REFERENCES users (id) ON DELETE SET NULL; + +-- +goose Down +ALTER TABLE channels DROP COLUMN dm_owner_id; diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index ebf9af5..ec1fae9 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -144,6 +144,12 @@ type ReadyChannel struct { Status string `json:"status,omitempty"` LastMessageID string `json:"last_message_id,omitempty"` LastMessageAt string `json:"last_message_at,omitempty"` + // Групповая личная беседа (AGENT.md 7.8, Фаза 7): имя, состав и владелец. + // У 1:1 is_group = false, а собеседник лежит в recipient_id. + IsGroup bool `json:"is_group,omitempty"` + MemberCount int `json:"member_count,omitempty"` + MemberIDs []string `json:"member_ids,omitempty"` + OwnerID string `json:"owner_id,omitempty"` } type ReadyRole struct { diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index 888877a..15b1468 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -92,6 +92,19 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error) RecipientID: formatID(summary.RecipientID), Status: visibleStatus(summary.Status, summary.LastSeenAt), } + if summary.IsGroup { + // Групповая беседа: имя и состав вместо собеседника. + channel.IsGroup = true + channel.Name = summary.GroupName + channel.MemberCount = summary.MemberCount + channel.MemberIDs = make([]string, 0, len(summary.Participants)) + for _, participant := range summary.Participants { + channel.MemberIDs = append(channel.MemberIDs, formatID(participant.UserID)) + } + if summary.OwnerID != nil { + channel.OwnerID = formatID(*summary.OwnerID) + } + } if summary.AvatarFileID != nil { channel.IconFileID = formatID(*summary.AvatarFileID) } diff --git a/internal/server/api_social.go b/internal/server/api_social.go index c7da81c..3a8fcd8 100644 --- a/internal/server/api_social.go +++ b/internal/server/api_social.go @@ -3,6 +3,8 @@ package server import ( "context" "errors" + "fmt" + "log/slog" "net/http" "strings" "time" @@ -66,6 +68,25 @@ type dmChannelPayload struct { } `json:"recipient"` LastMessageID string `json:"last_message_id,omitempty"` LastMessageAt string `json:"last_message_at,omitempty"` + // Групповая беседа (AGENT.md 7.8): имя, участники и владелец. У 1:1 + // is_group = false, а данные собеседника лежат в recipient. + IsGroup bool `json:"is_group"` + Name string `json:"name,omitempty"` + MemberCount int `json:"member_count,omitempty"` + OwnerID string `json:"owner_id,omitempty"` + Recipients []dmRecipientPayload `json:"recipients,omitempty"` +} + +// dmRecipientPayload — участник групповой беседы. +type dmRecipientPayload struct { + UserID string `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + AvatarFileID string `json:"avatar_file_id,omitempty"` + Status string `json:"status"` + VisibleStatus string `json:"visible_status"` + IsOwner bool `json:"is_owner"` + IsSelf bool `json:"is_self"` } type dmChannelListOutput struct { @@ -407,6 +428,362 @@ func (s *Server) registerSocialRoutes(api huma.API) { } return output, nil }) + + // Групповые личные беседы (AGENT.md 7.8, Фаза 7): создание, добавление и + // выход участников, переименование. Права простые и явные: добавлять может + // любой участник, удалять других и переименовывать — владелец, выйти может + // каждый сам. + huma.Register(api, huma.Operation{ + OperationID: "createGroupDirectChannel", + Method: http.MethodPost, + Path: "/users/@me/channels/group", + Summary: "Создать групповую беседу", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, input *struct { + Body struct { + Name string `json:"name,omitempty" maxLength:"64"` + RecipientIDs []string `json:"recipient_ids"` + } + }, + ) (*dmChannelOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + members, err := s.groupDMMembers(ctx, user.ID, input.Body.RecipientIDs) + if err != nil { + return nil, err + } + // Группа — это минимум три участника: двое и меньше остаются обычной + // личной беседой (см. store.RemoveDMParticipant). + if len(members) < 2 { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", + "a group conversation needs at least two other members") + } + name := strings.TrimSpace(input.Body.Name) + if name == "" { + name = s.defaultGroupDMName(ctx, members) + } + if len([]rune(name)) > maxGroupDMNameLength { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "group name is too long") + } + channel, err := s.store.CreateGroupDMChannel(ctx, user.ID, name, members) + if err != nil { + return nil, humaError(err) + } + s.dispatchDMCreate(channel.ID, append([]uint64{user.ID}, members...)) + summary, err := s.groupDMSummary(ctx, channel, user.ID) + if err != nil { + return nil, err + } + output := &dmChannelOutput{} + output.Body.Channel = s.dmChannelPayload(summary) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "addDirectChannelRecipient", + Method: http.MethodPut, + Path: "/channels/{channel_id}/recipients/{user_id}", + Summary: "Добавить участника в групповую беседу", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + UserID string `path:"user_id"` + }, + ) (*dmChannelOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) + if err != nil { + return nil, err + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + if targetID == user.ID { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you are already a member") + } + if _, err := s.store.GetUser(ctx, targetID); err != nil { + return nil, humaError(err) + } + // Блокировка запрещает и добавление в общую беседу (AGENT.md 7.2). + if blocked, err := s.store.IsBlocked(ctx, user.ID, targetID); err != nil { + return nil, humaError(err) + } else if blocked { + return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked") + } + count, err := s.store.DMParticipantCount(ctx, channel.ID) + if err != nil { + return nil, humaError(err) + } + if count >= maxGroupDMMembers { + return nil, humaErrorStatus(http.StatusConflict, "dm.members_limit", "group conversation is full") + } + if err := s.store.AddDMParticipant(ctx, channel.ID, targetID); err != nil { + return nil, humaError(err) + } + // Новый участник получает событие создания беседы, остальные — обновление + // состава (AGENT.md 8.3). + if s.gateway != nil { + s.gateway.SendToUser(targetID, "DM_CHANNEL_CREATE", map[string]any{ + "channel_id": formatSnowflake(channel.ID), + }) + } + s.dispatchDMUpdate(channel, []uint64{}, targetID) + summary, err := s.groupDMSummary(ctx, channel, user.ID) + if err != nil { + return nil, err + } + output := &dmChannelOutput{} + output.Body.Channel = s.dmChannelPayload(summary) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "removeDirectChannelRecipient", + Method: http.MethodDelete, + Path: "/channels/{channel_id}/recipients/{user_id}", + Summary: "Выйти из групповой беседы или удалить участника", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + UserID string `path:"user_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) + if err != nil { + return nil, err + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + if targetID != user.ID { + // Удалять других может только владелец беседы. + if channel.DMOwnerID == nil || *channel.DMOwnerID != user.ID { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", + "only the group owner can remove other members") + } + member, err := s.store.IsDMParticipant(ctx, channel.ID, targetID) + if err != nil { + return nil, humaError(err) + } + if !member { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "user is not a member of this conversation") + } + } + if err := s.store.RemoveDMParticipant(ctx, channel.ID, targetID); err != nil { + return nil, humaError(err) + } + // Удалённый (или вышедший) теряет доступ: ему уходит CHANNEL_DELETE, + // остальным — обновление состава. + if s.gateway != nil { + s.gateway.SendToUser(targetID, "DM_CHANNEL_DELETE", map[string]any{ + "channel_id": formatSnowflake(channel.ID), + }) + } + s.dispatchDMUpdate(channel, []uint64{targetID}, 0) + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "renameDirectChannel", + Method: http.MethodPatch, + Path: "/channels/{channel_id}", + Summary: "Переименовать групповую беседу (владелец)", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, input *struct { + ChannelID string `path:"channel_id"` + Body struct { + Name string `json:"name" maxLength:"64"` + } + }, + ) (*dmChannelOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) + if err != nil { + return nil, err + } + if channel.DMOwnerID == nil || *channel.DMOwnerID != user.ID { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the group owner can rename the conversation") + } + name := strings.TrimSpace(input.Body.Name) + if name == "" || len([]rune(name)) > maxGroupDMNameLength { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "group name must be 1-64 characters") + } + if err := s.store.RenameDMChannel(ctx, channel.ID, name); err != nil { + return nil, humaError(err) + } + channel.Name = name + s.dispatchDMUpdate(channel, []uint64{}, 0) + summary, err := s.groupDMSummary(ctx, channel, user.ID) + if err != nil { + return nil, err + } + output := &dmChannelOutput{} + output.Body.Channel = s.dmChannelPayload(summary) + return output, nil + }) +} + +// Пределы групповой беседы (AGENT.md 7.8): не больше десяти участников и +// 64 символа в названии — группа не должна превращаться в сервер. +const ( + maxGroupDMMembers = 10 + maxGroupDMNameLength = 64 +) + +// groupDMMembers проверяет список приглашённых: пользователи существуют, это +// не сам приглашающий, никто не заблокирован. +func (s *Server) groupDMMembers(ctx context.Context, selfID uint64, rawIDs []string) ([]uint64, error) { + if len(rawIDs) > maxGroupDMMembers-1 { + return nil, humaErrorStatus(http.StatusConflict, "dm.members_limit", "group conversation is full") + } + seen := map[uint64]bool{} + members := make([]uint64, 0, len(rawIDs)) + for _, rawID := range rawIDs { + targetID, err := parseID("recipient_ids", rawID) + if err != nil { + return nil, err + } + if targetID == selfID || seen[targetID] { + continue + } + if _, err := s.store.GetUser(ctx, targetID); err != nil { + return nil, humaError(err) + } + if blocked, err := s.store.IsBlocked(ctx, selfID, targetID); err != nil { + return nil, humaError(err) + } else if blocked { + return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked") + } + seen[targetID] = true + members = append(members, targetID) + } + return members, nil +} + +// defaultGroupDMName собирает имя по участникам: «Алиса, Боб и ещё 2». +func (s *Server) defaultGroupDMName(ctx context.Context, members []uint64) string { + names := make([]string, 0, len(members)) + for _, memberID := range members { + member, err := s.store.GetUser(ctx, memberID) + if err != nil { + continue + } + name := member.DisplayName + if name == "" { + name = member.Username + } + names = append(names, name) + } + if len(names) == 0 { + return "Групповая беседа" + } + // В имя попадает не больше двух имён: длинный список обрезается. + if len(names) > 2 { + return fmt.Sprintf("%s, %s и ещё %d", names[0], names[1], len(names)-2) + } + return strings.Join(names, ", ") +} + +// groupDMForMember проверяет, что канал — личная беседа, а пользователь в ней +// состоит. Посторонним отвечаем 404: существование чужой беседы не подтверждаем. +func (s *Server) groupDMForMember(ctx context.Context, rawChannelID string, userID uint64) (*store.Channel, error) { + channelID, err := parseID("channel_id", rawChannelID) + if err != nil { + return nil, err + } + channel, err := s.store.GetChannel(ctx, channelID) + if err != nil { + return nil, humaError(err) + } + if channel.Type != store.ChannelDM || channel.GuildID != nil { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found") + } + participant, err := s.store.IsDMParticipant(ctx, channelID, userID) + if err != nil { + return nil, humaError(err) + } + if !participant { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found") + } + return channel, nil +} + +// groupDMSummary собирает карточку групповой беседы для ответа API. +func (s *Server) groupDMSummary(ctx context.Context, channel *store.Channel, viewerID uint64) (store.DMChannelSummary, error) { + participants, err := s.store.DMParticipantProfiles(ctx, channel.ID, viewerID) + if err != nil { + return store.DMChannelSummary{}, humaError(err) + } + ownerID := channel.DMOwnerID + return store.DMChannelSummary{ + Channel: *channel, + IsGroup: true, + GroupName: channel.Name, + MemberCount: len(participants), + Participants: participants, + OwnerID: ownerID, + }, nil +} + +// dispatchDMCreate сообщает участникам о новой беседе (AGENT.md 8.3). +func (s *Server) dispatchDMCreate(channelID uint64, userIDs []uint64) { + if s.gateway == nil { + return + } + for _, userID := range userIDs { + s.gateway.SendToUser(userID, "DM_CHANNEL_CREATE", map[string]any{ + "channel_id": formatSnowflake(channelID), + }) + } +} + +// dispatchDMUpdate сообщает участникам об изменении состава или имени: сам +// канал отдаётся в событии, чтобы клиент не делал лишний запрос. +func (s *Server) dispatchDMUpdate(channel *store.Channel, except []uint64, include uint64) { + if s.gateway == nil { + return + } + participants, err := s.store.DMParticipants(context.Background(), channel.ID) + if err != nil { + s.logger.WarnContext(context.Background(), "не удалось прочитать участников беседы", + slog.Any("error", err)) + return + } + skip := map[uint64]bool{} + for _, userID := range except { + skip[userID] = true + } + recipients := make([]uint64, 0, len(participants)+1) + for _, userID := range participants { + if !skip[userID] { + recipients = append(recipients, userID) + } + } + if include != 0 { + recipients = append(recipients, include) + } + payload := map[string]any{"channel_id": formatSnowflake(channel.ID)} + for _, userID := range recipients { + s.gateway.SendToUser(userID, "DM_CHANNEL_UPDATE", payload) + } } // relationshipTarget находит пользователя по id или логину. @@ -553,6 +930,37 @@ func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPaylo CanSend: true, CanView: true, } + if summary.IsGroup { + payload.IsGroup = true + payload.Name = summary.GroupName + payload.MemberCount = summary.MemberCount + if summary.OwnerID != nil { + payload.OwnerID = formatSnowflake(*summary.OwnerID) + } + payload.Recipients = make([]dmRecipientPayload, 0, len(summary.Participants)) + for _, participant := range summary.Participants { + item := dmRecipientPayload{ + UserID: formatSnowflake(participant.UserID), + Username: participant.Username, + DisplayName: participant.DisplayName, + Status: participant.Status, + VisibleStatus: visibleStatus(participant.Status, participant.LastSeenAt), + IsOwner: participant.IsOwner, + IsSelf: participant.IsCurrentUser, + } + if participant.AvatarFileID != nil { + item.AvatarFileID = formatSnowflake(*participant.AvatarFileID) + } + payload.Recipients = append(payload.Recipients, item) + } + if summary.LastMessageAt != nil { + payload.LastMessageAt = summary.LastMessageAt.UTC().Format(timeLayout) + } + if summary.LastMessageID != 0 { + payload.LastMessageID = formatSnowflake(summary.LastMessageID) + } + return payload + } payload.Recipient.UserID = formatSnowflake(summary.RecipientID) payload.Recipient.Username = summary.RecipientLogin payload.Recipient.DisplayName = summary.RecipientName diff --git a/internal/server/group_dm_test.go b/internal/server/group_dm_test.go new file mode 100644 index 0000000..a73d982 --- /dev/null +++ b/internal/server/group_dm_test.go @@ -0,0 +1,336 @@ +package server + +import ( + "encoding/json" + "net/http" + "testing" + + "glchat/internal/gateway" +) + +// dmChannelList — ответ GET /users/@me/channels со всеми полями групп. +type dmChannelList struct { + Channels []struct { + ID string `json:"id"` + IsGroup bool `json:"is_group"` + Name string `json:"name"` + MemberCount int `json:"member_count"` + OwnerID string `json:"owner_id"` + Recipient struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + } `json:"recipient"` + Recipients []struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + IsOwner bool `json:"is_owner"` + IsSelf bool `json:"is_self"` + } `json:"recipients"` + } `json:"channels"` +} + +func listDMChannels(t *testing.T, srv *Server, cookie *http.Cookie) dmChannelList { + t.Helper() + rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/channels", "", cookie) + if rec.Code != http.StatusOK { + t.Fatalf("GET /users/@me/channels = %d, body = %s", rec.Code, rec.Body.String()) + } + return decodeResponse[dmChannelList](t, rec) +} + +// TestGroupDirectChannelLifecycle — групповая беседа целиком (AGENT.md 7.8, +// Фаза 7): создание, состав в списке у каждого участника, выход и удаление +// участника владельцем, переименование, запрет для посторонних. +func TestGroupDirectChannelLifecycle(t *testing.T) { + srv, _ := newTestServer(t) + aliceCookie := registerAndLogin(t, srv, "alice_group", "alice-group@example.com") + bobCookie := registerAndLogin(t, srv, "bob_group", "bob-group@example.com") + carolCookie := registerAndLogin(t, srv, "carol_group", "carol-group@example.com") + daveCookie := registerAndLogin(t, srv, "dave_group", "dave-group@example.com") + + userID := func(email string) string { + t.Helper() + user, err := srv.auth.UserByEmail(t.Context(), email) + if err != nil { + t.Fatalf("UserByEmail(%s): %v", email, err) + } + return formatSnowflake(user.ID) + } + aliceID, bobID, carolID, daveID := userID("alice-group@example.com"), + userID("bob-group@example.com"), userID("carol-group@example.com"), userID("dave-group@example.com") + + // Создание: Алиса + Боб + Кэрол. + rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group", + `{"name":"Тестовая группа","recipient_ids":["`+bobID+`","`+carolID+`"]}`, aliceCookie) + if rec.Code != http.StatusOK { + t.Fatalf("create group = %d, body = %s", rec.Code, rec.Body.String()) + } + created := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + IsGroup bool `json:"is_group"` + Name string `json:"name"` + MemberCount int `json:"member_count"` + OwnerID string `json:"owner_id"` + } `json:"channel"` + }](t, rec) + channelID := created.Channel.ID + if channelID == "" || !created.Channel.IsGroup || created.Channel.Name != "Тестовая группа" { + t.Fatalf("unexpected group payload: %s", rec.Body.String()) + } + if created.Channel.MemberCount != 3 || created.Channel.OwnerID != aliceID { + t.Fatalf("group must have 3 members and alice as owner: %s", rec.Body.String()) + } + + // Каждый участник видит беседу в своём списке, посторонний — нет. + for _, cookie := range []*http.Cookie{aliceCookie, bobCookie, carolCookie} { + list := listDMChannels(t, srv, cookie) + found := false + for _, channel := range list.Channels { + if channel.ID == channelID { + found = true + if !channel.IsGroup || channel.MemberCount != 3 { + t.Fatalf("group channel in list = %+v", channel) + } + } + } + if !found { + t.Fatalf("participant does not see the group channel") + } + } + for _, channel := range listDMChannels(t, srv, daveCookie).Channels { + if channel.ID == channelID { + t.Fatal("stranger must not see the group channel") + } + } + + // Посторонний не может ни переименовать, ни добавить участника (404). + rec = doJSON(t, srv, http.MethodPatch, "/api/v1/channels/"+channelID, `{"name":"Взлом"}`, daveCookie) + if rec.Code != http.StatusNotFound { + t.Fatalf("rename by stranger = %d, want 404, body = %s", rec.Code, rec.Body.String()) + } + rec = doJSON(t, srv, http.MethodPut, "/api/v1/channels/"+channelID+"/recipients/"+daveID, "", daveCookie) + if rec.Code != http.StatusNotFound { + t.Fatalf("add by stranger = %d, want 404", rec.Code) + } + + // Участник (не владелец) добавляет Дейва: состав становится 4. + rec = doJSON(t, srv, http.MethodPut, "/api/v1/channels/"+channelID+"/recipients/"+daveID, "", bobCookie) + if rec.Code != http.StatusOK { + t.Fatalf("add participant = %d, body = %s", rec.Code, rec.Body.String()) + } + added := decodeResponse[struct { + Channel struct { + MemberCount int `json:"member_count"` + Recipients []struct { + UserID string `json:"user_id"` + IsOwner bool `json:"is_owner"` + } `json:"recipients"` + } `json:"channel"` + }](t, rec) + if added.Channel.MemberCount != 4 { + t.Fatalf("member count after add = %d, want 4", added.Channel.MemberCount) + } + if len(added.Channel.Recipients) != 4 || !added.Channel.Recipients[0].IsOwner { + t.Fatalf("recipients payload = %s", rec.Body.String()) + } + + // Не владелец не может удалить другого участника. + rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/recipients/"+carolID, "", bobCookie) + if rec.Code != http.StatusForbidden { + t.Fatalf("remove by non-owner = %d, want 403, body = %s", rec.Code, rec.Body.String()) + } + if code := errorCodeOf(t, rec); code != "perm.denied" { + t.Fatalf("error code = %q", code) + } + + // Владелец удаляет Дейва, и тот теряет доступ к беседе. + rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/recipients/"+daveID, "", aliceCookie) + if rec.Code != http.StatusOK { + t.Fatalf("owner removes member = %d, body = %s", rec.Code, rec.Body.String()) + } + rec = doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages", `{"content":"привет"}`, daveCookie) + if rec.Code != http.StatusForbidden && rec.Code != http.StatusNotFound { + t.Fatalf("removed member can post = %d (%s)", rec.Code, rec.Body.String()) + } + + // Переименование владельцем. + rec = doJSON(t, srv, http.MethodPatch, "/api/v1/channels/"+channelID, `{"name":"Новое имя"}`, aliceCookie) + if rec.Code != http.StatusOK { + t.Fatalf("owner rename = %d, body = %s", rec.Code, rec.Body.String()) + } + renamed := decodeResponse[struct { + Channel struct { + Name string `json:"name"` + } `json:"channel"` + }](t, rec) + if renamed.Channel.Name != "Новое имя" { + t.Fatalf("renamed channel = %s", rec.Body.String()) + } + + // Кэрол выходит сама: остаётся трое — беседа по-прежнему групповая. + rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/recipients/"+carolID, "", carolCookie) + if rec.Code != http.StatusOK { + t.Fatalf("leave group = %d, body = %s", rec.Code, rec.Body.String()) + } + for _, channel := range listDMChannels(t, srv, carolCookie).Channels { + if channel.ID == channelID { + t.Fatal("member who left still sees the group channel") + } + } + + // После выхода Кэрол остаются двое: беседа снова обычная личная, имя и + // владелец сброшены (AGENT.md 7.8). + list := listDMChannels(t, srv, aliceCookie) + var pair *int + for index := range list.Channels { + if list.Channels[index].ID == channelID { + pair = &index + } + } + if pair == nil { + t.Fatal("two-member conversation disappeared from the list") + } + plain := list.Channels[*pair] + if plain.IsGroup || plain.Name != "" || plain.OwnerID != "" { + t.Fatalf("two-member conversation must become a plain DM: %+v", plain) + } + // В списке Алисы собеседник — Боб (у 1:1 поле recipient одно). + if plain.Recipient.UserID != bobID { + t.Fatalf("unexpected recipient: %+v", plain.Recipient) + } + + // Последний участник выходит — беседа удаляется вместе с перепиской: + // разговор с самим собой смысла не имеет. + rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/recipients/"+bobID, "", bobCookie) + if rec.Code != http.StatusOK { + t.Fatalf("last leave = %d, body = %s", rec.Code, rec.Body.String()) + } + for _, channel := range listDMChannels(t, srv, aliceCookie).Channels { + if channel.ID == channelID { + t.Fatal("conversation with a single member must be removed") + } + } +} + +// TestGroupDirectChannelValidation — ограничения групповой беседы: не меньше +// трёх участников, не больше десяти, запрет на заблокированных. +func TestGroupDirectChannelValidation(t *testing.T) { + srv, _ := newTestServer(t) + aliceCookie := registerAndLogin(t, srv, "alice_gval", "alice-gval@example.com") + registerAndLogin(t, srv, "bob_gval", "bob-gval@example.com") + bob, err := srv.auth.UserByEmail(t.Context(), "bob-gval@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + bobID := formatSnowflake(bob.ID) + + // Один получатель — это обычная личная беседа, а не группа. + rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group", + `{"recipient_ids":["`+bobID+`"]}`, aliceCookie) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("group with one recipient = %d, want 422, body = %s", rec.Code, rec.Body.String()) + } + + // Себя в списке можно не указывать: дубликаты и self игнорируются, но + // вдвоём группы не получается — снова 422. + rec = doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group", + `{"recipient_ids":["`+bobID+`","`+bobID+`"]}`, aliceCookie) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("group with duplicate recipients = %d, want 422", rec.Code) + } + + // Заблокированного нельзя добавить в группу (AGENT.md 7.2). + rec = doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+bobID, "", aliceCookie) + if rec.Code != http.StatusOK { + t.Fatalf("block user = %d, body = %s", rec.Code, rec.Body.String()) + } + registerAndLogin(t, srv, "carol_gval", "carol-gval@example.com") + carol, err := srv.auth.UserByEmail(t.Context(), "carol-gval@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + rec = doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group", + `{"recipient_ids":["`+bobID+`","`+formatSnowflake(carol.ID)+`"]}`, aliceCookie) + if rec.Code != http.StatusForbidden { + t.Fatalf("group with blocked user = %d, want 403, body = %s", rec.Code, rec.Body.String()) + } + if code := errorCodeOf(t, rec); code != "dm.blocked" { + t.Fatalf("error code = %q", code) + } + + // Имя по умолчанию собирается из участников, если его не задали. + rec = doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group", + `{"recipient_ids":["`+formatSnowflake(carol.ID)+`"]}`, aliceCookie) + if rec.Code != http.StatusUnprocessableEntity { + // Кэрол + Алиса = двое: снова не группа, проверяем именно отказ. + t.Fatalf("unexpected status = %d, body = %s", rec.Code, rec.Body.String()) + } +} + +// TestGroupDirectChannelInReady — групповая беседа приходит в READY снапшоте +// с именем, составом и владельцем (AGENT.md 7.8, 8.3). +func TestGroupDirectChannelInReady(t *testing.T) { + srv, _ := newTestServer(t) + aliceCookie := registerAndLogin(t, srv, "alice_ready", "alice-ready@example.com") + registerAndLogin(t, srv, "bob_ready", "bob-ready@example.com") + registerAndLogin(t, srv, "carol_ready", "carol-ready@example.com") + alice, err := srv.auth.UserByEmail(t.Context(), "alice-ready@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + bob, err := srv.auth.UserByEmail(t.Context(), "bob-ready@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + carol, err := srv.auth.UserByEmail(t.Context(), "carol-ready@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + + rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group", + `{"name":"Готовая группа","recipient_ids":["`+formatSnowflake(bob.ID)+`","`+formatSnowflake(carol.ID)+`"]}`, + aliceCookie) + if rec.Code != http.StatusOK { + t.Fatalf("create group = %d, body = %s", rec.Code, rec.Body.String()) + } + + // READY собирает тот же калькулятор прав, что и Gateway (AGENT.md 8.3). + builder := gateway.NewSnapshot(srv.store, srv.perms) + snapshot, err := builder.Build(t.Context(), alice) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + payload, err := json.Marshal(snapshot) + if err != nil { + t.Fatalf("marshal snapshot: %v", err) + } + var decoded struct { + DMChannels []struct { + ID string `json:"id"` + IsGroup bool `json:"is_group"` + Name string `json:"name"` + MemberCount int `json:"member_count"` + MemberIDs []string `json:"member_ids"` + OwnerID string `json:"owner_id"` + } `json:"dm_channels"` + } + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode snapshot: %v", err) + } + found := false + for _, channel := range decoded.DMChannels { + if channel.IsGroup && channel.Name == "Готовая группа" { + found = true + if channel.MemberCount != 3 || len(channel.MemberIDs) != 3 { + t.Fatalf("group members in READY = %+v", channel) + } + if channel.OwnerID != formatSnowflake(alice.ID) { + t.Fatalf("group owner in READY = %q", channel.OwnerID) + } + } + } + if !found { + t.Fatalf("group channel is missing in READY: %s", payload) + } +} diff --git a/internal/store/channels.go b/internal/store/channels.go index bf9c15c..b2bc7ed 100644 --- a/internal/store/channels.go +++ b/internal/store/channels.go @@ -31,7 +31,10 @@ type Channel struct { BackgroundFileID *uint64 VoiceStatus string UserLimit int - CreatedAt time.Time + // DMOwnerID — владелец групповой личной беседы (AGENT.md 7.8): только он + // добавляет и удаляет участников и переименовывает беседу. У 1:1 — NULL. + DMOwnerID *uint64 + CreatedAt time.Time } type ChannelOverride struct { @@ -56,7 +59,7 @@ type CreateChannelParams struct { } const channelColumns = `id, guild_id, type, name, description, position, parent_id, - slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, created_at` + slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, dm_owner_id, created_at` func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) { if params.ID == 0 { @@ -269,11 +272,12 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) { nsfw int background sql.NullString backgroundID sql.NullInt64 + dmOwnerID sql.NullInt64 createdAt string ) err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description, &channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background, - &backgroundID, &channel.VoiceStatus, &channel.UserLimit, &createdAt) + &backgroundID, &channel.VoiceStatus, &channel.UserLimit, &dmOwnerID, &createdAt) if err != nil { return nil, mapError(err) } @@ -284,6 +288,7 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) { channel.Background = background.String } channel.BackgroundFileID = optionalID(backgroundID) + channel.DMOwnerID = optionalID(dmOwnerID) channel.CreatedAt = parseTimestamp(createdAt) return &channel, nil } diff --git a/internal/store/dm_groups.go b/internal/store/dm_groups.go new file mode 100644 index 0000000..055ea66 --- /dev/null +++ b/internal/store/dm_groups.go @@ -0,0 +1,184 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// Групповые личные беседы (AGENT.md 7.8) живут в тех же таблицах, что и 1:1: +// channels.type = 'dm' + dm_participants. Отличие только в числе участников и +// владельце (channels.dm_owner_id): он добавляет/удаляет участников и +// переименовывает беседу. Беседа с двумя участниками снова считается обычной +// личной, с одним — удаляется (см. RemoveDMParticipant). + +// CreateGroupDMChannel создаёт групповую беседу: владелец плюс приглашённые. +func (s *Store) CreateGroupDMChannel(ctx context.Context, ownerID uint64, name string, participantIDs []uint64) (*Channel, error) { + channelID := s.NextID() + members := make([]uint64, 0, len(participantIDs)+1) + members = append(members, ownerID) + for _, participantID := range participantIDs { + if participantID != ownerID { + members = append(members, participantID) + } + } + if err := s.InTx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO channels (id, guild_id, type, name, position, dm_owner_id, created_at) + VALUES (?, NULL, 'dm', ?, 0, ?, ?)`, + int64(channelID), name, int64(ownerID), s.Now()); err != nil { + return err + } + for _, userID := range members { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO dm_participants (channel_id, user_id, joined_at) VALUES (?, ?, ?)`, + int64(channelID), int64(userID), s.Now()); err != nil { + return err + } + } + return nil + }); err != nil { + return nil, mapError(err) + } + return s.GetChannel(ctx, channelID) +} + +// AddDMParticipant добавляет участника в беседу (повторный вызов — не ошибка). +func (s *Store) AddDMParticipant(ctx context.Context, channelID, userID uint64) error { + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO dm_participants (channel_id, user_id, joined_at) VALUES (?, ?, ?) + ON CONFLICT (channel_id, user_id) DO NOTHING`, + int64(channelID), int64(userID), s.Now()) + return err +} + +// RemoveDMParticipant убирает участника. Когда участников остаётся двое, +// беседа снова обычная личная (имя и владелец сбрасываются), а когда один — +// канал удаляется вместе с сообщениями (FK ON DELETE CASCADE) и файлами +// вложений: переписка с самим собой смысла не имеет. +func (s *Store) RemoveDMParticipant(ctx context.Context, channelID, userID uint64) error { + return s.InTx(ctx, func(tx *sql.Tx) error { + result, err := tx.ExecContext(ctx, + `DELETE FROM dm_participants WHERE channel_id = ? AND user_id = ?`, + int64(channelID), int64(userID)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + var remaining int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM dm_participants WHERE channel_id = ?`, int64(channelID)).Scan(&remaining); err != nil { + return err + } + switch { + case remaining >= 3: + // Владелец ушёл — передаём беседу самому «старому» участнику. + var ownerID sql.NullInt64 + if err := tx.QueryRowContext(ctx, + `SELECT dm_owner_id FROM channels WHERE id = ?`, int64(channelID)).Scan(&ownerID); err != nil { + return err + } + if ownerID.Valid && uint64(ownerID.Int64) == userID { + var nextOwner uint64 + if err := tx.QueryRowContext(ctx, ` + SELECT user_id FROM dm_participants WHERE channel_id = ? ORDER BY joined_at, user_id LIMIT 1`, + int64(channelID)).Scan(&nextOwner); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `UPDATE channels SET dm_owner_id = ? WHERE id = ?`, int64(nextOwner), int64(channelID)); err != nil { + return err + } + } + case remaining == 2: + if _, err := tx.ExecContext(ctx, + `UPDATE channels SET name = '', dm_owner_id = NULL WHERE id = ?`, int64(channelID)); err != nil { + return err + } + default: + if _, err := tx.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, int64(channelID)); err != nil { + return err + } + } + return nil + }) +} + +// RenameDMChannel задаёт имя групповой беседы. +func (s *Store) RenameDMChannel(ctx context.Context, channelID uint64, name string) error { + _, err := s.writer.ExecContext(ctx, + `UPDATE channels SET name = ? WHERE id = ? AND type = 'dm'`, name, int64(channelID)) + return err +} + +// DMParticipantCount считает участников беседы. +func (s *Store) DMParticipantCount(ctx context.Context, channelID uint64) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM dm_participants WHERE channel_id = ?`, int64(channelID)).Scan(&count) + return count, err +} + +// DMParticipant — участник беседы для интерфейса (список участников группы). +type DMParticipant struct { + UserID uint64 + Username string + DisplayName string + AvatarFileID *uint64 + Status string + LastSeenAt *time.Time + IsOwner bool + IsCurrentUser bool +} + +// DMParticipantProfiles возвращает участников беседы с профилями. +func (s *Store) DMParticipantProfiles(ctx context.Context, channelID, currentUserID uint64) ([]DMParticipant, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT u.id, u.username, u.display_name, u.avatar_file_id, u.status, u.last_seen_at, + c.dm_owner_id = u.id + FROM dm_participants p + JOIN users u ON u.id = p.user_id + JOIN channels c ON c.id = p.channel_id + WHERE p.channel_id = ? AND u.deleted_at IS NULL + ORDER BY p.joined_at, u.id`, int64(channelID)) + if err != nil { + return nil, err + } + defer rows.Close() + + participants := make([]DMParticipant, 0, 4) + for rows.Next() { + var ( + participant DMParticipant + avatarID sql.NullInt64 + lastSeenAt sql.NullString + isOwner int + ) + if err := rows.Scan(&participant.UserID, &participant.Username, &participant.DisplayName, + &avatarID, &participant.Status, &lastSeenAt, &isOwner); err != nil { + return nil, err + } + participant.AvatarFileID = optionalID(avatarID) + if lastSeenAt.Valid { + value := parseTimestamp(lastSeenAt.String) + participant.LastSeenAt = &value + } + participant.IsOwner = isOwner == 1 + participant.IsCurrentUser = participant.UserID == currentUserID + participants = append(participants, participant) + } + return participants, rows.Err() +} + +// GroupDMOwnerID возвращает владельца групповой беседы (nil — обычная 1:1). +func (s *Store) GroupDMOwnerID(ctx context.Context, channelID uint64) (*uint64, error) { + var ownerID sql.NullInt64 + err := s.reader.QueryRowContext(ctx, + `SELECT dm_owner_id FROM channels WHERE id = ? AND type = 'dm'`, int64(channelID)).Scan(&ownerID) + if err != nil { + return nil, mapError(err) + } + return optionalID(ownerID), nil +} diff --git a/internal/store/social.go b/internal/store/social.go index 0628a49..3f4d3c3 100644 --- a/internal/store/social.go +++ b/internal/store/social.go @@ -186,6 +186,8 @@ func (s *Store) FindDMChannel(ctx context.Context, firstID, secondID uint64) (*C } // DMChannelSummary — личная беседа со собеседником и последним сообщением. +// Для групповой беседы (AGENT.md 7.8) вместо собеседника заполняются Name, +// MemberCount и Participants, а RecipientID остаётся нулевым. type DMChannelSummary struct { Channel Channel RecipientID uint64 @@ -198,10 +200,31 @@ type DMChannelSummary struct { LastMessageID uint64 LastMessageAt *time.Time LastAuthorID *uint64 + // IsGroup — участников больше двух; GroupName — имя беседы. + IsGroup bool + GroupName string + MemberCount int + Participants []DMParticipant + OwnerID *uint64 } -// ListDMChannels возвращает личные беседы пользователя. +// ListDMChannels возвращает личные беседы пользователя: и 1:1, и групповые +// (AGENT.md 7.8). Беседа считается групповой, если в ней больше двух +// участников; с двумя — обычная личная, с одним — не показывается вовсе. func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) { + channels, err := s.listDirectChannels(ctx, userID) + if err != nil { + return nil, err + } + groups, err := s.listGroupChannels(ctx, userID) + if err != nil { + return nil, err + } + return append(channels, groups...), nil +} + +// listDirectChannels возвращает беседы ровно с двумя участниками. +func (s *Store) listDirectChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) { rows, err := s.reader.QueryContext(ctx, ` SELECT c.id, c.created_at, u.id, u.display_name, u.username, u.avatar_file_id, u.status, u.last_seen_at, u.timezone, @@ -213,6 +236,7 @@ func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelS JOIN dm_participants other ON other.channel_id = c.id AND other.user_id <> p.user_id JOIN users u ON u.id = other.user_id AND u.deleted_at IS NULL WHERE p.user_id = ? + AND (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id) = 2 ORDER BY c.id DESC`, int64(userID)) if err != nil { return nil, err @@ -255,6 +279,69 @@ func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelS return channels, rows.Err() } +// listGroupChannels возвращает групповые беседы: имя, число участников и +// последнее сообщение. Участники подтягиваются отдельным запросом — групп у +// пользователя немного, а список нужен интерфейсу для карточки беседы. +func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT c.id, c.name, c.dm_owner_id, c.created_at, + (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id), + (SELECT m.id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1), + (SELECT m.created_at FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1), + (SELECT m.author_id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1) + FROM dm_participants p + JOIN channels c ON c.id = p.channel_id AND c.type = 'dm' + WHERE p.user_id = ? + AND (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id) > 2 + ORDER BY c.id DESC`, int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + groups := make([]DMChannelSummary, 0, 4) + for rows.Next() { + var ( + summary DMChannelSummary + ownerID sql.NullInt64 + createdAt string + lastMessageID sql.NullInt64 + lastMessageAt sql.NullString + lastAuthorID sql.NullInt64 + ) + if err := rows.Scan(&summary.Channel.ID, &summary.GroupName, &ownerID, &createdAt, + &summary.MemberCount, &lastMessageID, &lastMessageAt, &lastAuthorID); err != nil { + return nil, err + } + summary.IsGroup = true + summary.OwnerID = optionalID(ownerID) + summary.Channel.Type = ChannelDM + summary.Channel.Name = summary.GroupName + summary.Channel.DMOwnerID = summary.OwnerID + summary.Channel.CreatedAt = parseTimestamp(createdAt) + if lastMessageID.Valid { + summary.LastMessageID = uint64(lastMessageID.Int64) + } + if lastMessageAt.Valid { + value := parseTimestamp(lastMessageAt.String) + summary.LastMessageAt = &value + } + summary.LastAuthorID = optionalID(lastAuthorID) + groups = append(groups, summary) + } + if err := rows.Err(); err != nil { + return nil, err + } + for index := range groups { + participants, err := s.DMParticipantProfiles(ctx, groups[index].Channel.ID, userID) + if err != nil { + return nil, err + } + groups[index].Participants = participants + } + return groups, nil +} + // TouchLastSeen обновляет время последней активности пользователя. func (s *Store) TouchLastSeen(ctx context.Context, userID uint64) error { _, err := s.writer.ExecContext(ctx, diff --git a/web/src/api/friends.ts b/web/src/api/friends.ts index 308fdac..5ba8cfd 100644 --- a/web/src/api/friends.ts +++ b/web/src/api/friends.ts @@ -1,5 +1,5 @@ import { request } from './client'; -import type { MemberCosmetics } from './types'; +import type { DmRecipient, MemberCosmetics } from './types'; import { userStatuses, type Channel, type UserStatus } from './types'; /** @@ -273,9 +273,113 @@ export async function fetchDirectChannels(signal?: AbortSignal): Promise item !== null) - : []; + if (!Array.isArray(payload.channels)) { + return []; + } + return payload.channels + .map((item) => { + const record = asRecord(item); + const channel = parseDirectChannel(item); + // Групповая беседа: имя и состав приходят полями канала, а не recipient. + return record === null || channel === null ? null : withGroupFields(channel, record); + }) + .filter((item): item is Channel => item !== null); +} + +/** Разбор участника групповой беседы из ответа API. */ +function parseDmRecipient(value: unknown): DmRecipient | null { + const record = asRecord(value); + const userId = asString(record?.['user_id']); + if (record === null || userId === undefined) { + return null; + } + const recipient: DmRecipient = { + user_id: userId, + username: asString(record['username']) ?? '', + display_name: asString(record['display_name']) ?? '', + status: asStatus(record['status']) ?? 'offline', + visible_status: asStatus(record['visible_status']) ?? asStatus(record['status']) ?? 'offline', + is_owner: record['is_owner'] === true, + is_self: record['is_self'] === true, + }; + const avatar = asString(record['avatar_file_id']); + if (avatar !== undefined) { + recipient.avatar_file_id = avatar; + } + return recipient; +} + +/** Поля групповой беседы из ответа API (общие для создания и обновления). */ +function withGroupFields(channel: Channel, record: Record): Channel { + if (record['is_group'] !== true) { + return channel; + } + channel.is_group = true; + channel.name = asString(record['name']) ?? channel.name; + const memberCount = record['member_count']; + if (typeof memberCount === 'number') { + channel.member_count = memberCount; + } + const ownerId = asString(record['owner_id']); + if (ownerId !== undefined) { + channel.owner_id = ownerId; + } + if (Array.isArray(record['recipients'])) { + channel.recipients = record['recipients'] + .map(parseDmRecipient) + .filter((item): item is DmRecipient => item !== null); + } + return channel; +} + +/** `POST /users/@me/channels/group` — создать групповую беседу (7.8). */ +export async function createGroupDirectChannel( + name: string, + recipientIds: string[], +): Promise { + const payload = await request<{ channel?: unknown }>('/users/@me/channels/group', { + method: 'POST', + body: { name, recipient_ids: recipientIds }, + }); + const record = asRecord(payload.channel); + const channel = parseDirectChannel(payload.channel); + if (record === null || channel === null) { + throw new Error('malformed group channel payload'); + } + return withGroupFields(channel, record); +} + +/** `PUT /channels/{id}/recipients/{user_id}` — добавить участника в группу. */ +export async function addGroupRecipient(channelId: string, userId: string): Promise { + const payload = await request<{ channel?: unknown }>( + `/channels/${channelId}/recipients/${userId}`, + { method: 'PUT' }, + ); + const record = asRecord(payload.channel); + const channel = parseDirectChannel(payload.channel); + if (record === null || channel === null) { + throw new Error('malformed group channel payload'); + } + return withGroupFields(channel, record); +} + +/** `DELETE /channels/{id}/recipients/{user_id}` — выйти или удалить участника. */ +export function removeGroupRecipient(channelId: string, userId: string): Promise<{ ok: true }> { + return request<{ ok: true }>(`/channels/${channelId}/recipients/${userId}`, { method: 'DELETE' }); +} + +/** `PATCH /channels/{id}` — переименовать групповую беседу (владелец). */ +export async function renameGroupDirectChannel(channelId: string, name: string): Promise { + const payload = await request<{ channel?: unknown }>(`/channels/${channelId}`, { + method: 'PATCH', + body: { name }, + }); + const record = asRecord(payload.channel); + const channel = parseDirectChannel(payload.channel); + if (record === null || channel === null) { + throw new Error('malformed group channel payload'); + } + return withGroupFields(channel, record); } /** `POST /users/@me/channels` — открыть (или создать) беседу с пользователем. */ diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts index 295bab0..3f80dee 100644 --- a/web/src/api/gatewayEvents.ts +++ b/web/src/api/gatewayEvents.ts @@ -401,6 +401,23 @@ export function parseChannelPayload(value: unknown): Channel | null { if (lastMessageAt !== undefined) { channel.last_message_at = lastMessageAt; } + // Групповая личная беседа (AGENT.md 7.8, Фаза 7): имя, состав и владелец. + if (record['is_group'] === true) { + channel.is_group = true; + const memberCount = record['member_count']; + if (typeof memberCount === 'number') { + channel.member_count = memberCount; + } + const ownerId = asString(record['owner_id']); + if (ownerId !== undefined) { + channel.owner_id = ownerId; + } + if (Array.isArray(record['member_ids'])) { + channel.member_ids = record['member_ids'].filter( + (item): item is string => typeof item === 'string', + ); + } + } return channel; } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d26830e..d704b80 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -111,6 +111,27 @@ export interface Channel { status?: UserStatus; last_message_id?: string; last_message_at?: string; + /** + * Групповая личная беседа (AGENT.md 7.8, Фаза 7): участников больше двух. + * У 1:1 `is_group` не задан, а собеседник лежит в `recipient_id`. + */ + is_group?: boolean; + member_count?: number; + member_ids?: string[]; + owner_id?: string; + recipients?: DmRecipient[]; +} + +/** Участник групповой беседы. */ +export interface DmRecipient { + user_id: string; + username: string; + display_name: string; + avatar_file_id?: string; + status: UserStatus; + visible_status: UserStatus; + is_owner: boolean; + is_self: boolean; } export interface Guild { diff --git a/web/src/components/friends/DirectMessageView.tsx b/web/src/components/friends/DirectMessageView.tsx index ffbd83f..a9cc835 100644 --- a/web/src/components/friends/DirectMessageView.tsx +++ b/web/src/components/friends/DirectMessageView.tsx @@ -71,10 +71,20 @@ export function DirectMessageView({ channelId }: { channelId: string }) { const name = channel.name === '' ? t('friends.dm.unknown') : channel.name; const status = channel.status ?? 'offline'; + const isGroup = channel.is_group === true; + const memberCount = channel.member_count ?? channel.recipients?.length ?? 0; + const memberNames = (channel.recipients ?? []) + .map((recipient) => + recipient.display_name === '' ? recipient.username : recipient.display_name, + ) + .join(', '); return (
-
+
- {name} + {channel.is_group === true ? ( + + {channel.member_count ?? channel.recipients?.length ?? 0} + + ) : null} {selected ? null : mentions > 0 ? ( [...dmChannels].sort(byRecent), [dmChannels]); const friendsSorted = useMemo(() => [...friends].sort(byPresence), [friends]); @@ -133,9 +148,22 @@ export function FriendsSidebar({ channelId }: { channelId: string | null }) {
-

- {t('friends.dmSection')} -

+
+

+ {t('friends.dmSection')} +

+ +
+ {groupOpen ? setGroupOpen(false)} /> : null} {direct.length === 0 ? (

{t('friends.dmEmpty')} diff --git a/web/tests/groupDm.test.tsx b/web/tests/groupDm.test.tsx new file mode 100644 index 0000000..46489c9 --- /dev/null +++ b/web/tests/groupDm.test.tsx @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { Relationships } from '@/api/friends'; +import { + findRequest, + installFetch, + installGatewaySocket, + json, + makeReadySnapshot, + makeUser, + renderApp, + type FetchRoute, +} from './helpers'; + +/** + * Групповые личные беседы (AGENT.md 7.8, Фаза 7): отображение в сайдбаре, + * создание из списка друзей и шапка беседы с числом участников. + */ + +const user = makeUser(); + +const instance = { + instance: { + name: 'glchat-test', + registration_enabled: true, + allow_guild_creation: true, + max_guilds_per_user: 5, + main_guild_id: 'g-main', + }, +}; + +const friends: Relationships = { + friends: [ + { + user_id: 'user-2', + username: 'bob', + display_name: 'Боб', + status: 'online', + visible_status: 'online', + is_instance_admin: false, + badges: [], + timezone: 'Europe/Moscow', + relationship: 'friend', + }, + { + user_id: 'user-3', + username: 'carol', + display_name: 'Кэрол', + status: 'offline', + visible_status: 'offline', + is_instance_admin: false, + badges: [], + timezone: 'Europe/Moscow', + relationship: 'friend', + }, + ], + incoming: [], + outgoing: [], + blocked: [], +}; + +/** Групповая беседа в REST-форме (`GET /users/@me/channels`). */ +function groupChannel(id = 'dm-group-1', memberCount = 3) { + return { + id, + type: 'dm', + is_group: true, + name: 'Игровая', + member_count: memberCount, + owner_id: user.id, + can_send: true, + can_view: true, + recipients: [ + { + user_id: user.id, + username: 'alice', + display_name: 'Alice', + status: 'online', + visible_status: 'online', + is_owner: true, + is_self: true, + }, + { + user_id: 'user-2', + username: 'bob', + display_name: 'Боб', + status: 'online', + visible_status: 'online', + is_owner: false, + is_self: false, + }, + { + user_id: 'user-3', + username: 'carol', + display_name: 'Кэрол', + status: 'offline', + visible_status: 'offline', + is_owner: false, + is_self: false, + }, + ], + }; +} + +function baseRoutes(channels: unknown[], extra: FetchRoute[] = []): FetchRoute[] { + return [ + ...extra, + { match: '/api/v1/users/@me/relationships', response: () => json(friends) }, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + { match: '/api/v1/users/@me/channels', response: () => json({ channels }) }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json(instance) }, + ]; +} + +describe('групповые беседы', () => { + it('группа показана в сайдбаре с числом участников и без точки статуса', async () => { + const channel = groupChannel(); + installFetch( + baseRoutes( + [channel], + [ + { + match: `/api/v1/channels/${channel.id}/messages`, + response: () => json({ messages: [] }), + }, + { match: `/api/v1/channels/${channel.id}/pins`, response: () => json({ messages: [] }) }, + { + match: `/api/v1/channels/${channel.id}/ack`, + method: 'POST', + response: () => json({ ok: true }), + }, + ], + ), + ); + const gateway = installGatewaySocket(makeReadySnapshot()); + + // Сайдбар раздела «Друзья» живёт в оболочке приложения: открываем беседу, + // чтобы он отрисовался (как в реальном переходе). + renderApp(`/app/friends/${channel.id}`); + await gateway.greet(); + + const link = await screen.findByTestId('dm-link-dm-group-1'); + expect(link).toHaveTextContent('Игровая'); + expect(screen.getByTestId('dm-members-dm-group-1')).toHaveTextContent('3'); + // У группы нет статуса собеседника: точки рядом с аватаром быть не должно. + expect(screen.queryByTestId('dm-status-dm-group-1')).toBeNull(); + }); + + it('шапка групповой беседы показывает число участников', async () => { + const channel = groupChannel(); + installFetch( + baseRoutes( + [channel], + [ + { + match: `/api/v1/channels/${channel.id}/messages`, + response: () => json({ messages: [] }), + }, + { match: `/api/v1/channels/${channel.id}/pins`, response: () => json({ messages: [] }) }, + { + match: `/api/v1/channels/${channel.id}/ack`, + method: 'POST', + response: () => json({ ok: true }), + }, + ], + ), + ); + const gateway = installGatewaySocket(makeReadySnapshot()); + + renderApp(`/app/friends/${channel.id}`); + await gateway.greet(); + + expect(await screen.findByTestId('dm-group-header')).toBeVisible(); + expect(screen.getByTestId('dm-member-count')).toHaveTextContent('Участников: 3'); + }); + + it('создаёт группу из двух друзей и открывает её', async () => { + const created = groupChannel('dm-group-2'); + const fetchMock = installFetch( + baseRoutes( + [], + [ + { + match: '/api/v1/users/@me/channels/group', + method: 'POST', + response: () => json({ channel: created }), + }, + ], + ), + ); + const gateway = installGatewaySocket(makeReadySnapshot()); + const visitor = userEvent.setup(); + + const { router } = renderApp('/app/friends'); + await gateway.greet(); + await screen.findByTestId('dm-sidebar-empty'); + + await visitor.click(await screen.findByTestId('group-dm-open')); + await visitor.type(screen.getByLabelText('Название группы'), 'Игровая'); + await visitor.click(screen.getByRole('checkbox', { name: 'Боб' })); + await visitor.click(screen.getByRole('checkbox', { name: 'Кэрол' })); + + const submit = screen.getByRole('button', { name: 'Создать' }); + // С одним участником группа не создаётся: кнопка включается со второго. + expect(submit).toBeEnabled(); + await visitor.click(submit); + + await waitFor(() => { + expect(findRequest(fetchMock, { url: '/channels/group', method: 'POST' })).toBeDefined(); + }); + const request = findRequest(fetchMock, { url: '/channels/group', method: 'POST' }); + expect(request?.body).toEqual({ + name: 'Игровая', + recipient_ids: ['user-2', 'user-3'], + }); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app/friends/dm-group-2'); + }); + // Беседа сразу попала в список — отдельный запрос не нужен. + expect(await screen.findByTestId('dm-link-dm-group-2')).toBeVisible(); + }); + + it('без двух выбранных друзей кнопка создания выключена', async () => { + installFetch(baseRoutes([])); + const gateway = installGatewaySocket(makeReadySnapshot()); + const visitor = userEvent.setup(); + + renderApp('/app/friends'); + await gateway.greet(); + await visitor.click(await screen.findByTestId('group-dm-open')); + await visitor.click(screen.getByRole('checkbox', { name: 'Боб' })); + + expect(screen.getByRole('button', { name: 'Создать' })).toBeDisabled(); + }); +});