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
+439
View File
@@ -0,0 +1,439 @@
package server
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/permissions"
"glchat/internal/store"
"glchat/internal/voice"
)
type voiceStatePayload struct {
UserID string `json:"user_id"`
ChannelID string `json:"channel_id"`
GuildID string `json:"guild_id"`
SessionID string `json:"session_id,omitempty"`
SelfMute bool `json:"self_mute"`
SelfDeaf bool `json:"self_deaf"`
ServerMute bool `json:"server_mute"`
ServerDeaf bool `json:"server_deaf"`
Camera bool `json:"camera"`
Screen bool `json:"screen"`
JoinedAt string `json:"joined_at"`
}
type voiceStateListOutput struct {
Body struct {
States []voiceStatePayload `json:"voice_states"`
}
}
type voiceJoinOutput struct {
Body struct {
// Token — токен доступа LiveKit, URL — публичный адрес сервиса.
Token string `json:"token"`
URL string `json:"url"`
Room string `json:"room"`
ChannelID string `json:"channel_id"`
GuildID string `json:"guild_id"`
State voiceStatePayload `json:"state"`
}
}
// registerVoiceRoutes описывает голосовые комнаты (AGENT.md 7.14): вход,
// выход, флаги микрофона и камеры, модерация и перемещение участников.
func (s *Server) registerVoiceRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "joinVoiceChannel",
Method: http.MethodPost,
Path: "/channels/{channel_id}/voice/join",
Summary: "Войти в голосовую комнату (выдаёт токен LiveKit)",
Tags: []string{"Voice"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
},
) (*voiceJoinOutput, error) {
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
channelID, resolved, channel, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ConnectVoice)
if err != nil {
return nil, err
}
if channel.Type != store.ChannelVoice {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "канал не является голосовым")
}
if err := s.ensureVoiceCapacity(ctx, channel, user); err != nil {
return nil, err
}
if !s.voice.Enabled() {
return nil, humaErrorStatus(http.StatusServiceUnavailable, "voice.disabled", "голос на инстансе не настроен")
}
// Сохраняем состояние: пользователь мог перейти из другой комнаты.
state, err := s.store.UpsertVoiceState(ctx, store.VoiceStateParams{
UserID: user.ID,
GuildID: *channel.GuildID,
ChannelID: channelID,
SessionID: formatSnowflake(session.ID),
})
if err != nil {
return nil, humaError(err)
}
token, err := s.voice.Issue(voice.RoomName(*channel.GuildID, channelID), formatSnowflake(user.ID), user.DisplayName, voice.Grants{
RoomJoin: true, CanPublish: resolved.Can(permissions.Speak), CanSubscribe: true, CanPublishData: true,
})
if err != nil {
return nil, humaError(err)
}
s.dispatchVoiceState(ctx, state)
s.postSystemMessage(ctx, channelID, "voice_join", map[string]any{"user_id": formatSnowflake(user.ID)})
output := &voiceJoinOutput{}
output.Body.Token = token
output.Body.URL = s.cfg.LiveKitURL
output.Body.Room = voice.RoomName(*channel.GuildID, channelID)
output.Body.ChannelID = formatSnowflake(channelID)
output.Body.GuildID = formatSnowflake(*channel.GuildID)
output.Body.State = s.voiceStatePayload(state)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "leaveVoiceChannel",
Method: http.MethodPost,
Path: "/channels/{channel_id}/voice/leave",
Summary: "Покинуть голосовую комнату",
Tags: []string{"Voice"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channelID, err := parseID("channel_id", input.ChannelID)
if err != nil {
return nil, err
}
channel, err := s.store.GetChannel(ctx, channelID)
if err != nil {
return nil, humaError(err)
}
if channel.GuildID == nil {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found")
}
if err := s.store.DeleteVoiceState(ctx, *channel.GuildID, user.ID); err != nil {
return nil, humaError(err)
}
s.dispatchVoiceLeave(ctx, *channel.GuildID, channelID, user.ID)
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "updateMyVoiceState",
Method: http.MethodPatch,
Path: "/guilds/{guild_id}/voice-states/@me",
Summary: "Обновить свои флаги голоса (микрофон, звук, камера, экран)",
Tags: []string{"Voice"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
Body struct {
SelfMute *bool `json:"self_mute,omitempty"`
SelfDeaf *bool `json:"self_deaf,omitempty"`
Camera *bool `json:"camera,omitempty"`
Screen *bool `json:"screen,omitempty"`
}
},
) (*voiceStateListOutput, error) {
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
if err != nil {
return nil, err
}
current, err := s.store.GetVoiceState(ctx, guildID, user.ID)
if err != nil {
return nil, humaError(err)
}
params := store.VoiceStateParams{
UserID: user.ID,
GuildID: guildID,
ChannelID: current.ChannelID,
SessionID: formatSnowflake(session.ID),
SelfMute: current.SelfMute,
SelfDeaf: current.SelfDeaf,
ServerMute: current.ServerMute,
ServerDeaf: current.ServerDeaf,
Camera: current.Camera,
Screen: current.Screen,
}
if input.Body.SelfMute != nil {
params.SelfMute = *input.Body.SelfMute
}
if input.Body.SelfDeaf != nil {
params.SelfDeaf = *input.Body.SelfDeaf
}
if input.Body.Camera != nil {
params.Camera = *input.Body.Camera
}
if input.Body.Screen != nil {
params.Screen = *input.Body.Screen
}
state, err := s.store.UpsertVoiceState(ctx, params)
if err != nil {
return nil, humaError(err)
}
s.dispatchVoiceState(ctx, state)
return s.voiceStatesOutput(ctx, guildID)
})
huma.Register(api, huma.Operation{
OperationID: "listVoiceStates",
Method: http.MethodGet,
Path: "/guilds/{guild_id}/voice-states",
Summary: "Кто в голосовых комнатах сервера",
Tags: []string{"Voice"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
},
) (*voiceStateListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
if err != nil {
return nil, err
}
return s.voiceStatesOutput(ctx, guildID)
})
huma.Register(api, huma.Operation{
OperationID: "moderateVoiceState",
Method: http.MethodPatch,
Path: "/guilds/{guild_id}/voice-states/{user_id}",
Summary: "Серверный мьют и глушение участника",
Tags: []string{"Voice"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
UserID string `path:"user_id"`
Body struct {
ServerMute *bool `json:"server_mute,omitempty"`
ServerDeaf *bool `json:"server_deaf,omitempty"`
}
},
) (*voiceStateListOutput, error) {
actor, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, actor, permissions.ViewGuild)
if err != nil {
return nil, err
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
if input.Body.ServerMute != nil && !resolved.Has(permissions.MuteMembers) {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "MUTE_MEMBERS is required")
}
if input.Body.ServerDeaf != nil && !resolved.Has(permissions.DeafenMembers) {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "DEAFEN_MEMBERS is required")
}
current, err := s.store.GetVoiceState(ctx, guildID, targetID)
if err != nil {
return nil, humaError(err)
}
params := store.VoiceStateParams{
UserID: targetID, GuildID: guildID, ChannelID: current.ChannelID, SessionID: current.SessionID,
SelfMute: current.SelfMute, SelfDeaf: current.SelfDeaf,
ServerMute: current.ServerMute, ServerDeaf: current.ServerDeaf,
Camera: current.Camera, Screen: current.Screen,
}
if input.Body.ServerMute != nil {
params.ServerMute = *input.Body.ServerMute
}
if input.Body.ServerDeaf != nil {
params.ServerDeaf = *input.Body.ServerDeaf
}
state, err := s.store.UpsertVoiceState(ctx, params)
if err != nil {
return nil, humaError(err)
}
action := "voice.mute"
if input.Body.ServerMute != nil && !*input.Body.ServerMute {
action = "voice.unmute"
}
s.recordAudit(ctx, actor, guildID, action, "user", &targetID, "")
s.dispatchVoiceState(ctx, state)
return s.voiceStatesOutput(ctx, guildID)
})
huma.Register(api, huma.Operation{
OperationID: "moveVoiceMember",
Method: http.MethodPost,
Path: "/guilds/{guild_id}/voice-states/{user_id}/move",
Summary: "Переместить участника в другую голосовую комнату",
Tags: []string{"Voice"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
UserID string `path:"user_id"`
Body struct {
ChannelID string `json:"channel_id"`
}
},
) (*voiceStateListOutput, error) {
actor, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, actor, permissions.MoveMembers)
if err != nil {
return nil, err
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
channelID, err := parseID("channel_id", input.Body.ChannelID)
if err != nil {
return nil, err
}
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
return nil, err
}
channel, err := s.store.GetChannel(ctx, channelID)
if err != nil {
return nil, humaError(err)
}
if channel.Type != store.ChannelVoice {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "целевой канал не голосовой")
}
target, err := s.store.GetUser(ctx, targetID)
if err != nil {
return nil, humaError(err)
}
if err := s.ensureVoiceCapacity(ctx, channel, target); err != nil {
return nil, err
}
current, err := s.store.GetVoiceState(ctx, guildID, targetID)
if err != nil {
return nil, humaError(err)
}
state, err := s.store.UpsertVoiceState(ctx, store.VoiceStateParams{
UserID: targetID, GuildID: guildID, ChannelID: channelID, SessionID: current.SessionID,
SelfMute: current.SelfMute, SelfDeaf: current.SelfDeaf,
ServerMute: current.ServerMute, ServerDeaf: current.ServerDeaf,
Camera: current.Camera, Screen: current.Screen,
})
if err != nil {
return nil, humaError(err)
}
s.recordAudit(ctx, actor, guildID, "voice.move", "user", &targetID, "")
s.dispatchVoiceState(ctx, state)
return s.voiceStatesOutput(ctx, guildID)
})
}
// ensureVoiceCapacity проверяет лимит участников голосовой комнаты (AGENT.md 7.14).
func (s *Server) ensureVoiceCapacity(ctx context.Context, channel *store.Channel, user *store.User) error {
if channel.UserLimit <= 0 || user.IsInstanceAdmin {
return nil
}
count, err := s.store.CountChannelVoiceStates(ctx, channel.ID)
if err != nil {
return humaError(err)
}
// Если пользователь уже в этой комнате, лимит на него не действует.
current, err := s.store.GetVoiceState(ctx, *channel.GuildID, user.ID)
if err == nil && current.ChannelID == channel.ID {
return nil
}
if count >= channel.UserLimit {
return humaErrorStatus(http.StatusForbidden, "voice.channel_full", "в голосовой комнате нет свободных мест")
}
return nil
}
// voiceStatesOutput собирает список голосовых состояний сервера.
func (s *Server) voiceStatesOutput(ctx context.Context, guildID uint64) (*voiceStateListOutput, error) {
states, err := s.store.ListVoiceStates(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
output := &voiceStateListOutput{}
output.Body.States = make([]voiceStatePayload, 0, len(states))
for i := range states {
output.Body.States = append(output.Body.States, s.voiceStatePayload(&states[i]))
}
return output, nil
}
func (s *Server) voiceStatePayload(state *store.VoiceState) voiceStatePayload {
return voiceStatePayload{
UserID: formatSnowflake(state.UserID),
ChannelID: formatSnowflake(state.ChannelID),
GuildID: formatSnowflake(state.GuildID),
SessionID: state.SessionID,
SelfMute: state.SelfMute,
SelfDeaf: state.SelfDeaf,
ServerMute: state.ServerMute,
ServerDeaf: state.ServerDeaf,
Camera: state.Camera,
Screen: state.Screen,
JoinedAt: state.JoinedAt.UTC().Format(time.RFC3339),
}
}
// dispatchVoiceState рассылает событие VOICE_STATE_UPDATE участникам сервера.
func (s *Server) dispatchVoiceState(ctx context.Context, state *store.VoiceState) {
if s.gateway == nil {
return
}
members, err := s.store.ListGuildMembers(ctx, state.GuildID)
if err != nil {
return
}
payload := s.voiceStatePayload(state)
for _, member := range members {
s.gateway.SendToUser(member.UserID, "VOICE_STATE_UPDATE", payload)
}
}
// dispatchVoiceLeave сообщает об уходе из голосовой комнаты.
func (s *Server) dispatchVoiceLeave(ctx context.Context, guildID, channelID, userID uint64) {
if s.gateway == nil {
return
}
members, err := s.store.ListGuildMembers(ctx, guildID)
if err != nil {
return
}
payload := map[string]any{
"guild_id": formatSnowflake(guildID),
"channel_id": formatSnowflake(channelID),
"user_id": formatSnowflake(userID),
"left": true,
}
for _, member := range members {
s.gateway.SendToUser(member.UserID, "VOICE_STATE_UPDATE", payload)
}
}
+121
View File
@@ -10,10 +10,13 @@ import (
"net/textproto"
"net/url"
"strconv"
"strings"
"testing"
"time"
"glchat/internal/permissions"
"glchat/internal/store"
"glchat/internal/voice"
)
// storeOverride скрывает комнату от роли @user.
@@ -932,3 +935,121 @@ func uploadEmoji(t *testing.T, server *httptest.Server, cookie *http.Cookie, gui
recorder *httptest.ResponseRecorder
}{status: resp.StatusCode, body: string(payload), recorder: recorder}
}
// TestVoiceStateFlow проверяет вход в голосовую комнату, флаги и модерацию.
func TestVoiceStateFlow(t *testing.T) {
f := newMessagingFixture(t)
// Создаём голосовую комнату и добавляем участника.
voiceRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels",
`{"name":"Голосовая","type":"voice","user_limit":2}`, f.ownerCookie)
voiceChannel := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, voiceRec)
// Без ключей LiveKit вход недоступен с понятной ошибкой.
join := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceChannel.Channel.ID+"/voice/join", "", f.memberCookie)
if join.Code != http.StatusServiceUnavailable {
t.Fatalf("join без LiveKit = %d, want 503", join.Code)
}
// Включаем голос: ключи подставляем в конфиг тестового сервера.
f.srv.cfg.LiveKitAPIKey = "testkey"
f.srv.cfg.LiveKitAPISecret = "testsecret"
f.srv.cfg.LiveKitURL = "wss://gl.test/rtc"
f.srv.voice = voice.NewIssuer("testkey", "testsecret", time.Hour)
join = doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceChannel.Channel.ID+"/voice/join", "", f.memberCookie)
if join.Code != http.StatusOK {
t.Fatalf("join = %d, body = %s", join.Code, join.Body.String())
}
payload := decodeResponse[struct {
Token string `json:"token"`
URL string `json:"url"`
Room string `json:"room"`
ChannelID string `json:"channel_id"`
State struct {
UserID string `json:"user_id"`
ChannelID string `json:"channel_id"`
SelfMute bool `json:"self_mute"`
Camera bool `json:"camera"`
} `json:"state"`
}](t, join)
if payload.Token == "" || payload.URL != "wss://gl.test/rtc" {
t.Fatalf("токен/адрес = %q / %q", payload.Token, payload.URL)
}
if payload.State.UserID != f.memberID || payload.State.ChannelID != voiceChannel.Channel.ID {
t.Fatalf("состояние = %+v", payload.State)
}
if !strings.HasPrefix(payload.Room, "guild_") || !strings.Contains(payload.Room, "channel_") {
t.Fatalf("имя комнаты = %q", payload.Room)
}
// Свои флаги: микрофон и камера.
flags := doJSON(t, f.srv, http.MethodPatch, "/api/v1/guilds/"+f.guildID+"/voice-states/@me",
`{"self_mute":true,"camera":true}`, f.memberCookie)
if flags.Code != http.StatusOK {
t.Fatalf("flags = %d, body = %s", flags.Code, flags.Body.String())
}
states := decodeResponse[struct {
States []struct {
UserID string `json:"user_id"`
SelfMute bool `json:"self_mute"`
Camera bool `json:"camera"`
} `json:"voice_states"`
}](t, flags)
if len(states.States) != 1 || !states.States[0].SelfMute || !states.States[0].Camera {
t.Fatalf("states = %+v", states.States)
}
// Модерация: у участника нет MUTE_MEMBERS — серверный мьют запрещён.
denied := doJSON(t, f.srv, http.MethodPatch,
"/api/v1/guilds/"+f.guildID+"/voice-states/"+f.ownerID, `{"server_mute":true}`, f.memberCookie)
if denied.Code != http.StatusForbidden {
t.Fatalf("member mute = %d, want 403", denied.Code)
}
muted := doJSON(t, f.srv, http.MethodPatch,
"/api/v1/guilds/"+f.guildID+"/voice-states/"+f.memberID, `{"server_mute":true}`, f.ownerCookie)
if muted.Code != http.StatusOK {
t.Fatalf("owner mute = %d, body = %s", muted.Code, muted.Body.String())
}
// Перемещение и выход.
secondRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels",
`{"name":"Вторая","type":"voice"}`, f.ownerCookie)
second := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, secondRec)
move := doJSON(t, f.srv, http.MethodPost,
"/api/v1/guilds/"+f.guildID+"/voice-states/"+f.memberID+"/move",
`{"channel_id":"`+second.Channel.ID+`"}`, f.ownerCookie)
if move.Code != http.StatusOK {
t.Fatalf("move = %d, body = %s", move.Code, move.Body.String())
}
moved := decodeResponse[struct {
States []struct {
ChannelID string `json:"channel_id"`
} `json:"voice_states"`
}](t, move)
if len(moved.States) != 1 || moved.States[0].ChannelID != second.Channel.ID {
t.Fatalf("после перемещения = %+v", moved.States)
}
leave := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+second.Channel.ID+"/voice/leave", "", f.memberCookie)
if leave.Code != http.StatusOK {
t.Fatalf("leave = %d, body = %s", leave.Code, leave.Body.String())
}
after := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/voice-states", "", f.ownerCookie)
empty := decodeResponse[struct {
States []struct {
UserID string `json:"user_id"`
} `json:"voice_states"`
}](t, after)
if len(empty.States) != 0 {
t.Fatalf("после выхода = %+v", empty.States)
}
}
+5
View File
@@ -22,6 +22,7 @@ import (
"glchat/internal/permissions"
"glchat/internal/source"
"glchat/internal/store"
"glchat/internal/voice"
)
// Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации.
@@ -59,6 +60,8 @@ type Server struct {
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
slowmodeMu sync.Mutex
slowmode map[string]time.Time
// voice — подписыватель токенов LiveKit (AGENT.md 7.14).
voice *voice.TokenIssuer
// presence — время последнего обновления last_seen по пользователю.
presenceMu sync.Mutex
presence map[uint64]time.Time
@@ -89,6 +92,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, time.Hour),
}
switch {
case deps.Permissions != nil:
@@ -114,6 +118,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerFileRoutes(s.api, apiRouter)
s.registerSocialRoutes(s.api)
s.registerEmojiRoutes(s.api, apiRouter)
s.registerVoiceRoutes(s.api)
}
apiRouter.Get("/openapi.json", s.handleOpenAPI)
})