Files
glchat/internal/voice/admin.go
T
grendervill 4ba449670a fix(voice): читать ответ GetParticipant в формате LiveKit 1.x
RoomService отвечает объектом участника на верхнем уровне, а не в обёртке
`{"participant": …}`: дорожка микрофона не находилась и серверный мьют молча
пропускался (AGENT.md 7.14). Обёртку тоже принимаем — на случай смены формы
ответа в новых версиях.

- `participantEnvelope` разбирает оба варианта;
- тесты `internal/voice/admin_test.go`: выбор дорожки микрофона (не камеры),
  тело MutePublishedTrack, отсутствие микрофона, обёрнутый ответ,
  RemoveParticipant и выключенный клиент; в каждом вызове проверяется
  Bearer-токен, привязанный к комнате.
2026-09-20 19:59:04 +03:00

155 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package voice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// AdminClient обращается к RoomService LiveKit (Twirp/JSON) для модерации:
// серверный мьют дорожки и удаление участника из комнаты (AGENT.md 7.14).
// Собственный HTTP-клиент вместо SDK: нужны три метода из всего API.
type AdminClient struct {
issuer *TokenIssuer
baseURL string
http *http.Client
}
// NewAdminClient создаёт клиента RoomService. Пустой baseURL означает, что
// модерация на стороне SFU недоступна (например, в тестах).
func NewAdminClient(issuer *TokenIssuer, baseURL string) *AdminClient {
return &AdminClient{
issuer: issuer,
baseURL: strings.TrimSuffix(strings.TrimSpace(baseURL), "/"),
http: &http.Client{Timeout: 5 * time.Second},
}
}
// Enabled сообщает, настроен ли доступ к RoomService.
func (c *AdminClient) Enabled() bool {
return c != nil && c.baseURL != "" && c.issuer.Enabled()
}
// Participant — участник комнаты на стороне SFU.
type Participant struct {
Identity string `json:"identity"`
SID string `json:"sid"`
Tracks []struct {
SID string `json:"sid"`
Type string `json:"type"`
Source string `json:"source"`
Muted bool `json:"muted"`
Name string `json:"name"`
} `json:"tracks"`
}
// participantEnvelope принимает оба формата ответа RoomService: LiveKit 1.x
// отдаёт объект участника на верхнем уровне (`GetParticipant`), а обёртку
// `{"participant": {...}}` поддерживаем на случай смены формы в новых версиях.
type participantEnvelope struct {
Participant
Wrapped *Participant `json:"participant"`
}
// ParticipantInfo отдаёт данные участника комнаты.
func (c *AdminClient) ParticipantInfo(ctx context.Context, room, identity string) (*Participant, error) {
var envelope participantEnvelope
err := c.call(ctx, room, "GetParticipant", map[string]any{"room": room, "identity": identity}, &envelope)
if err != nil {
return nil, err
}
if envelope.Wrapped != nil && envelope.Identity == "" && len(envelope.Tracks) == 0 {
return envelope.Wrapped, nil
}
participant := envelope.Participant
return &participant, nil
}
// SetMicrophoneMuted включает или выключает микрофон участника на стороне SFU:
// клиент физически не может обойти серверный мьют (AGENT.md 7.14).
func (c *AdminClient) SetMicrophoneMuted(ctx context.Context, room, identity string, muted bool) error {
participant, err := c.ParticipantInfo(ctx, room, identity)
if err != nil {
return err
}
trackSID := ""
for _, track := range participant.Tracks {
if strings.EqualFold(track.Source, "MICROPHONE") || strings.EqualFold(track.Type, "AUDIO") {
trackSID = track.SID
break
}
}
if trackSID == "" {
// Участник без микрофона: мьютить нечего, это не ошибка.
return nil
}
return c.call(ctx, room, "MutePublishedTrack", map[string]any{
"room": room, "identity": identity, "track_sid": trackSID, "muted": muted,
}, nil)
}
// RemoveParticipant удаляет участника из комнаты (кик из голосовой).
func (c *AdminClient) RemoveParticipant(ctx context.Context, room, identity string) error {
return c.call(ctx, room, "RemoveParticipant", map[string]any{"room": room, "identity": identity}, nil)
}
// ListParticipants перечисляет участников комнаты: используется для проверки
// расхождений между БД и SFU.
func (c *AdminClient) ListParticipants(ctx context.Context, room string) ([]Participant, error) {
var response struct {
Participants []Participant `json:"participants"`
}
if err := c.call(ctx, room, "ListParticipants", map[string]any{"room": room}, &response); err != nil {
return nil, err
}
return response.Participants, nil
}
// call выполняет запрос к Twirp-методу RoomService с административным токеном
// этой комнаты: LiveKit принимает `roomAdmin` только вместе с именем комнаты.
func (c *AdminClient) call(ctx context.Context, room, method string, body any, out any) error {
if !c.Enabled() {
return ErrDisabled
}
payload, err := json.Marshal(body)
if err != nil {
return err
}
token, err := c.issuer.IssueAdmin(room)
if err != nil {
return err
}
endpoint := fmt.Sprintf("%s/twirp/livekit.RoomService/%s", c.baseURL, method)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+token)
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("voice.api_unavailable: %w", err)
}
defer func() { _ = response.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return err
}
if response.StatusCode != http.StatusOK {
return fmt.Errorf("voice.api_error: %s: %s", response.Status, strings.TrimSpace(string(raw)))
}
if out == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("voice.api_decode: %w", err)
}
return nil
}