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:
2026-09-20 18:04:59 +03:00
parent 435c4deb9c
commit ecd52c11d3
13 changed files with 1045 additions and 19 deletions
+139
View File
@@ -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
}