feat(voice): вход в голосовые комнаты, voice states и модерация (Фаза 3)
AGENT.md 7.14, начало Фазы 3:
- `internal/voice`: собственный подписыватель токенов LiveKit (HS256, grants
roomJoin/canPublish/canSubscribe/canPublishData) и адрес комнаты
`guild_{g}_channel_{c}` — без внешних зависимостей;
- таблица `voice_states` (миграция 00009) и store-методы: вход, флаги
(микрофон, звук, камера, экран), серверный мьют и глушение, перемещение,
выход, подсчёт участников комнаты;
- ручки: POST /channels/{id}/voice/join (проверяет CONNECT_VOICE, тип комнаты,
лимит участников и выдаёт токен), POST /channels/{id}/voice/leave,
PATCH /guilds/{id}/voice-states/@me, GET /guilds/{id}/voice-states,
PATCH /guilds/{id}/voice-states/{user_id} (MUTE_MEMBERS/DEAFEN_MEMBERS,
аудит), POST /guilds/{id}/voice-states/{user_id}/move (MOVE_MEMBERS);
- события VOICE_STATE_UPDATE всем участникам сервера, системные записи о входе
в голосовую комнату, READY отдаёт голосовые состояния серверов;
- конфиг: LIVEKIT_API_KEY/SECRET/URL приложению, публичный адрес
`LIVEKIT_PUBLIC_URL` (по умолчанию ws(s)://<домен>/rtc) в установщике и
compose; `/api/v1/meta` сообщает voice_enabled и voice_url;
- тесты: claims токена LiveKit и подпись, выключенный голос, имя комнаты, вход
и флаги, запрет мьюта без прав, перемещение и выход.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VoiceState — присутствие пользователя в голосовой комнате (AGENT.md 7.14).
|
||||
type VoiceState struct {
|
||||
UserID uint64
|
||||
GuildID uint64
|
||||
ChannelID uint64
|
||||
SessionID string
|
||||
SelfMute bool
|
||||
SelfDeaf bool
|
||||
ServerMute bool
|
||||
ServerDeaf bool
|
||||
Camera bool
|
||||
Screen bool
|
||||
JoinedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// VoiceStateParams — параметры записи голосового состояния.
|
||||
type VoiceStateParams struct {
|
||||
UserID uint64
|
||||
GuildID uint64
|
||||
ChannelID uint64
|
||||
SessionID string
|
||||
SelfMute bool
|
||||
SelfDeaf bool
|
||||
ServerMute bool
|
||||
ServerDeaf bool
|
||||
Camera bool
|
||||
Screen bool
|
||||
}
|
||||
|
||||
const voiceStateColumns = `user_id, guild_id, channel_id, session_id, self_mute, self_deaf,
|
||||
server_mute, server_deaf, camera, screen, joined_at, updated_at`
|
||||
|
||||
// UpsertVoiceState записывает состояние: пользователь входит в комнату или
|
||||
// обновляет флаги (микрофон, камера, шаринг экрана).
|
||||
func (s *Store) UpsertVoiceState(ctx context.Context, params VoiceStateParams) (*VoiceState, error) {
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO voice_states (user_id, guild_id, channel_id, session_id, self_mute, self_deaf,
|
||||
server_mute, server_deaf, camera, screen, joined_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, guild_id) DO UPDATE SET
|
||||
channel_id = excluded.channel_id,
|
||||
session_id = excluded.session_id,
|
||||
self_mute = excluded.self_mute,
|
||||
self_deaf = excluded.self_deaf,
|
||||
server_mute = excluded.server_mute,
|
||||
server_deaf = excluded.server_deaf,
|
||||
camera = excluded.camera,
|
||||
screen = excluded.screen,
|
||||
updated_at = excluded.updated_at`,
|
||||
int64(params.UserID), int64(params.GuildID), int64(params.ChannelID), params.SessionID,
|
||||
boolToInt(params.SelfMute), boolToInt(params.SelfDeaf), boolToInt(params.ServerMute),
|
||||
boolToInt(params.ServerDeaf), boolToInt(params.Camera), boolToInt(params.Screen),
|
||||
s.Now(), s.Now())
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
return s.GetVoiceState(ctx, params.GuildID, params.UserID)
|
||||
}
|
||||
|
||||
// GetVoiceState возвращает состояние пользователя в сервере.
|
||||
func (s *Store) GetVoiceState(ctx context.Context, guildID, userID uint64) (*VoiceState, error) {
|
||||
row := s.reader.QueryRowContext(ctx,
|
||||
`SELECT `+voiceStateColumns+` FROM voice_states WHERE guild_id = ? AND user_id = ?`,
|
||||
int64(guildID), int64(userID))
|
||||
return scanVoiceState(row)
|
||||
}
|
||||
|
||||
// ListVoiceStates отдаёт состояния всех участников сервера.
|
||||
func (s *Store) ListVoiceStates(ctx context.Context, guildID uint64) ([]VoiceState, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT `+voiceStateColumns+` FROM voice_states WHERE guild_id = ? ORDER BY joined_at`, int64(guildID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
states := make([]VoiceState, 0, 8)
|
||||
for rows.Next() {
|
||||
state, err := scanVoiceState(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
states = append(states, *state)
|
||||
}
|
||||
return states, rows.Err()
|
||||
}
|
||||
|
||||
// CountChannelVoiceStates считает участников комнаты (лимит user_limit).
|
||||
func (s *Store) CountChannelVoiceStates(ctx context.Context, channelID uint64) (int, error) {
|
||||
var count int
|
||||
err := s.reader.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM voice_states WHERE channel_id = ?`, int64(channelID)).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// DeleteVoiceState убирает пользователя из голосовой комнаты.
|
||||
func (s *Store) DeleteVoiceState(ctx context.Context, guildID, userID uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx,
|
||||
`DELETE FROM voice_states WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID))
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteVoiceStatesForChannel убирает всех из комнаты (при её удалении).
|
||||
func (s *Store) DeleteVoiceStatesForChannel(ctx context.Context, channelID uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx, `DELETE FROM voice_states WHERE channel_id = ?`, int64(channelID))
|
||||
return err
|
||||
}
|
||||
|
||||
func scanVoiceState(scanner interface{ Scan(...any) error }) (*VoiceState, error) {
|
||||
var (
|
||||
state VoiceState
|
||||
selfMute, selfDeaf int
|
||||
serverMute, serverDeaf int
|
||||
camera, screen int
|
||||
joinedAt, updatedAt string
|
||||
)
|
||||
err := scanner.Scan(&state.UserID, &state.GuildID, &state.ChannelID, &state.SessionID,
|
||||
&selfMute, &selfDeaf, &serverMute, &serverDeaf, &camera, &screen, &joinedAt, &updatedAt)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
state.SelfMute = selfMute == 1
|
||||
state.SelfDeaf = selfDeaf == 1
|
||||
state.ServerMute = serverMute == 1
|
||||
state.ServerDeaf = serverDeaf == 1
|
||||
state.Camera = camera == 1
|
||||
state.Screen = screen == 1
|
||||
state.JoinedAt = parseTimestamp(joinedAt)
|
||||
state.UpdatedAt = parseTimestamp(updatedAt)
|
||||
return &state, nil
|
||||
}
|
||||
Reference in New Issue
Block a user