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
+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