Files
glchat/internal/store/dm_groups.go
T
grendervill 50709396cd feat(dm): хранилище и миграция звонков в беседах (Фаза 7)
Звонок в личной и групповой беседе живёт в своей таблице: в voice_states
guild_id обязателен, а у беседы сервера нет. Миграция 00026 добавляет
dm_calls (статус, media, длительность, причина завершения) и
dm_call_participants (состояние участника и флаги микрофона, камеры и
экрана); уникальный частичный индекс держит один идущий звонок на беседу.

В DMParticipantProfiles сравнение с владельцем обёрнуто в COALESCE: у 1:1
dm_owner_id пуст, и NULL нельзя было прочитать в int — ручка звонка в
личной беседе падала на 500.

Тесты: жизненный цикл звонка в хранилище (гудки, active на втором
участнике, история, повторный звонок) и выборка сторожем.
2026-09-26 17:02:51 +03:00

191 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:
// Беседа снова обычная личная: имя, владелец и иконка группы
// сбрасываются (AGENT.md 7.8, D-070). Файл иконки после этого
// становится сиротой и удаляется уборкой (AGENT.md 7.7).
if _, err := tx.ExecContext(ctx,
`UPDATE channels SET name = '', dm_owner_id = NULL, icon_file_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 возвращает участников беседы с профилями. Выражение
// «владелец» обёрнуто в COALESCE: у 1:1 dm_owner_id пуст, а сравнение с NULL
// даёт NULL, который нельзя прочитать в int.
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,
COALESCE(c.dm_owner_id = u.id, 0)
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
}