From 50709396cd6d6db26019c5a35d5f0f9f87cd9af0 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 17:02:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(dm):=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=D1=89=D0=B5=20=D0=B8=20=D0=BC=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B2=20=D0=B1=D0=B5=D1=81=D0=B5=D0=B4=D0=B0=D1=85=20?= =?UTF-8?q?(=D0=A4=D0=B0=D0=B7=D0=B0=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Звонок в личной и групповой беседе живёт в своей таблице: в voice_states guild_id обязателен, а у беседы сервера нет. Миграция 00026 добавляет dm_calls (статус, media, длительность, причина завершения) и dm_call_participants (состояние участника и флаги микрофона, камеры и экрана); уникальный частичный индекс держит один идущий звонок на беседу. В DMParticipantProfiles сравнение с владельцем обёрнуто в COALESCE: у 1:1 dm_owner_id пуст, и NULL нельзя было прочитать в int — ручка звонка в личной беседе падала на 500. Тесты: жизненный цикл звонка в хранилище (гудки, active на втором участнике, история, повторный звонок) и выборка сторожем. --- .../migrations/00026_phase7_dm_calls.sql | 51 ++ internal/store/dm_calls.go | 485 ++++++++++++++++++ internal/store/dm_calls_test.go | 156 ++++++ internal/store/dm_groups.go | 6 +- 4 files changed, 696 insertions(+), 2 deletions(-) create mode 100644 internal/database/migrations/00026_phase7_dm_calls.sql create mode 100644 internal/store/dm_calls.go create mode 100644 internal/store/dm_calls_test.go diff --git a/internal/database/migrations/00026_phase7_dm_calls.sql b/internal/database/migrations/00026_phase7_dm_calls.sql new file mode 100644 index 0000000..af0f88d --- /dev/null +++ b/internal/database/migrations/00026_phase7_dm_calls.sql @@ -0,0 +1,51 @@ +-- +goose Up +-- Фаза 7: звонки в личных и групповых беседах (AGENT.md 7.8, 7.14). +-- +-- Звонок — отдельная сущность беседы: у него своя комната LiveKit, своё +-- состояние участников и своя история (кто звонил, когда, сколько длился, +-- кто пропустил). В voice_states звонок не помещается: там guild_id +-- обязателен, а у личной беседы сервера нет. +-- +-- Статусы звонка (dm_calls.status): ringing — идут гудки, active — хотя бы +-- двое в комнате, ended — завершён. Состояния участника +-- (dm_call_participants.state): invited — приглашён и ещё не ответил, +-- joined — в звонке, declined — отклонил, left — вышел, missed — не ответил. +CREATE TABLE dm_calls ( + id INTEGER PRIMARY KEY, + channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + initiator_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + status TEXT NOT NULL, + -- media: audio | video — чем открыли звонок, клиент показывает это в истории. + media TEXT NOT NULL DEFAULT 'audio', + created_at TEXT NOT NULL, + -- started_at — момент, когда звонок стал active: от него считается + -- длительность разговора, а не от гудков. + started_at TEXT, + ended_at TEXT, + ended_by INTEGER REFERENCES users (id) ON DELETE SET NULL, + -- end_reason: completed | canceled | declined | missed | failed. + end_reason TEXT +); +CREATE INDEX dm_calls_channel_idx ON dm_calls (channel_id, id DESC); +-- В беседе одновременно идёт не больше одного звонка: гонку двух «позвонить» +-- закрывает сама база, а не проверка в приложении. +CREATE UNIQUE INDEX dm_calls_live_channel_idx ON dm_calls (channel_id) WHERE status <> 'ended'; + +CREATE TABLE dm_call_participants ( + call_id INTEGER NOT NULL REFERENCES dm_calls (id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + state TEXT NOT NULL, + invited_at TEXT NOT NULL, + joined_at TEXT, + left_at TEXT, + self_mute INTEGER NOT NULL DEFAULT 0, + self_deaf INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screen INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (call_id, user_id) +); +CREATE INDEX dm_call_participants_user_idx ON dm_call_participants (user_id, call_id DESC); + +-- +goose Down +DROP TABLE dm_call_participants; +DROP TABLE dm_calls; diff --git a/internal/store/dm_calls.go b/internal/store/dm_calls.go new file mode 100644 index 0000000..69ef874 --- /dev/null +++ b/internal/store/dm_calls.go @@ -0,0 +1,485 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// Звонки в личных и групповых беседах (AGENT.md 7.8, Фаза 7): своя таблица, а +// не voice_states — там guild_id обязателен, а у беседы сервера нет. Комната +// LiveKit у звонка одна на звонок, имя собирает voice.CallRoomName. + +// Статусы звонка. +const ( + DMCallRinging = "ringing" + DMCallActive = "active" + DMCallEnded = "ended" +) + +// Состояния участника звонка. +const ( + DMCallInvited = "invited" + DMCallJoined = "joined" + DMCallDeclined = "declined" + DMCallLeft = "left" + DMCallMissed = "missed" +) + +// Причины завершения звонка. +const ( + DMCallReasonCompleted = "completed" + DMCallReasonCanceled = "canceled" + DMCallReasonDeclined = "declined" + DMCallReasonMissed = "missed" + DMCallReasonFailed = "failed" +) + +// История звонков беседы: по умолчанию 50 записей на страницу, максимум 100. +const ( + defaultDMCallHistory = 50 + maxDMCallHistory = 100 +) + +// DMCall — звонок беседы: кто позвонил, когда и чем закончилось. +type DMCall struct { + ID uint64 + ChannelID uint64 + InitiatorID uint64 + Status string + Media string + CreatedAt time.Time + StartedAt *time.Time + EndedAt *time.Time + EndedBy *uint64 + EndReason string +} + +// DMCallParticipant — участник звонка и его состояние. +type DMCallParticipant struct { + CallID uint64 + UserID uint64 + State string + InvitedAt time.Time + JoinedAt *time.Time + LeftAt *time.Time + SelfMute bool + SelfDeaf bool + Camera bool + Screen bool +} + +// DMCallFlags — флаги участника: микрофон, звук, камера, шаринг экрана. +type DMCallFlags struct { + SelfMute bool + SelfDeaf bool + Camera bool + Screen bool +} + +// DMCallWithParticipants — звонок вместе с составом: так его отдают API и +// Gateway, поэтому отдельный тип, а не два запроса у вызывающего кода. +type DMCallWithParticipants struct { + DMCall + Participants []DMCallParticipant +} + +// DMCallDurationSeconds считает длительность разговора: от момента, когда +// звонок стал active (кто-то ответил), до завершения или до текущего времени. +func (s *Store) DMCallDurationSeconds(call *DMCall) int { + if call == nil || call.StartedAt == nil { + return 0 + } + end := s.now() + if call.EndedAt != nil { + end = *call.EndedAt + } + if end.Before(*call.StartedAt) { + return 0 + } + return int(end.Sub(*call.StartedAt).Seconds()) +} + +const dmCallColumns = `id, channel_id, initiator_id, status, media, created_at, + started_at, ended_at, ended_by, end_reason` + +const dmCallParticipantColumns = `call_id, user_id, state, invited_at, joined_at, left_at, + self_mute, self_deaf, camera, screen` + +// CreateDMCall открывает звонок: инициатор сразу в комнате, остальные +// приглашены. Второй одновременный звонок в беседе невозможен — его +// отклоняет уникальный индекс dm_calls_live_channel_idx. +func (s *Store) CreateDMCall(ctx context.Context, channelID, initiatorID uint64, media string, invitees []uint64) (*DMCallWithParticipants, error) { + callID := s.NextID() + now := s.Now() + if err := s.InTx(ctx, func(tx *sql.Tx) error { + var live int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM dm_calls WHERE channel_id = ? AND status <> ?`, + int64(channelID), DMCallEnded).Scan(&live); err != nil { + return err + } + if live > 0 { + return ErrConflict + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO dm_calls (id, channel_id, initiator_id, status, media, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + int64(callID), int64(channelID), int64(initiatorID), DMCallRinging, media, now); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO dm_call_participants (call_id, user_id, state, invited_at, joined_at) + VALUES (?, ?, ?, ?, ?)`, + int64(callID), int64(initiatorID), DMCallJoined, now, now); err != nil { + return err + } + for _, inviteeID := range invitees { + if inviteeID == initiatorID { + continue + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO dm_call_participants (call_id, user_id, state, invited_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (call_id, user_id) DO NOTHING`, + int64(callID), int64(inviteeID), DMCallInvited, now); err != nil { + return err + } + } + return nil + }); err != nil { + return nil, mapError(err) + } + return s.GetDMCall(ctx, callID) +} + +// GetDMCall возвращает звонок с составом. +func (s *Store) GetDMCall(ctx context.Context, callID uint64) (*DMCallWithParticipants, error) { + row := s.reader.QueryRowContext(ctx, + `SELECT `+dmCallColumns+` FROM dm_calls WHERE id = ?`, int64(callID)) + call, err := scanDMCall(row) + if err != nil { + return nil, err + } + participants, err := s.listDMCallParticipants(ctx, callID) + if err != nil { + return nil, err + } + return &DMCallWithParticipants{DMCall: *call, Participants: participants}, nil +} + +// GetLiveDMCall возвращает идущий звонок беседы: гудки или разговор. +func (s *Store) GetLiveDMCall(ctx context.Context, channelID uint64) (*DMCallWithParticipants, error) { + row := s.reader.QueryRowContext(ctx, + `SELECT `+dmCallColumns+` FROM dm_calls WHERE channel_id = ? AND status <> ? ORDER BY id DESC LIMIT 1`, + int64(channelID), DMCallEnded) + call, err := scanDMCall(row) + if err != nil { + return nil, err + } + participants, err := s.listDMCallParticipants(ctx, call.ID) + if err != nil { + return nil, err + } + return &DMCallWithParticipants{DMCall: *call, Participants: participants}, nil +} + +// IsDMCallParticipant сообщает, звали ли пользователя в этот звонок. +func (s *Store) IsDMCallParticipant(ctx context.Context, callID, userID uint64) (bool, error) { + var exists int + err := s.reader.QueryRowContext(ctx, + `SELECT 1 FROM dm_call_participants WHERE call_id = ? AND user_id = ?`, + int64(callID), int64(userID)).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// JoinDMCallParticipant переводит участника в состояние «в звонке» и +// запускает отсчёт разговора, когда в комнате оказывается второй человек. +func (s *Store) JoinDMCallParticipant(ctx context.Context, callID, userID uint64) (*DMCall, error) { + now := s.Now() + if err := s.InTx(ctx, func(tx *sql.Tx) error { + result, err := tx.ExecContext(ctx, ` + UPDATE dm_call_participants SET state = ?, joined_at = ?, left_at = NULL + WHERE call_id = ? AND user_id = ?`, + DMCallJoined, now, int64(callID), int64(userID)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + var joined int + if err := tx.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM dm_call_participants WHERE call_id = ? AND state = ?`, + int64(callID), DMCallJoined).Scan(&joined); err != nil { + return err + } + if joined < 2 { + return nil + } + _, err = tx.ExecContext(ctx, ` + UPDATE dm_calls SET status = ?, started_at = COALESCE(started_at, ?) + WHERE id = ? AND status = ?`, + DMCallActive, now, int64(callID), DMCallRinging) + return err + }); err != nil { + return nil, mapError(err) + } + return s.GetDMCallRow(ctx, callID) +} + +// LeaveDMCallParticipant помечает участника вышедшим, отклонившим или +// пропустившим звонок. +func (s *Store) LeaveDMCallParticipant(ctx context.Context, callID, userID uint64, state string) error { + _, err := s.writer.ExecContext(ctx, ` + UPDATE dm_call_participants SET state = ?, left_at = ? + WHERE call_id = ? AND user_id = ?`, + state, s.Now(), int64(callID), int64(userID)) + return mapError(err) +} + +// CountDMCallParticipants считает участников звонка в заданном состоянии. +func (s *Store) CountDMCallParticipants(ctx context.Context, callID uint64, state string) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM dm_call_participants WHERE call_id = ? AND state = ?`, + int64(callID), state).Scan(&count) + return count, err +} + +// UpdateDMCallFlags сохраняет флаги участника (микрофон, звук, камера, экран). +func (s *Store) UpdateDMCallFlags(ctx context.Context, callID, userID uint64, flags DMCallFlags) error { + result, err := s.writer.ExecContext(ctx, ` + UPDATE dm_call_participants + SET self_mute = ?, self_deaf = ?, camera = ?, screen = ? + WHERE call_id = ? AND user_id = ?`, + boolToInt(flags.SelfMute), boolToInt(flags.SelfDeaf), + boolToInt(flags.Camera), boolToInt(flags.Screen), + int64(callID), int64(userID)) + if err != nil { + return mapError(err) + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +// EndDMCall завершает звонок: не ответившие помечаются пропустившими, те, кто +// был в комнате, — вышедшими. Повторный вызов не меняет уже завершённый звонок. +func (s *Store) EndDMCall(ctx context.Context, callID uint64, endedBy *uint64, reason string) error { + now := s.Now() + var actor sql.NullInt64 + if endedBy != nil { + actor = sql.NullInt64{Int64: int64(*endedBy), Valid: true} + } + return s.InTx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, ` + UPDATE dm_calls SET status = ?, ended_at = ?, ended_by = ?, end_reason = ? + WHERE id = ? AND status <> ?`, + DMCallEnded, now, actor, reason, int64(callID), DMCallEnded); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE dm_call_participants SET state = ?, left_at = ? + WHERE call_id = ? AND state = ?`, + DMCallMissed, now, int64(callID), DMCallInvited); err != nil { + return err + } + _, err := tx.ExecContext(ctx, ` + UPDATE dm_call_participants SET state = ?, left_at = ? + WHERE call_id = ? AND state = ?`, + DMCallLeft, now, int64(callID), DMCallJoined) + return err + }) +} + +// ListDMCalls отдаёт историю звонков беседы от новых к старым. beforeID — +// курсор: отдаются звонки с меньшим идентификатором. +func (s *Store) ListDMCalls(ctx context.Context, channelID uint64, limit int, beforeID uint64) ([]DMCallWithParticipants, error) { + if limit <= 0 || limit > maxDMCallHistory { + limit = defaultDMCallHistory + } + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+dmCallColumns+` FROM dm_calls + WHERE channel_id = ? AND (? = 0 OR id < ?) + ORDER BY id DESC LIMIT ?`, + int64(channelID), int64(beforeID), int64(beforeID), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + calls := make([]DMCallWithParticipants, 0, limit) + for rows.Next() { + call, err := scanDMCall(rows) + if err != nil { + return nil, err + } + calls = append(calls, DMCallWithParticipants{DMCall: *call}) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(calls) == 0 { + return calls, nil + } + // Участники тянутся одним запросом на страницу: диапазон идентификаторов + // страницы непрерывен, поэтому лишних звонков в выборку не попадает. + page := make(map[uint64][]DMCallParticipant, len(calls)) + participants, err := s.listDMCallParticipantsRange(ctx, channelID, calls[len(calls)-1].ID, beforeID) + if err != nil { + return nil, err + } + for _, participant := range participants { + page[participant.CallID] = append(page[participant.CallID], participant) + } + for index := range calls { + calls[index].Participants = page[calls[index].ID] + if calls[index].Participants == nil { + calls[index].Participants = []DMCallParticipant{} + } + } + return calls, nil +} + +// ListStaleDMCalls ищет звонки для сторожа: гудки без ответа (ringingBefore) и +// разговоры, в которых не осталось никого (emptyBefore — время последнего +// обновления звонка). Оба условия — «строго раньше» указанного момента. +func (s *Store) ListStaleDMCalls(ctx context.Context, ringingBefore, emptyBefore time.Time) ([]DMCall, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+dmCallColumns+` FROM dm_calls c + WHERE c.status <> ? + AND ( + (c.status = ? AND c.created_at < ?) + OR (c.status = ? AND NOT EXISTS ( + SELECT 1 FROM dm_call_participants p + WHERE p.call_id = c.id AND p.state = ? + ) AND c.created_at < ?) + ) + ORDER BY c.id`, + DMCallEnded, DMCallRinging, s.Timestamp(ringingBefore), DMCallActive, DMCallJoined, + s.Timestamp(emptyBefore)) + if err != nil { + return nil, err + } + defer rows.Close() + + calls := make([]DMCall, 0, 4) + for rows.Next() { + call, err := scanDMCall(rows) + if err != nil { + return nil, err + } + calls = append(calls, *call) + } + return calls, rows.Err() +} + +// CountLiveDMCalls считает звонки, которые ещё не завершены (диагностика). +func (s *Store) CountLiveDMCalls(ctx context.Context) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM dm_calls WHERE status <> ?`, DMCallEnded).Scan(&count) + return count, err +} + +// GetDMCallRow возвращает звонок без состава. +func (s *Store) GetDMCallRow(ctx context.Context, callID uint64) (*DMCall, error) { + row := s.reader.QueryRowContext(ctx, + `SELECT `+dmCallColumns+` FROM dm_calls WHERE id = ?`, int64(callID)) + return scanDMCall(row) +} + +// listDMCallParticipants перечисляет состав звонка. +func (s *Store) listDMCallParticipants(ctx context.Context, callID uint64) ([]DMCallParticipant, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT `+dmCallParticipantColumns+` FROM dm_call_participants WHERE call_id = ? ORDER BY invited_at, user_id`, + int64(callID)) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDMCallParticipants(rows) +} + +// listDMCallParticipantsRange перечисляет состав звонков страницы истории. +func (s *Store) listDMCallParticipantsRange(ctx context.Context, channelID, minCallID, beforeID uint64) ([]DMCallParticipant, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT p.call_id, p.user_id, p.state, p.invited_at, p.joined_at, p.left_at, + p.self_mute, p.self_deaf, p.camera, p.screen + FROM dm_call_participants p JOIN dm_calls c ON c.id = p.call_id + WHERE c.channel_id = ? AND c.id >= ? AND (? = 0 OR c.id < ?) + ORDER BY p.call_id DESC, p.invited_at, p.user_id`, + int64(channelID), int64(minCallID), int64(beforeID), int64(beforeID)) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDMCallParticipants(rows) +} + +func scanDMCall(scanner interface{ Scan(...any) error }) (*DMCall, error) { + var ( + call DMCall + createdAt string + startedAt, endedAt sql.NullString + endedBy sql.NullInt64 + endReason sql.NullString + ) + err := scanner.Scan(&call.ID, &call.ChannelID, &call.InitiatorID, &call.Status, &call.Media, + &createdAt, &startedAt, &endedAt, &endedBy, &endReason) + if err != nil { + return nil, mapError(err) + } + call.CreatedAt = parseTimestamp(createdAt) + if startedAt.Valid { + value := parseTimestamp(startedAt.String) + call.StartedAt = &value + } + if endedAt.Valid { + value := parseTimestamp(endedAt.String) + call.EndedAt = &value + } + call.EndedBy = optionalID(endedBy) + call.EndReason = endReason.String + return &call, nil +} + +func scanDMCallParticipants(rows *sql.Rows) ([]DMCallParticipant, error) { + participants := make([]DMCallParticipant, 0, 4) + for rows.Next() { + var ( + participant DMCallParticipant + invitedAt string + joinedAt, leftAt sql.NullString + selfMute, selfDeaf int + camera, screen int + ) + if err := rows.Scan(&participant.CallID, &participant.UserID, &participant.State, &invitedAt, + &joinedAt, &leftAt, &selfMute, &selfDeaf, &camera, &screen); err != nil { + return nil, err + } + participant.InvitedAt = parseTimestamp(invitedAt) + if joinedAt.Valid { + value := parseTimestamp(joinedAt.String) + participant.JoinedAt = &value + } + if leftAt.Valid { + value := parseTimestamp(leftAt.String) + participant.LeftAt = &value + } + participant.SelfMute = selfMute == 1 + participant.SelfDeaf = selfDeaf == 1 + participant.Camera = camera == 1 + participant.Screen = screen == 1 + participants = append(participants, participant) + } + return participants, rows.Err() +} diff --git a/internal/store/dm_calls_test.go b/internal/store/dm_calls_test.go new file mode 100644 index 0000000..608408b --- /dev/null +++ b/internal/store/dm_calls_test.go @@ -0,0 +1,156 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "glchat/internal/database" +) + +// newCallStore открывает тестовую БД с применёнными миграциями. +func newCallStore(t *testing.T) *Store { + t.Helper() + ctx := context.Background() + db, err := database.Open(ctx, database.Options{ + Path: filepath.Join(t.TempDir(), "glchat.db"), + ReadPool: 2, + Migrate: true, + }) + if err != nil { + t.Fatalf("open test database: %v", err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Errorf("close test database: %v", err) + } + }) + return New(db) +} + +// TestDMCallStoreLifecycle — состояние звонка в хранилище (AGENT.md 7.8): +// один звонок на беседу, переход в active на втором участнике, история. +func TestDMCallStoreLifecycle(t *testing.T) { + ctx := t.Context() + st := newCallStore(t) + alice := createStoreUser(t, st, "alice_store") + bob := createStoreUser(t, st, "bob_store") + channel, err := st.CreateGroupDMChannel(ctx, alice.ID, "Беседа", []uint64{bob.ID}) + if err != nil { + t.Fatalf("create conversation: %v", err) + } + + call, err := st.CreateDMCall(ctx, channel.ID, alice.ID, "audio", []uint64{bob.ID}) + if err != nil { + t.Fatalf("create call: %v", err) + } + if call.Status != DMCallRinging || call.StartedAt != nil { + t.Fatalf("new call = %+v", call.DMCall) + } + if len(call.Participants) != 2 { + t.Fatalf("participants = %+v", call.Participants) + } + // Второй одновременный звонок в беседе запрещён. + if _, err := st.CreateDMCall(ctx, channel.ID, bob.ID, "audio", nil); !errors.Is(err, ErrConflict) { + t.Fatalf("second call error = %v, want ErrConflict", err) + } + + joined, err := st.JoinDMCallParticipant(ctx, call.ID, bob.ID) + if err != nil { + t.Fatalf("join: %v", err) + } + if joined.Status != DMCallActive || joined.StartedAt == nil { + t.Fatalf("call after second join = %+v", joined) + } + if err := st.UpdateDMCallFlags(ctx, call.ID, bob.ID, DMCallFlags{SelfMute: true, Camera: true}); err != nil { + t.Fatalf("update flags: %v", err) + } + if err := st.LeaveDMCallParticipant(ctx, call.ID, alice.ID, DMCallLeft); err != nil { + t.Fatalf("leave: %v", err) + } + if err := st.EndDMCall(ctx, call.ID, &bob.ID, DMCallReasonCompleted); err != nil { + t.Fatalf("end: %v", err) + } + + // Текущего звонка больше нет, а история хранит состав и длительность. + if _, err := st.GetLiveDMCall(ctx, channel.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("live call error = %v, want ErrNotFound", err) + } + history, err := st.ListDMCalls(ctx, channel.ID, 10, 0) + if err != nil { + t.Fatalf("history: %v", err) + } + if len(history) != 1 { + t.Fatalf("history = %+v", history) + } + entry := history[0] + if entry.Status != DMCallEnded || entry.EndReason != DMCallReasonCompleted || entry.EndedBy == nil { + t.Fatalf("history entry = %+v", entry.DMCall) + } + if st.DMCallDurationSeconds(&entry.DMCall) < 0 { + t.Fatalf("duration = %d", st.DMCallDurationSeconds(&entry.DMCall)) + } + states := map[uint64]string{} + for _, participant := range entry.Participants { + states[participant.UserID] = participant.State + } + if states[alice.ID] != DMCallLeft || states[bob.ID] != DMCallLeft { + t.Fatalf("participant states = %+v", states) + } + + // После завершения можно звонить снова: живой звонок только один. + next, err := st.CreateDMCall(ctx, channel.ID, bob.ID, "video", []uint64{alice.ID}) + if err != nil { + t.Fatalf("create next call: %v", err) + } + if next.Status != DMCallRinging || next.InitiatorID != bob.ID { + t.Fatalf("next call = %+v", next.DMCall) + } +} + +// TestDMCallStaleSweep — сторож находит гудки без ответа и опустевшие комнаты. +func TestDMCallStaleSweep(t *testing.T) { + ctx := t.Context() + st := newCallStore(t) + alice := createStoreUser(t, st, "alice_sweep") + bob := createStoreUser(t, st, "bob_sweep") + channel, err := st.CreateGroupDMChannel(ctx, alice.ID, "Беседа", []uint64{bob.ID}) + if err != nil { + t.Fatalf("create conversation: %v", err) + } + call, err := st.CreateDMCall(ctx, channel.ID, alice.ID, "audio", []uint64{bob.ID}) + if err != nil { + t.Fatalf("create call: %v", err) + } + stale, err := st.ListStaleDMCalls(ctx, time.Now().UTC().Add(time.Minute), time.Now().UTC().Add(time.Minute)) + if err != nil { + t.Fatalf("list stale: %v", err) + } + if len(stale) != 1 || stale[0].ID != call.ID { + t.Fatalf("stale calls = %+v", stale) + } + fresh, err := st.ListStaleDMCalls(ctx, time.Now().UTC().Add(-time.Minute), time.Now().UTC().Add(-time.Minute)) + if err != nil { + t.Fatalf("list fresh: %v", err) + } + if len(fresh) != 0 { + t.Fatalf("fresh call must not be swept: %+v", fresh) + } +} + +func createStoreUser(t *testing.T, st *Store, username string) *User { + t.Helper() + user, err := st.CreateUser(t.Context(), CreateUserParams{ + Username: username, + DisplayName: username, + EmailEnc: "enc", + EmailIndex: username, + PasswordHash: "hash", + }) + if err != nil { + t.Fatalf("create user %s: %v", username, err) + } + return user +} diff --git a/internal/store/dm_groups.go b/internal/store/dm_groups.go index 53391bc..91dc441 100644 --- a/internal/store/dm_groups.go +++ b/internal/store/dm_groups.go @@ -137,11 +137,13 @@ type DMParticipant struct { IsCurrentUser bool } -// DMParticipantProfiles возвращает участников беседы с профилями. +// 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, - c.dm_owner_id = u.id + 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