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:
@@ -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;
|
||||||
@@ -144,6 +144,12 @@ type ReadyChannel struct {
|
|||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
LastMessageID string `json:"last_message_id,omitempty"`
|
LastMessageID string `json:"last_message_id,omitempty"`
|
||||||
LastMessageAt string `json:"last_message_at,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 {
|
type ReadyRole struct {
|
||||||
|
|||||||
@@ -92,6 +92,19 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
|
|||||||
RecipientID: formatID(summary.RecipientID),
|
RecipientID: formatID(summary.RecipientID),
|
||||||
Status: visibleStatus(summary.Status, summary.LastSeenAt),
|
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 {
|
if summary.AvatarFileID != nil {
|
||||||
channel.IconFileID = formatID(*summary.AvatarFileID)
|
channel.IconFileID = formatID(*summary.AvatarFileID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -66,6 +68,25 @@ type dmChannelPayload struct {
|
|||||||
} `json:"recipient"`
|
} `json:"recipient"`
|
||||||
LastMessageID string `json:"last_message_id,omitempty"`
|
LastMessageID string `json:"last_message_id,omitempty"`
|
||||||
LastMessageAt string `json:"last_message_at,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 {
|
type dmChannelListOutput struct {
|
||||||
@@ -407,6 +428,362 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
}
|
}
|
||||||
return output, nil
|
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 или логину.
|
// relationshipTarget находит пользователя по id или логину.
|
||||||
@@ -553,6 +930,37 @@ func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPaylo
|
|||||||
CanSend: true,
|
CanSend: true,
|
||||||
CanView: 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.UserID = formatSnowflake(summary.RecipientID)
|
||||||
payload.Recipient.Username = summary.RecipientLogin
|
payload.Recipient.Username = summary.RecipientLogin
|
||||||
payload.Recipient.DisplayName = summary.RecipientName
|
payload.Recipient.DisplayName = summary.RecipientName
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ type Channel struct {
|
|||||||
BackgroundFileID *uint64
|
BackgroundFileID *uint64
|
||||||
VoiceStatus string
|
VoiceStatus string
|
||||||
UserLimit int
|
UserLimit int
|
||||||
|
// DMOwnerID — владелец групповой личной беседы (AGENT.md 7.8): только он
|
||||||
|
// добавляет и удаляет участников и переименовывает беседу. У 1:1 — NULL.
|
||||||
|
DMOwnerID *uint64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +59,7 @@ type CreateChannelParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelColumns = `id, guild_id, type, name, description, position, parent_id,
|
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) {
|
func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) {
|
||||||
if params.ID == 0 {
|
if params.ID == 0 {
|
||||||
@@ -269,11 +272,12 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
|||||||
nsfw int
|
nsfw int
|
||||||
background sql.NullString
|
background sql.NullString
|
||||||
backgroundID sql.NullInt64
|
backgroundID sql.NullInt64
|
||||||
|
dmOwnerID sql.NullInt64
|
||||||
createdAt string
|
createdAt string
|
||||||
)
|
)
|
||||||
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
|
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
|
||||||
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
|
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
|
||||||
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &createdAt)
|
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &dmOwnerID, &createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
}
|
}
|
||||||
@@ -284,6 +288,7 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
|||||||
channel.Background = background.String
|
channel.Background = background.String
|
||||||
}
|
}
|
||||||
channel.BackgroundFileID = optionalID(backgroundID)
|
channel.BackgroundFileID = optionalID(backgroundID)
|
||||||
|
channel.DMOwnerID = optionalID(dmOwnerID)
|
||||||
channel.CreatedAt = parseTimestamp(createdAt)
|
channel.CreatedAt = parseTimestamp(createdAt)
|
||||||
return &channel, nil
|
return &channel, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -186,6 +186,8 @@ func (s *Store) FindDMChannel(ctx context.Context, firstID, secondID uint64) (*C
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DMChannelSummary — личная беседа со собеседником и последним сообщением.
|
// DMChannelSummary — личная беседа со собеседником и последним сообщением.
|
||||||
|
// Для групповой беседы (AGENT.md 7.8) вместо собеседника заполняются Name,
|
||||||
|
// MemberCount и Participants, а RecipientID остаётся нулевым.
|
||||||
type DMChannelSummary struct {
|
type DMChannelSummary struct {
|
||||||
Channel Channel
|
Channel Channel
|
||||||
RecipientID uint64
|
RecipientID uint64
|
||||||
@@ -198,10 +200,31 @@ type DMChannelSummary struct {
|
|||||||
LastMessageID uint64
|
LastMessageID uint64
|
||||||
LastMessageAt *time.Time
|
LastMessageAt *time.Time
|
||||||
LastAuthorID *uint64
|
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) {
|
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, `
|
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,
|
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,
|
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 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
|
JOIN users u ON u.id = other.user_id AND u.deleted_at IS NULL
|
||||||
WHERE p.user_id = ?
|
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))
|
ORDER BY c.id DESC`, int64(userID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -255,6 +279,69 @@ func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelS
|
|||||||
return channels, rows.Err()
|
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 обновляет время последней активности пользователя.
|
// TouchLastSeen обновляет время последней активности пользователя.
|
||||||
func (s *Store) TouchLastSeen(ctx context.Context, userID uint64) error {
|
func (s *Store) TouchLastSeen(ctx context.Context, userID uint64) error {
|
||||||
_, err := s.writer.ExecContext(ctx,
|
_, err := s.writer.ExecContext(ctx,
|
||||||
|
|||||||
+108
-4
@@ -1,5 +1,5 @@
|
|||||||
import { request } from './client';
|
import { request } from './client';
|
||||||
import type { MemberCosmetics } from './types';
|
import type { DmRecipient, MemberCosmetics } from './types';
|
||||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -273,9 +273,113 @@ export async function fetchDirectChannels(signal?: AbortSignal): Promise<Channel
|
|||||||
'/users/@me/channels',
|
'/users/@me/channels',
|
||||||
signal === undefined ? {} : { signal },
|
signal === undefined ? {} : { signal },
|
||||||
);
|
);
|
||||||
return Array.isArray(payload.channels)
|
if (!Array.isArray(payload.channels)) {
|
||||||
? payload.channels.map(parseDirectChannel).filter((item): item is Channel => item !== null)
|
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<string, unknown>): 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<Channel> {
|
||||||
|
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<Channel> {
|
||||||
|
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<Channel> {
|
||||||
|
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` — открыть (или создать) беседу с пользователем. */
|
/** `POST /users/@me/channels` — открыть (или создать) беседу с пользователем. */
|
||||||
|
|||||||
@@ -401,6 +401,23 @@ export function parseChannelPayload(value: unknown): Channel | null {
|
|||||||
if (lastMessageAt !== undefined) {
|
if (lastMessageAt !== undefined) {
|
||||||
channel.last_message_at = lastMessageAt;
|
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;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,27 @@ export interface Channel {
|
|||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
last_message_id?: string;
|
last_message_id?: string;
|
||||||
last_message_at?: 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 {
|
export interface Guild {
|
||||||
|
|||||||
@@ -71,10 +71,20 @@ export function DirectMessageView({ channelId }: { channelId: string }) {
|
|||||||
|
|
||||||
const name = channel.name === '' ? t('friends.dm.unknown') : channel.name;
|
const name = channel.name === '' ? t('friends.dm.unknown') : channel.name;
|
||||||
const status = channel.status ?? 'offline';
|
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 (
|
return (
|
||||||
<section id="app-content" className="absolute inset-0 flex flex-col">
|
<section id="app-content" className="absolute inset-0 flex flex-col">
|
||||||
<header className="flex items-center gap-2 border-b border-border/40 px-4 py-2">
|
<header
|
||||||
|
className="flex items-center gap-2 border-b border-border/40 px-4 py-2"
|
||||||
|
data-testid={isGroup ? 'dm-group-header' : 'dm-header'}
|
||||||
|
>
|
||||||
<span className="relative shrink-0">
|
<span className="relative shrink-0">
|
||||||
<Avatar
|
<Avatar
|
||||||
name={name}
|
name={name}
|
||||||
@@ -82,17 +92,28 @@ export function DirectMessageView({ channelId }: { channelId: string }) {
|
|||||||
fileId={channel.icon_file_id}
|
fileId={channel.icon_file_id}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
{isGroup ? null : (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-testid="dm-status"
|
data-testid="dm-status"
|
||||||
data-status={status}
|
data-status={status}
|
||||||
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface-1 ${presenceDotClass[status]}`}
|
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface-1 ${presenceDotClass[status]}`}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<h2 className="truncate text-sm font-semibold" title={name}>
|
<h2
|
||||||
|
className="truncate text-sm font-semibold"
|
||||||
|
title={memberNames === '' ? name : memberNames}
|
||||||
|
>
|
||||||
{name}
|
{name}
|
||||||
</h2>
|
</h2>
|
||||||
|
{isGroup ? (
|
||||||
|
<span className="shrink-0 text-xs text-fg-muted" data-testid="dm-member-count">
|
||||||
|
{t('friends.group.members', { count: memberCount })}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<span className="sr-only">{t(`user.status.${status}`)}</span>
|
<span className="sr-only">{t(`user.status.${status}`)}</span>
|
||||||
|
)}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { createGroupDirectChannel } from '@/api/friends';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Field } from '@/components/ui/Field';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { useFriendsStore } from '@/stores/friends';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создание групповой беседы (AGENT.md 7.8, Фаза 7): выбрать друзей, задать
|
||||||
|
* имя (необязательно — сервер соберёт его из имён участников) и открыть
|
||||||
|
* беседу. Группа — это минимум три человека, поэтому меньше двух друзей
|
||||||
|
* выбрать нельзя.
|
||||||
|
*/
|
||||||
|
export function GroupDmDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const friends = useFriendsStore((state) => state.friends);
|
||||||
|
const upsertDmChannel = useSessionStore((state) => state.upsertDmChannel);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () => createGroupDirectChannel(name.trim(), selected),
|
||||||
|
onSuccess: (channel) => {
|
||||||
|
upsertDmChannel(channel);
|
||||||
|
onClose();
|
||||||
|
void navigate(`/app/friends/${channel.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = (userId: string): void => {
|
||||||
|
setSelected((current) =>
|
||||||
|
current.includes(userId) ? current.filter((id) => id !== userId) : [...current, userId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-2 flex flex-col gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-2"
|
||||||
|
data-testid="group-dm-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (selected.length >= 2) {
|
||||||
|
create.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label={t('friends.group.name')}
|
||||||
|
name="group_name"
|
||||||
|
maxLength={64}
|
||||||
|
placeholder={t('friends.group.namePlaceholder')}
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<fieldset className="flex flex-col gap-1">
|
||||||
|
<legend className="text-xs text-fg-muted">{t('friends.group.membersHint')}</legend>
|
||||||
|
{friends.length === 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t('friends.friendsEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
className="flex max-h-40 flex-col gap-0.5 overflow-y-auto"
|
||||||
|
data-testid="group-friends"
|
||||||
|
>
|
||||||
|
{friends.map((friend) => {
|
||||||
|
const label = friend.display_name === '' ? friend.username : friend.display_name;
|
||||||
|
return (
|
||||||
|
<li key={friend.user_id}>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 rounded-[var(--radius-sm)] px-1 py-0.5 text-sm hover:bg-surface-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(friend.user_id)}
|
||||||
|
onChange={() => toggle(friend.user_id)}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
{create.isError ? <ErrorNotice error={create.error} /> : null}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={selected.length < 2 || create.isPending}>
|
||||||
|
{t(create.isPending ? 'common.saving' : 'friends.group.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -423,6 +423,15 @@
|
|||||||
"sidebarHint": "Direct messages live here — they are not in the server rail.",
|
"sidebarHint": "Direct messages live here — they are not in the server rail.",
|
||||||
"dmSection": "Direct messages",
|
"dmSection": "Direct messages",
|
||||||
"dmEmpty": "No direct messages yet.",
|
"dmEmpty": "No direct messages yet.",
|
||||||
|
"group": {
|
||||||
|
"title": "Create a group conversation",
|
||||||
|
"name": "Group name",
|
||||||
|
"namePlaceholder": "For example “Game night”",
|
||||||
|
"membersHint": "Pick at least two friends:",
|
||||||
|
"create": "Create",
|
||||||
|
"members": "Members: {{count}}",
|
||||||
|
"open": "Open the group “{{name}}”, members: {{count}}"
|
||||||
|
},
|
||||||
"friendsSection": "Friends",
|
"friendsSection": "Friends",
|
||||||
"friendsEmpty": "Your friend list is empty.",
|
"friendsEmpty": "Your friend list is empty.",
|
||||||
"open": "Open a conversation with {{name}}",
|
"open": "Open a conversation with {{name}}",
|
||||||
|
|||||||
@@ -423,6 +423,15 @@
|
|||||||
"sidebarHint": "Личные беседы живут здесь — в рейке серверов их нет.",
|
"sidebarHint": "Личные беседы живут здесь — в рейке серверов их нет.",
|
||||||
"dmSection": "Личные беседы",
|
"dmSection": "Личные беседы",
|
||||||
"dmEmpty": "Личных бесед пока нет.",
|
"dmEmpty": "Личных бесед пока нет.",
|
||||||
|
"group": {
|
||||||
|
"title": "Создать групповую беседу",
|
||||||
|
"name": "Название группы",
|
||||||
|
"namePlaceholder": "Например, «Игровая»",
|
||||||
|
"membersHint": "Выберите минимум двух друзей:",
|
||||||
|
"create": "Создать",
|
||||||
|
"members": "Участников: {{count}}",
|
||||||
|
"open": "Открыть группу «{{name}}», участников: {{count}}"
|
||||||
|
},
|
||||||
"friendsSection": "Друзья",
|
"friendsSection": "Друзья",
|
||||||
"friendsEmpty": "Список друзей пуст.",
|
"friendsEmpty": "Список друзей пуст.",
|
||||||
"open": "Открыть беседу с {{name}}",
|
"open": "Открыть беседу с {{name}}",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
|
|
||||||
import type { Channel, UserStatus } from '@/api/types';
|
import type { Channel, UserStatus } from '@/api/types';
|
||||||
|
import { GroupDmDialog } from '@/components/friends/GroupDmDialog';
|
||||||
import { useDmUnread } from '@/components/friends/useDmUnread';
|
import { useDmUnread } from '@/components/friends/useDmUnread';
|
||||||
import { useOpenDirectChannel } from '@/components/friends/useOpenDirectChannel';
|
import { useOpenDirectChannel } from '@/components/friends/useOpenDirectChannel';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
@@ -32,11 +33,14 @@ function DirectChannelLink({ channel, selected }: { channel: Channel; selected:
|
|||||||
const { unread, mentions, exact } = useDmUnread(channel);
|
const { unread, mentions, exact } = useDmUnread(channel);
|
||||||
const highlighted = !selected && (unread > 0 || mentions > 0);
|
const highlighted = !selected && (unread > 0 || mentions > 0);
|
||||||
const name = channel.name === '' ? t('friends.dm.unknown') : channel.name;
|
const name = channel.name === '' ? t('friends.dm.unknown') : channel.name;
|
||||||
|
const memberCount = channel.member_count ?? channel.recipients?.length ?? 0;
|
||||||
const label =
|
const label =
|
||||||
mentions > 0
|
mentions > 0
|
||||||
? t('channels.selectMentions', { name, count: mentions })
|
? t('channels.selectMentions', { name, count: mentions })
|
||||||
: unread > 0
|
: unread > 0
|
||||||
? t('channels.selectUnread', { name, count: unread })
|
? t('channels.selectUnread', { name, count: unread })
|
||||||
|
: channel.is_group === true
|
||||||
|
? t('friends.group.open', { name, count: memberCount })
|
||||||
: t('friends.open', { name });
|
: t('friends.open', { name });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,9 +63,19 @@ function DirectChannelLink({ channel, selected }: { channel: Channel; selected:
|
|||||||
fileId={channel.icon_file_id}
|
fileId={channel.icon_file_id}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
{channel.is_group === true ? null : (
|
||||||
<StatusDot status={channel.status ?? 'offline'} testId={`dm-status-${channel.id}`} />
|
<StatusDot status={channel.status ?? 'offline'} testId={`dm-status-${channel.id}`} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{name}</span>
|
<span className="truncate">{name}</span>
|
||||||
|
{channel.is_group === true ? (
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-[10px] text-fg-muted"
|
||||||
|
data-testid={`dm-members-${channel.id}`}
|
||||||
|
>
|
||||||
|
{channel.member_count ?? channel.recipients?.length ?? 0}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{selected ? null : mentions > 0 ? (
|
{selected ? null : mentions > 0 ? (
|
||||||
<span
|
<span
|
||||||
data-testid={`dm-mentions-${channel.id}`}
|
data-testid={`dm-mentions-${channel.id}`}
|
||||||
@@ -119,6 +133,7 @@ export function FriendsSidebar({ channelId }: { channelId: string | null }) {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
const [groupOpen, setGroupOpen] = useState(false);
|
||||||
const direct = useMemo(() => [...dmChannels].sort(byRecent), [dmChannels]);
|
const direct = useMemo(() => [...dmChannels].sort(byRecent), [dmChannels]);
|
||||||
const friendsSorted = useMemo(() => [...friends].sort(byPresence), [friends]);
|
const friendsSorted = useMemo(() => [...friends].sort(byPresence), [friends]);
|
||||||
|
|
||||||
@@ -133,9 +148,22 @@ export function FriendsSidebar({ channelId }: { channelId: string | null }) {
|
|||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
||||||
<section aria-label={t('friends.dmSection')}>
|
<section aria-label={t('friends.dmSection')}>
|
||||||
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||||
{t('friends.dmSection')}
|
{t('friends.dmSection')}
|
||||||
</h2>
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="group-dm-open"
|
||||||
|
aria-expanded={groupOpen}
|
||||||
|
title={t('friends.group.title')}
|
||||||
|
onClick={() => setGroupOpen((open) => !open)}
|
||||||
|
className="ml-auto rounded-[var(--radius-sm)] px-1.5 text-sm leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{groupOpen ? <GroupDmDialog onClose={() => setGroupOpen(false)} /> : null}
|
||||||
{direct.length === 0 ? (
|
{direct.length === 0 ? (
|
||||||
<p className="px-2 text-xs text-fg-muted" data-testid="dm-sidebar-empty">
|
<p className="px-2 text-xs text-fg-muted" data-testid="dm-sidebar-empty">
|
||||||
{t('friends.dmEmpty')}
|
{t('friends.dmEmpty')}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user