feat(dm): групповые личные беседы (AGENT.md 7.8, Фаза 7)

Беседа на несколько участников живёт в тех же таблицах, что и 1:1
(channels.type = 'dm' + dm_participants), добавляется только владелец
(channels.dm_owner_id, миграция 00020).

Сервер: POST /users/@me/channels/group (2–9 приглашённых, имя необязательно —
собирается из имён), PUT/DELETE /channels/{id}/recipients/{user_id}
(добавляет любой участник, удаляет других и переименовывает только владелец,
выйти может каждый сам), PATCH /channels/{id}. Правила состава: группа — это
3+ участника; при двух беседа снова обычная личная (имя и владелец
сбрасываются), при одном — удаляется вместе с перепиской. Заблокированного
нельзя ни пригласить, ни добавить; посторонним беседа не видна (404).
READY и REST отдают is_group, name, member_count, owner_id и состав;
участники получают DM_CHANNEL_CREATE/DM_CHANNEL_UPDATE, удалённый —
DM_CHANNEL_DELETE.

Клиент: группа в сайдбаре с числом участников (без точки чужого статуса),
шапка беседы с числом участников и именами в подсказке, создание группы из
списка друзей (минимум двое) с переходом в новую беседу.

Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
This commit is contained in:
2026-09-26 15:40:17 +03:00
parent 50265c66b8
commit f9464130cd
17 changed files with 1622 additions and 23 deletions
@@ -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;
+6
View File
@@ -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 {
+13
View File
@@ -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)
}
+408
View File
@@ -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
+336
View File
@@ -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)
}
}
+8 -3
View File
@@ -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
}
+184
View File
@@ -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
}
+88 -1
View File
@@ -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,