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:
@@ -31,7 +31,10 @@ type Channel struct {
|
||||
BackgroundFileID *uint64
|
||||
VoiceStatus string
|
||||
UserLimit int
|
||||
CreatedAt time.Time
|
||||
// DMOwnerID — владелец групповой личной беседы (AGENT.md 7.8): только он
|
||||
// добавляет и удаляет участников и переименовывает беседу. У 1:1 — NULL.
|
||||
DMOwnerID *uint64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ChannelOverride struct {
|
||||
@@ -56,7 +59,7 @@ type CreateChannelParams struct {
|
||||
}
|
||||
|
||||
const channelColumns = `id, guild_id, type, name, description, position, parent_id,
|
||||
slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, created_at`
|
||||
slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, dm_owner_id, created_at`
|
||||
|
||||
func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) {
|
||||
if params.ID == 0 {
|
||||
@@ -269,11 +272,12 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
||||
nsfw int
|
||||
background sql.NullString
|
||||
backgroundID sql.NullInt64
|
||||
dmOwnerID sql.NullInt64
|
||||
createdAt string
|
||||
)
|
||||
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
|
||||
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
|
||||
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &createdAt)
|
||||
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &dmOwnerID, &createdAt)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
@@ -284,6 +288,7 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
||||
channel.Background = background.String
|
||||
}
|
||||
channel.BackgroundFileID = optionalID(backgroundID)
|
||||
channel.DMOwnerID = optionalID(dmOwnerID)
|
||||
channel.CreatedAt = parseTimestamp(createdAt)
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
@@ -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 — личная беседа со собеседником и последним сообщением.
|
||||
// Для групповой беседы (AGENT.md 7.8) вместо собеседника заполняются Name,
|
||||
// MemberCount и Participants, а RecipientID остаётся нулевым.
|
||||
type DMChannelSummary struct {
|
||||
Channel Channel
|
||||
RecipientID uint64
|
||||
@@ -198,10 +200,31 @@ type DMChannelSummary struct {
|
||||
LastMessageID uint64
|
||||
LastMessageAt *time.Time
|
||||
LastAuthorID *uint64
|
||||
// IsGroup — участников больше двух; GroupName — имя беседы.
|
||||
IsGroup bool
|
||||
GroupName string
|
||||
MemberCount int
|
||||
Participants []DMParticipant
|
||||
OwnerID *uint64
|
||||
}
|
||||
|
||||
// ListDMChannels возвращает личные беседы пользователя.
|
||||
// ListDMChannels возвращает личные беседы пользователя: и 1:1, и групповые
|
||||
// (AGENT.md 7.8). Беседа считается групповой, если в ней больше двух
|
||||
// участников; с двумя — обычная личная, с одним — не показывается вовсе.
|
||||
func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
||||
channels, err := s.listDirectChannels(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups, err := s.listGroupChannels(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(channels, groups...), nil
|
||||
}
|
||||
|
||||
// listDirectChannels возвращает беседы ровно с двумя участниками.
|
||||
func (s *Store) listDirectChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT c.id, c.created_at, u.id, u.display_name, u.username, u.avatar_file_id, u.status,
|
||||
u.last_seen_at, u.timezone,
|
||||
@@ -213,6 +236,7 @@ func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelS
|
||||
JOIN dm_participants other ON other.channel_id = c.id AND other.user_id <> p.user_id
|
||||
JOIN users u ON u.id = other.user_id AND u.deleted_at IS NULL
|
||||
WHERE p.user_id = ?
|
||||
AND (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id) = 2
|
||||
ORDER BY c.id DESC`, int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -255,6 +279,69 @@ func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelS
|
||||
return channels, rows.Err()
|
||||
}
|
||||
|
||||
// listGroupChannels возвращает групповые беседы: имя, число участников и
|
||||
// последнее сообщение. Участники подтягиваются отдельным запросом — групп у
|
||||
// пользователя немного, а список нужен интерфейсу для карточки беседы.
|
||||
func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT c.id, c.name, c.dm_owner_id, c.created_at,
|
||||
(SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id),
|
||||
(SELECT m.id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
|
||||
(SELECT m.created_at FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
|
||||
(SELECT m.author_id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1)
|
||||
FROM dm_participants p
|
||||
JOIN channels c ON c.id = p.channel_id AND c.type = 'dm'
|
||||
WHERE p.user_id = ?
|
||||
AND (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id) > 2
|
||||
ORDER BY c.id DESC`, int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := make([]DMChannelSummary, 0, 4)
|
||||
for rows.Next() {
|
||||
var (
|
||||
summary DMChannelSummary
|
||||
ownerID sql.NullInt64
|
||||
createdAt string
|
||||
lastMessageID sql.NullInt64
|
||||
lastMessageAt sql.NullString
|
||||
lastAuthorID sql.NullInt64
|
||||
)
|
||||
if err := rows.Scan(&summary.Channel.ID, &summary.GroupName, &ownerID, &createdAt,
|
||||
&summary.MemberCount, &lastMessageID, &lastMessageAt, &lastAuthorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.IsGroup = true
|
||||
summary.OwnerID = optionalID(ownerID)
|
||||
summary.Channel.Type = ChannelDM
|
||||
summary.Channel.Name = summary.GroupName
|
||||
summary.Channel.DMOwnerID = summary.OwnerID
|
||||
summary.Channel.CreatedAt = parseTimestamp(createdAt)
|
||||
if lastMessageID.Valid {
|
||||
summary.LastMessageID = uint64(lastMessageID.Int64)
|
||||
}
|
||||
if lastMessageAt.Valid {
|
||||
value := parseTimestamp(lastMessageAt.String)
|
||||
summary.LastMessageAt = &value
|
||||
}
|
||||
summary.LastAuthorID = optionalID(lastAuthorID)
|
||||
groups = append(groups, summary)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range groups {
|
||||
participants, err := s.DMParticipantProfiles(ctx, groups[index].Channel.ID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups[index].Participants = participants
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// TouchLastSeen обновляет время последней активности пользователя.
|
||||
func (s *Store) TouchLastSeen(ctx context.Context, userID uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx,
|
||||
|
||||
Reference in New Issue
Block a user