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 }