Files
glchat/internal/store/dm_groups.go
T
grendervill f9464130cd 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 (сайдбар, шапка, создание,
выключенная кнопка).
2026-09-26 15:40:17 +03:00

185 lines
7.0 KiB
Go

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
}