From ecd7b3be10cb935303f4c561c2288803ea730f7e Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 18:38:54 +0300 Subject: [PATCH] =?UTF-8?q?fix(install):=20LiveKit=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8?= =?UTF-8?q?=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveKit читает livekit.yaml только при старте, а compose не замечает правок в примонтированном файле — из-за этого вебхуки не включались после обновления. Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис. --- deploy/install.sh | 25 + web/src/api/gateway.ts | 5 + web/src/api/voice.ts | 182 ++++++ web/src/components/voice/RemoteAudio.tsx | 79 +++ web/src/components/voice/VoiceChannelItem.tsx | 175 ++++++ web/src/components/voice/VoiceIndicators.tsx | 110 ++++ .../components/voice/VoiceOccupantMenu.tsx | 102 +++ web/src/components/voice/VoiceOccupantRow.tsx | 100 +++ web/src/components/voice/VoiceRoomPanel.tsx | 370 +++++++++++ .../components/voice/VoiceSpeakingNotice.tsx | 41 ++ .../components/voice/useGuildVoiceStates.ts | 31 + web/src/components/voice/useVoiceEnabled.ts | 20 + web/src/components/voice/useVoiceOccupants.ts | 158 +++++ web/src/components/voice/voiceDrag.ts | 20 + web/src/i18n/locales/en.json | 65 +- web/src/i18n/locales/ru.json | 65 +- web/src/lib/identity.ts | 53 +- web/src/lib/livekit.ts | 248 ++++++++ web/src/lib/useMediaDevices.ts | 12 +- web/src/pages/app/AppLayout.tsx | 3 + web/src/pages/app/ChannelSidebar.tsx | 19 +- web/src/pages/app/GuildView.tsx | 13 +- web/src/pages/app/UserPanel.tsx | 58 +- web/src/stores/gateway.ts | 19 + web/src/stores/session.ts | 85 +++ web/src/stores/voice.ts | 35 +- web/src/stores/voiceConnection.ts | 581 ++++++++++++++++++ 27 files changed, 2637 insertions(+), 37 deletions(-) create mode 100644 web/src/api/voice.ts create mode 100644 web/src/components/voice/RemoteAudio.tsx create mode 100644 web/src/components/voice/VoiceChannelItem.tsx create mode 100644 web/src/components/voice/VoiceIndicators.tsx create mode 100644 web/src/components/voice/VoiceOccupantMenu.tsx create mode 100644 web/src/components/voice/VoiceOccupantRow.tsx create mode 100644 web/src/components/voice/VoiceRoomPanel.tsx create mode 100644 web/src/components/voice/VoiceSpeakingNotice.tsx create mode 100644 web/src/components/voice/useGuildVoiceStates.ts create mode 100644 web/src/components/voice/useVoiceEnabled.ts create mode 100644 web/src/components/voice/useVoiceOccupants.ts create mode 100644 web/src/components/voice/voiceDrag.ts create mode 100644 web/src/lib/livekit.ts create mode 100644 web/src/stores/voiceConnection.ts diff --git a/deploy/install.sh b/deploy/install.sh index 8e5e091..2f1b848 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1491,6 +1491,26 @@ build_or_pull_image() { ok "образ собран: $(docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' "$APP_IMAGE" | head -1)" } +# restart_livekit_if_config_changed сравнивает хэш livekit.yaml с сохранённым и +# перезапускает контейнер: иначе новые настройки (например вебхуки) не применятся. +restart_livekit_if_config_changed() { + local config="${TARGET_DIR}/livekit/livekit.yaml" + local marker="${TARGET_DIR}/.livekit-config.sha256" + [ -f "$config" ] || return 0 + local hash + hash="$(sha256sum "$config" | awk '{print $1}')" + if [ -f "$marker" ] && [ "$(cat "$marker")" = "$hash" ]; then + return 0 + fi + info "конфиг LiveKit изменился — перезапускаем сервис" + if (cd "$TARGET_DIR" && docker compose -f compose.yaml restart livekit >/dev/null 2>&1); then + printf '%s' "$hash" >"$marker" + ok "LiveKit перезапущен с новым конфигом" + else + warn "не удалось перезапустить LiveKit: проверьте вручную (glchat restart livekit)" + fi +} + start_stack() { if [ "$SKIP_IMAGE" = "1" ]; then return 0 @@ -1504,6 +1524,11 @@ start_stack() { [ "$PROXY_MODE" = "embedded" ] && compose_args+=(--profile embedded-proxy) (cd "$TARGET_DIR" && docker compose "${compose_args[@]}" pull --quiet 2>/dev/null || true) (cd "$TARGET_DIR" && docker compose "${compose_args[@]}" up -d --remove-orphans) || die "не удалось запустить стек" + + # LiveKit читает конфиг только при старте, а compose не видит изменений в + # примонтированном файле: перезапускаем сервис, если конфиг изменился. + restart_livekit_if_config_changed + info "ожидаем готовности приложения" local waited=0 until curl -fsS "http://127.0.0.1:${APP_PORT}/readyz" >/dev/null 2>&1; do diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts index 5923a5e..edd1eb6 100644 --- a/web/src/api/gateway.ts +++ b/web/src/api/gateway.ts @@ -1,8 +1,10 @@ import { parseGuildEmojiList } from './emojis'; import { parseChannelPayload } from './gatewayEvents'; +import { parseVoiceStateList } from './voice'; import { describeUnknown } from '@/lib/http'; import type { GuildEmoji } from './emojis'; +import type { VoiceState } from './voice'; import type { Channel, Role, UserStatus } from './types'; /** @@ -47,6 +49,8 @@ export interface GatewayGuild { roles: Role[]; /** Кастомные эмодзи сервера (AGENT.md 7.12). */ emojis: GuildEmoji[]; + /** Кто из участников в голосовых комнатах (AGENT.md 7.14). */ + voice_states: VoiceState[]; member_ids: string[]; my_role_ids: string[]; my_nickname?: string; @@ -236,6 +240,7 @@ function parseGuild(value: unknown): GatewayGuild | null { channels, roles, emojis: parseGuildEmojiList(record['emojis']), + voice_states: parseVoiceStateList(record['voice_states']), member_ids: asStringArray(record['member_ids']), my_role_ids: asStringArray(record['my_role_ids']), my_permissions: asStringArray(record['my_permissions']), diff --git a/web/src/api/voice.ts b/web/src/api/voice.ts new file mode 100644 index 0000000..8196306 --- /dev/null +++ b/web/src/api/voice.ts @@ -0,0 +1,182 @@ +import { request } from './client'; + +/** + * Голосовые комнаты (AGENT.md §7.14): вход/выход, флаги участника, модерация + * и перемещение. Медиа живёт в LiveKit, а здесь — только REST-состояние + * `voice_states`, которое сервер рассылает ещё и событием `VOICE_STATE_UPDATE`. + */ + +/** Голосовое состояние участника (`voice_states` из READY и событий). */ +export interface VoiceState { + user_id: string; + channel_id: string; + guild_id: string; + session_id?: string; + self_mute: boolean; + self_deaf: boolean; + server_mute: boolean; + server_deaf: boolean; + camera: boolean; + screen: boolean; + joined_at: string; +} + +/** Ответ `POST /channels/{id}/voice/join`: токен и адрес LiveKit. */ +export interface VoiceJoinResult { + token: string; + url: string; + room: string; + channel_id: string; + guild_id: string; + state: VoiceState; +} + +/** Тело `PATCH /guilds/{id}/voice-states/@me`. */ +export interface VoiceSelfPatch { + self_mute?: boolean; + self_deaf?: boolean; + camera?: boolean; + screen?: boolean; +} + +/** Тело `PATCH /guilds/{id}/voice-states/{user_id}` (права модератора). */ +export interface VoiceModerationPatch { + server_mute?: boolean; + server_deaf?: boolean; +} + +/** Разобранное событие `VOICE_STATE_UPDATE`: состояние или уход из комнаты. */ +export type VoiceStateEvent = + | { kind: 'state'; state: VoiceState } + | { kind: 'left'; guildId: string; userId: string }; + +export const guildVoiceStatesQueryKey = (guildId: string) => + ['guilds', guildId, 'voice-states'] as const; + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** Разбирает голосовое состояние, отбрасывая кадры не по контракту. */ +export function parseVoiceState(value: unknown): VoiceState | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const userId = asString(record['user_id']); + const channelId = asString(record['channel_id']); + if (userId === undefined || channelId === undefined) { + return null; + } + const state: VoiceState = { + user_id: userId, + channel_id: channelId, + guild_id: asString(record['guild_id']) ?? '', + self_mute: record['self_mute'] === true, + self_deaf: record['self_deaf'] === true, + server_mute: record['server_mute'] === true, + server_deaf: record['server_deaf'] === true, + camera: record['camera'] === true, + screen: record['screen'] === true, + joined_at: asString(record['joined_at']) ?? '', + }; + const sessionId = asString(record['session_id']); + if (sessionId !== undefined) { + state.session_id = sessionId; + } + return state; +} + +/** Список голосовых состояний из ответа REST или снапшота READY. */ +export function parseVoiceStateList(value: unknown): VoiceState[] { + return Array.isArray(value) + ? value.map(parseVoiceState).filter((item): item is VoiceState => item !== null) + : []; +} + +/** + * `VOICE_STATE_UPDATE`: либо элемент `voice_states`, либо `{left: true}` + * с идентификаторами сервера, комнаты и участника. + */ +export function parseVoiceStateEvent(value: unknown): VoiceStateEvent | null { + const record = asRecord(value); + if (record === null) { + return null; + } + if (record['left'] === true) { + const guildId = asString(record['guild_id']); + const userId = asString(record['user_id']); + if (guildId === undefined || userId === undefined) { + return null; + } + return { kind: 'left', guildId, userId }; + } + const state = parseVoiceState(value); + return state === null ? null : { kind: 'state', state }; +} + +/** Вход в голосовую комнату: сервер выдаёт токен и записывает состояние. */ +export function joinVoiceChannel(channelId: string): Promise { + return request(`/channels/${encodeURIComponent(channelId)}/voice/join`, { + method: 'POST', + }); +} + +export function leaveVoiceChannel(channelId: string): Promise<{ ok: true }> { + return request<{ ok: true }>(`/channels/${encodeURIComponent(channelId)}/voice/leave`, { + method: 'POST', + }); +} + +/** Свои флаги: микрофон, наушники, камера, демонстрация экрана. */ +export async function updateMyVoiceState( + guildId: string, + patch: VoiceSelfPatch, +): Promise { + const payload = await request<{ voice_states: unknown }>( + `/guilds/${encodeURIComponent(guildId)}/voice-states/@me`, + { method: 'PATCH', body: patch }, + ); + return parseVoiceStateList(payload.voice_states); +} + +export async function fetchVoiceStates( + guildId: string, + signal?: AbortSignal, +): Promise { + const payload = await request<{ voice_states: unknown }>( + `/guilds/${encodeURIComponent(guildId)}/voice-states`, + signal === undefined ? {} : { signal }, + ); + return parseVoiceStateList(payload.voice_states); +} + +/** Серверный мьют и глушение участника: права `MUTE_MEMBERS` / `DEAFEN_MEMBERS`. */ +export async function moderateVoiceState( + guildId: string, + userId: string, + patch: VoiceModerationPatch, +): Promise { + const payload = await request<{ voice_states: unknown }>( + `/guilds/${encodeURIComponent(guildId)}/voice-states/${encodeURIComponent(userId)}`, + { method: 'PATCH', body: patch }, + ); + return parseVoiceStateList(payload.voice_states); +} + +/** Перемещение участника в другую голосовую комнату: право `MOVE_MEMBERS`. */ +export async function moveVoiceMember( + guildId: string, + userId: string, + channelId: string, +): Promise { + const payload = await request<{ voice_states: unknown }>( + `/guilds/${encodeURIComponent(guildId)}/voice-states/${encodeURIComponent(userId)}/move`, + { method: 'POST', body: { channel_id: channelId } }, + ); + return parseVoiceStateList(payload.voice_states); +} diff --git a/web/src/components/voice/RemoteAudio.tsx b/web/src/components/voice/RemoteAudio.tsx new file mode 100644 index 0000000..d3ffa98 --- /dev/null +++ b/web/src/components/voice/RemoteAudio.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react'; + +import type { VoiceTrackHandle } from '@/lib/livekit'; + +/** + * Аудио удалённого участника: дорожка подключается к своему элементу, а + * громкость и глушение применяются к нему же (AGENT.md 7.14). + */ +export function RemoteAudio({ + track, + volume, + muted, +}: { + track: VoiceTrackHandle; + /** Громкость 0–100 (личная настройка участника). */ + volume: number; + /** Локальное глушение: звук выключен целиком. */ + muted: boolean; +}) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current; + if (element === null) { + return; + } + track.attach(element); + return () => { + track.detach(element); + }; + }, [track]); + + useEffect(() => { + const element = ref.current; + if (element === null) { + return; + } + element.volume = Math.min(1, Math.max(0, volume / 100)); + element.muted = muted; + }, [volume, muted]); + + return