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:
@@ -24,6 +24,10 @@ x-app-env: &app-env
|
|||||||
GOMAXPROCS: "${GOMAXPROCS}"
|
GOMAXPROCS: "${GOMAXPROCS}"
|
||||||
MAX_UPLOAD_SIZE: "${MAX_UPLOAD_SIZE}"
|
MAX_UPLOAD_SIZE: "${MAX_UPLOAD_SIZE}"
|
||||||
SHUTDOWN_TIMEOUT_SECONDS: "20"
|
SHUTDOWN_TIMEOUT_SECONDS: "20"
|
||||||
|
# LiveKit: приложение выдаёт клиентам токены доступа к комнатам.
|
||||||
|
LIVEKIT_API_KEY: "${LIVEKIT_API_KEY}"
|
||||||
|
LIVEKIT_API_SECRET: "${LIVEKIT_API_SECRET}"
|
||||||
|
LIVEKIT_URL: "${LIVEKIT_PUBLIC_URL}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
|||||||
@@ -355,6 +355,14 @@ resolve_derived_values() {
|
|||||||
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}"
|
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}"
|
||||||
GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}"
|
GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}"
|
||||||
GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}"
|
GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}"
|
||||||
|
# Публичный адрес LiveKit: клиенты ходят через тот же домен по /rtc.
|
||||||
|
if [ -z "${LIVEKIT_PUBLIC_URL:-}" ]; then
|
||||||
|
if [ "${TLS_ENABLED}" = "true" ]; then
|
||||||
|
LIVEKIT_PUBLIC_URL="wss://${DOMAIN}/rtc"
|
||||||
|
else
|
||||||
|
LIVEKIT_PUBLIC_URL="ws://${DOMAIN}/rtc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
# TLS считается включённым и когда сертификаты обслуживает внешний прокси
|
# TLS считается включённым и когда сертификаты обслуживает внешний прокси
|
||||||
# (--external-proxy --tls-terminated-by-proxy): ссылки в API должны быть https/wss.
|
# (--external-proxy --tls-terminated-by-proxy): ссылки в API должны быть https/wss.
|
||||||
if [ "$SKIP_TLS" = "1" ] && [ "$TLS_BY_PROXY" != "true" ]; then
|
if [ "$SKIP_TLS" = "1" ] && [ "$TLS_BY_PROXY" != "true" ]; then
|
||||||
@@ -728,6 +736,8 @@ TURN_EXTERNAL_TLS=@TURN_EXTERNAL_TLS@
|
|||||||
TURN_RELAY_START=@TURN_RELAY_START@
|
TURN_RELAY_START=@TURN_RELAY_START@
|
||||||
TURN_RELAY_END=@TURN_RELAY_END@
|
TURN_RELAY_END=@TURN_RELAY_END@
|
||||||
TURN_RELAY_HOST=@TURN_RELAY_HOST@
|
TURN_RELAY_HOST=@TURN_RELAY_HOST@
|
||||||
|
# Публичный адрес LiveKit для клиентов (через прокси: /rtc).
|
||||||
|
LIVEKIT_PUBLIC_URL=@LIVEKIT_PUBLIC_URL@
|
||||||
TURN_RELAY_PORT=@TURN_RELAY_PORT@
|
TURN_RELAY_PORT=@TURN_RELAY_PORT@
|
||||||
TURN_RELAY_TLS_PORT=@TURN_RELAY_TLS_PORT@
|
TURN_RELAY_TLS_PORT=@TURN_RELAY_TLS_PORT@
|
||||||
|
|
||||||
@@ -828,6 +838,7 @@ TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}
|
|||||||
WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||||
LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
|
LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
|
||||||
LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
|
LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
|
||||||
|
LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}
|
||||||
|
|
||||||
AGE_RECIPIENT=${AGE_RECIPIENT:-}
|
AGE_RECIPIENT=${AGE_RECIPIENT:-}
|
||||||
SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}
|
SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ type Config struct {
|
|||||||
Argon2MemoryKiB int
|
Argon2MemoryKiB int
|
||||||
Argon2Iterations int
|
Argon2Iterations int
|
||||||
Argon2Parallelism int
|
Argon2Parallelism int
|
||||||
|
// LiveKit: голос, видео и шаринг экрана (AGENT.md 7.14).
|
||||||
|
LiveKitAPIKey string
|
||||||
|
LiveKitAPISecret string
|
||||||
|
LiveKitURL string
|
||||||
Version string
|
Version string
|
||||||
Commit string
|
Commit string
|
||||||
BuildDate string
|
BuildDate string
|
||||||
@@ -67,6 +71,9 @@ func Load() (Config, error) {
|
|||||||
Argon2MemoryKiB: envInt("ARGON2_MEMORY_KIB", 19456),
|
Argon2MemoryKiB: envInt("ARGON2_MEMORY_KIB", 19456),
|
||||||
Argon2Iterations: envInt("ARGON2_ITERATIONS", 2),
|
Argon2Iterations: envInt("ARGON2_ITERATIONS", 2),
|
||||||
Argon2Parallelism: envInt("ARGON2_PARALLELISM", 1),
|
Argon2Parallelism: envInt("ARGON2_PARALLELISM", 1),
|
||||||
|
LiveKitAPIKey: env("LIVEKIT_API_KEY", ""),
|
||||||
|
LiveKitAPISecret: env("LIVEKIT_API_SECRET", ""),
|
||||||
|
LiveKitURL: env("LIVEKIT_URL", ""),
|
||||||
Version: env("APP_VERSION", "dev"),
|
Version: env("APP_VERSION", "dev"),
|
||||||
Commit: env("APP_COMMIT", "none"),
|
Commit: env("APP_COMMIT", "none"),
|
||||||
BuildDate: env("APP_BUILD_DATE", "unknown"),
|
BuildDate: env("APP_BUILD_DATE", "unknown"),
|
||||||
@@ -113,6 +120,11 @@ func (c Config) AllowedOrigins() []string {
|
|||||||
return origins
|
return origins
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VoiceEnabled сообщает, настроен ли LiveKit: без ключей голос выключен.
|
||||||
|
func (c Config) VoiceEnabled() bool {
|
||||||
|
return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain }
|
func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain }
|
||||||
func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain }
|
func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Голосовые состояния: кто в какой голосовой комнате и с какими флагами
|
||||||
|
-- (AGENT.md 7.14). Хранится по одной записи на пользователя в сервере.
|
||||||
|
CREATE TABLE voice_states (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE,
|
||||||
|
channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||||
|
session_id TEXT NOT NULL DEFAULT '',
|
||||||
|
self_mute INTEGER NOT NULL DEFAULT 0,
|
||||||
|
self_deaf INTEGER NOT NULL DEFAULT 0,
|
||||||
|
server_mute INTEGER NOT NULL DEFAULT 0,
|
||||||
|
server_deaf INTEGER NOT NULL DEFAULT 0,
|
||||||
|
camera INTEGER NOT NULL DEFAULT 0,
|
||||||
|
screen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
PRIMARY KEY (user_id, guild_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX voice_states_channel_idx ON voice_states (channel_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE voice_states;
|
||||||
@@ -79,6 +79,20 @@ type ReadyGuild struct {
|
|||||||
MyPerms []string `json:"my_permissions"`
|
MyPerms []string `json:"my_permissions"`
|
||||||
// Emojis — кастомные эмодзи сервера (AGENT.md 7.12).
|
// Emojis — кастомные эмодзи сервера (AGENT.md 7.12).
|
||||||
Emojis []ReadyEmoji `json:"emojis"`
|
Emojis []ReadyEmoji `json:"emojis"`
|
||||||
|
// VoiceStates — кто находится в голосовых комнатах (AGENT.md 7.14).
|
||||||
|
VoiceStates []ReadyVoiceState `json:"voice_states"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadyVoiceState — участник голосовой комнаты в снапшоте.
|
||||||
|
type ReadyVoiceState struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadyEmoji — эмодзи сервера в снапшоте: имя, файл и токен для вставки.
|
// ReadyEmoji — эмодзи сервера в снапшоте: имя, файл и токен для вставки.
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor
|
|||||||
MyRoles: []string{},
|
MyRoles: []string{},
|
||||||
MyPerms: []string{},
|
MyPerms: []string{},
|
||||||
Emojis: []ReadyEmoji{},
|
Emojis: []ReadyEmoji{},
|
||||||
|
VoiceStates: []ReadyVoiceState{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кастомные эмодзи сервера: клиент показывает их в пикере и в тексте.
|
// Кастомные эмодзи сервера: клиент показывает их в пикере и в тексте.
|
||||||
@@ -159,6 +160,24 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кто сейчас в голосовых комнатах сервера.
|
||||||
|
voiceStates, err := s.store.ListVoiceStates(ctx, guild.ID)
|
||||||
|
if err != nil {
|
||||||
|
return readyGuild, err
|
||||||
|
}
|
||||||
|
for _, state := range voiceStates {
|
||||||
|
readyGuild.VoiceStates = append(readyGuild.VoiceStates, ReadyVoiceState{
|
||||||
|
UserID: formatID(state.UserID),
|
||||||
|
ChannelID: formatID(state.ChannelID),
|
||||||
|
SelfMute: state.SelfMute,
|
||||||
|
SelfDeaf: state.SelfDeaf,
|
||||||
|
ServerMute: state.ServerMute,
|
||||||
|
ServerDeaf: state.ServerDeaf,
|
||||||
|
Camera: state.Camera,
|
||||||
|
Screen: state.Screen,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
roles, err := s.store.ListGuildRoles(ctx, guild.ID)
|
roles, err := s.store.ListGuildRoles(ctx, guild.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return readyGuild, err
|
return readyGuild, err
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ type Features struct {
|
|||||||
RegistrationEnabled bool `json:"registration_enabled"`
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
AntiBotEnabled bool `json:"anti_bot_enabled"`
|
AntiBotEnabled bool `json:"anti_bot_enabled"`
|
||||||
VoiceEnabled bool `json:"voice_enabled"`
|
VoiceEnabled bool `json:"voice_enabled"`
|
||||||
|
// VoiceURL — адрес LiveKit для клиента (ws/wss), пусто при выключенном голосе.
|
||||||
|
VoiceURL string `json:"voice_url,omitempty"`
|
||||||
WebPushEnabled bool `json:"web_push_enabled"`
|
WebPushEnabled bool `json:"web_push_enabled"`
|
||||||
OAuthEnabled bool `json:"oauth_enabled"`
|
OAuthEnabled bool `json:"oauth_enabled"`
|
||||||
PasskeysEnabled bool `json:"passkeys_enabled"`
|
PasskeysEnabled bool `json:"passkeys_enabled"`
|
||||||
@@ -42,7 +44,8 @@ func New(cfg config.Config) Response {
|
|||||||
Features: Features{
|
Features: Features{
|
||||||
RegistrationEnabled: true,
|
RegistrationEnabled: true,
|
||||||
AntiBotEnabled: false,
|
AntiBotEnabled: false,
|
||||||
VoiceEnabled: false,
|
VoiceEnabled: cfg.VoiceEnabled(),
|
||||||
|
VoiceURL: cfg.LiveKitURL,
|
||||||
WebPushEnabled: false,
|
WebPushEnabled: false,
|
||||||
OAuthEnabled: false,
|
OAuthEnabled: false,
|
||||||
PasskeysEnabled: false,
|
PasskeysEnabled: false,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,13 @@ import (
|
|||||||
"net/textproto"
|
"net/textproto"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"glchat/internal/permissions"
|
"glchat/internal/permissions"
|
||||||
"glchat/internal/store"
|
"glchat/internal/store"
|
||||||
|
"glchat/internal/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
// storeOverride скрывает комнату от роли @user.
|
// storeOverride скрывает комнату от роли @user.
|
||||||
@@ -932,3 +935,121 @@ func uploadEmoji(t *testing.T, server *httptest.Server, cookie *http.Cookie, gui
|
|||||||
recorder *httptest.ResponseRecorder
|
recorder *httptest.ResponseRecorder
|
||||||
}{status: resp.StatusCode, body: string(payload), recorder: recorder}
|
}{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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"glchat/internal/permissions"
|
"glchat/internal/permissions"
|
||||||
"glchat/internal/source"
|
"glchat/internal/source"
|
||||||
"glchat/internal/store"
|
"glchat/internal/store"
|
||||||
|
"glchat/internal/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации.
|
// Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации.
|
||||||
@@ -59,6 +60,8 @@ type Server struct {
|
|||||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||||
slowmodeMu sync.Mutex
|
slowmodeMu sync.Mutex
|
||||||
slowmode map[string]time.Time
|
slowmode map[string]time.Time
|
||||||
|
// voice — подписыватель токенов LiveKit (AGENT.md 7.14).
|
||||||
|
voice *voice.TokenIssuer
|
||||||
// presence — время последнего обновления last_seen по пользователю.
|
// presence — время последнего обновления last_seen по пользователю.
|
||||||
presenceMu sync.Mutex
|
presenceMu sync.Mutex
|
||||||
presence map[uint64]time.Time
|
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),
|
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
||||||
slowmode: map[string]time.Time{},
|
slowmode: map[string]time.Time{},
|
||||||
presence: map[uint64]time.Time{},
|
presence: map[uint64]time.Time{},
|
||||||
|
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, time.Hour),
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case deps.Permissions != nil:
|
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.registerFileRoutes(s.api, apiRouter)
|
||||||
s.registerSocialRoutes(s.api)
|
s.registerSocialRoutes(s.api)
|
||||||
s.registerEmojiRoutes(s.api, apiRouter)
|
s.registerEmojiRoutes(s.api, apiRouter)
|
||||||
|
s.registerVoiceRoutes(s.api)
|
||||||
}
|
}
|
||||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Package voice выдаёт токены доступа к LiveKit и хранит голосовые состояния
|
||||||
|
// (AGENT.md 7.14): собственный JWT-подписыватель без внешних зависимостей.
|
||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrDisabled — LiveKit не настроен: ключи не заданы.
|
||||||
|
var ErrDisabled = errors.New("voice.disabled")
|
||||||
|
|
||||||
|
// TokenIssuer подписывает токены доступа LiveKit (HS256).
|
||||||
|
type TokenIssuer struct {
|
||||||
|
apiKey string
|
||||||
|
apiSecret string
|
||||||
|
ttl time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIssuer создаёт подписывателя токенов.
|
||||||
|
func NewIssuer(apiKey, apiSecret string, ttl time.Duration) *TokenIssuer {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = time.Hour
|
||||||
|
}
|
||||||
|
return &TokenIssuer{apiKey: apiKey, apiSecret: apiSecret, ttl: ttl, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClock подменяет источник времени (тесты).
|
||||||
|
func (i *TokenIssuer) SetClock(now func() time.Time) { i.now = now }
|
||||||
|
|
||||||
|
// Enabled сообщает, настроен ли LiveKit.
|
||||||
|
func (i *TokenIssuer) Enabled() bool {
|
||||||
|
return i != nil && i.apiKey != "" && i.apiSecret != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grants — права участника комнаты LiveKit.
|
||||||
|
type Grants struct {
|
||||||
|
RoomJoin bool `json:"roomJoin"`
|
||||||
|
CanPublish bool `json:"canPublish"`
|
||||||
|
CanSubscribe bool `json:"canSubscribe"`
|
||||||
|
CanPublishData bool `json:"canPublishData"`
|
||||||
|
CanPublishAudio bool `json:"canPublishSources,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claims — полезная нагрузка токена LiveKit.
|
||||||
|
type Claims struct {
|
||||||
|
Issuer string `json:"iss"`
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Metadata string `json:"metadata,omitempty"`
|
||||||
|
Video struct {
|
||||||
|
Room string `json:"room"`
|
||||||
|
RoomJoin bool `json:"roomJoin"`
|
||||||
|
CanPublish bool `json:"canPublish"`
|
||||||
|
CanSubscribe bool `json:"canSubscribe"`
|
||||||
|
CanPublishData bool `json:"canPublishData"`
|
||||||
|
RoomAdmin bool `json:"roomAdmin,omitempty"`
|
||||||
|
Hidden bool `json:"hidden,omitempty"`
|
||||||
|
Recorder bool `json:"recorder,omitempty"`
|
||||||
|
} `json:"video"`
|
||||||
|
IssuedAt int64 `json:"iat"`
|
||||||
|
NotBefore int64 `json:"nbf"`
|
||||||
|
ExpiresAt int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue собирает и подписывает токен доступа к комнате.
|
||||||
|
func (i *TokenIssuer) Issue(room, identity, displayName string, grants Grants) (string, error) {
|
||||||
|
if !i.Enabled() {
|
||||||
|
return "", ErrDisabled
|
||||||
|
}
|
||||||
|
now := i.now().UTC()
|
||||||
|
claims := Claims{Issuer: i.apiKey, Subject: identity, Name: displayName}
|
||||||
|
claims.Video.Room = room
|
||||||
|
claims.Video.RoomJoin = grants.RoomJoin
|
||||||
|
claims.Video.CanPublish = grants.CanPublish
|
||||||
|
claims.Video.CanSubscribe = grants.CanSubscribe
|
||||||
|
claims.Video.CanPublishData = grants.CanPublishData
|
||||||
|
claims.IssuedAt = now.Unix()
|
||||||
|
claims.NotBefore = now.Add(-10 * time.Second).Unix()
|
||||||
|
claims.ExpiresAt = now.Add(i.ttl).Unix()
|
||||||
|
|
||||||
|
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(claims)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
signingInput := encodeSegment(header) + "." + encodeSegment(payload)
|
||||||
|
mac := hmac.New(sha256.New, []byte(i.apiSecret))
|
||||||
|
if _, err := mac.Write([]byte(signingInput)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomName собирает имя комнаты LiveKit: комнаты инстанса не пересекаются.
|
||||||
|
func RoomName(guildID, channelID uint64) string {
|
||||||
|
return fmt.Sprintf("guild_%d_channel_%d", guildID, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRoomName разбирает имя комнаты, если нужно понять, куда подключён клиент.
|
||||||
|
func ParseRoomName(room string) (guildID, channelID uint64, ok bool) {
|
||||||
|
if _, err := fmt.Sscanf(room, "guild_%d_channel_%d", &guildID, &channelID); err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return guildID, channelID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeSegment(payload []byte) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveLiveKitURL приводит адрес к виду, понятному браузеру: /rtc проксируется
|
||||||
|
// тем же доменом, поэтому оставляем как есть, но проверяем схему.
|
||||||
|
func ResolveLiveKitURL(raw string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", ErrDisabled
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(trimmed, "ws://") && !strings.HasPrefix(trimmed, "wss://") {
|
||||||
|
return "", fmt.Errorf("voice.invalid_url: %s", trimmed)
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(trimmed, "/"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoiceURLForClient отдаёт адрес LiveKit так, как его должен использовать
|
||||||
|
// клиент: пусто, если голос не настроен.
|
||||||
|
func (i *TokenIssuer) VoiceURLForClient(publicURL string) string {
|
||||||
|
if !i.Enabled() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
resolved, err := ResolveLiveKitURL(publicURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package voice_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"glchat/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueTokenHasLiveKitClaims(t *testing.T) {
|
||||||
|
issuer := voice.NewIssuer("key123", "secret456", time.Hour)
|
||||||
|
issuer.SetClock(func() time.Time { return time.Unix(1_700_000_000, 0) })
|
||||||
|
|
||||||
|
token, err := issuer.Issue(voice.RoomName(10, 20), "42", "Алиса", voice.Grants{
|
||||||
|
RoomJoin: true, CanPublish: true, CanSubscribe: true, CanPublishData: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("JWT состоит из %d частей, ожидалось 3", len(parts))
|
||||||
|
}
|
||||||
|
header, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode header: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(header), "HS256") {
|
||||||
|
t.Fatalf("header = %s", header)
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
Issuer string `json:"iss"`
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Video struct {
|
||||||
|
Room string `json:"room"`
|
||||||
|
RoomJoin bool `json:"roomJoin"`
|
||||||
|
CanPublish bool `json:"canPublish"`
|
||||||
|
} `json:"video"`
|
||||||
|
ExpiresAt int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
t.Fatalf("decode claims: %v", err)
|
||||||
|
}
|
||||||
|
if claims.Issuer != "key123" || claims.Subject != "42" || claims.Name != "Алиса" {
|
||||||
|
t.Fatalf("claims = %+v", claims)
|
||||||
|
}
|
||||||
|
if claims.Video.Room != "guild_10_channel_20" || !claims.Video.RoomJoin || !claims.Video.CanPublish {
|
||||||
|
t.Fatalf("video grants = %+v", claims.Video)
|
||||||
|
}
|
||||||
|
if claims.ExpiresAt != 1_700_000_000+3600 {
|
||||||
|
t.Fatalf("exp = %d", claims.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerDisabledWithoutKeys(t *testing.T) {
|
||||||
|
issuer := voice.NewIssuer("", "", time.Hour)
|
||||||
|
if issuer.Enabled() {
|
||||||
|
t.Fatal("пустые ключи должны означать выключенный голос")
|
||||||
|
}
|
||||||
|
if _, err := issuer.Issue("room", "1", "user", voice.Grants{}); err == nil {
|
||||||
|
t.Fatal("Issue без ключей должен возвращать ошибку")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRoomName(t *testing.T) {
|
||||||
|
guildID, channelID, ok := voice.ParseRoomName("guild_123_channel_456")
|
||||||
|
if !ok || guildID != 123 || channelID != 456 {
|
||||||
|
t.Fatalf("ParseRoomName = %d/%d/%t", guildID, channelID, ok)
|
||||||
|
}
|
||||||
|
if _, _, ok := voice.ParseRoomName("что-то другое"); ok {
|
||||||
|
t.Fatal("посторонняя строка не должна разбираться как имя комнаты")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLiveKitURL(t *testing.T) {
|
||||||
|
url, err := voice.ResolveLiveKitURL("wss://gl.example/rtc/")
|
||||||
|
if err != nil || url != "wss://gl.example/rtc" {
|
||||||
|
t.Fatalf("ResolveLiveKitURL = %q, %v", url, err)
|
||||||
|
}
|
||||||
|
if _, err := voice.ResolveLiveKitURL("http://gl.example"); err == nil {
|
||||||
|
t.Fatal("http-адрес должен отклоняться")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user