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

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

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

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

Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
This commit is contained in:
2026-09-26 15:40:17 +03:00
parent 50265c66b8
commit f9464130cd
17 changed files with 1622 additions and 23 deletions
+88 -1
View File
@@ -186,6 +186,8 @@ func (s *Store) FindDMChannel(ctx context.Context, firstID, secondID uint64) (*C
}
// DMChannelSummary — личная беседа со собеседником и последним сообщением.
// Для групповой беседы (AGENT.md 7.8) вместо собеседника заполняются Name,
// MemberCount и Participants, а RecipientID остаётся нулевым.
type DMChannelSummary struct {
Channel Channel
RecipientID uint64
@@ -198,10 +200,31 @@ type DMChannelSummary struct {
LastMessageID uint64
LastMessageAt *time.Time
LastAuthorID *uint64
// IsGroup — участников больше двух; GroupName — имя беседы.
IsGroup bool
GroupName string
MemberCount int
Participants []DMParticipant
OwnerID *uint64
}
// ListDMChannels возвращает личные беседы пользователя.
// ListDMChannels возвращает личные беседы пользователя: и 1:1, и групповые
// (AGENT.md 7.8). Беседа считается групповой, если в ней больше двух
// участников; с двумя — обычная личная, с одним — не показывается вовсе.
func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
channels, err := s.listDirectChannels(ctx, userID)
if err != nil {
return nil, err
}
groups, err := s.listGroupChannels(ctx, userID)
if err != nil {
return nil, err
}
return append(channels, groups...), nil
}
// listDirectChannels возвращает беседы ровно с двумя участниками.
func (s *Store) listDirectChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT c.id, c.created_at, u.id, u.display_name, u.username, u.avatar_file_id, u.status,
u.last_seen_at, u.timezone,
@@ -213,6 +236,7 @@ func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelS
JOIN dm_participants other ON other.channel_id = c.id AND other.user_id <> p.user_id
JOIN users u ON u.id = other.user_id AND u.deleted_at IS NULL
WHERE p.user_id = ?
AND (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id) = 2
ORDER BY c.id DESC`, int64(userID))
if err != nil {
return nil, err
@@ -255,6 +279,69 @@ func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelS
return channels, rows.Err()
}
// listGroupChannels возвращает групповые беседы: имя, число участников и
// последнее сообщение. Участники подтягиваются отдельным запросом — групп у
// пользователя немного, а список нужен интерфейсу для карточки беседы.
func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT c.id, c.name, c.dm_owner_id, c.created_at,
(SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id),
(SELECT m.id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
(SELECT m.created_at FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
(SELECT m.author_id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1)
FROM dm_participants p
JOIN channels c ON c.id = p.channel_id AND c.type = 'dm'
WHERE p.user_id = ?
AND (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id) > 2
ORDER BY c.id DESC`, int64(userID))
if err != nil {
return nil, err
}
defer rows.Close()
groups := make([]DMChannelSummary, 0, 4)
for rows.Next() {
var (
summary DMChannelSummary
ownerID sql.NullInt64
createdAt string
lastMessageID sql.NullInt64
lastMessageAt sql.NullString
lastAuthorID sql.NullInt64
)
if err := rows.Scan(&summary.Channel.ID, &summary.GroupName, &ownerID, &createdAt,
&summary.MemberCount, &lastMessageID, &lastMessageAt, &lastAuthorID); err != nil {
return nil, err
}
summary.IsGroup = true
summary.OwnerID = optionalID(ownerID)
summary.Channel.Type = ChannelDM
summary.Channel.Name = summary.GroupName
summary.Channel.DMOwnerID = summary.OwnerID
summary.Channel.CreatedAt = parseTimestamp(createdAt)
if lastMessageID.Valid {
summary.LastMessageID = uint64(lastMessageID.Int64)
}
if lastMessageAt.Valid {
value := parseTimestamp(lastMessageAt.String)
summary.LastMessageAt = &value
}
summary.LastAuthorID = optionalID(lastAuthorID)
groups = append(groups, summary)
}
if err := rows.Err(); err != nil {
return nil, err
}
for index := range groups {
participants, err := s.DMParticipantProfiles(ctx, groups[index].Channel.ID, userID)
if err != nil {
return nil, err
}
groups[index].Participants = participants
}
return groups, nil
}
// TouchLastSeen обновляет время последней активности пользователя.
func (s *Store) TouchLastSeen(ctx context.Context, userID uint64) error {
_, err := s.writer.ExecContext(ctx,