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
+8 -3
View File
@@ -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
}