fix(install): LiveKit перезапускается при изменении конфига
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в примонтированном файле — из-за этого вебхуки не включались после обновления. Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
This commit is contained in:
+51
-2
@@ -44,6 +44,10 @@ export const PERMISSION_MANAGE_EMOJIS = 'MANAGE_EMOJIS';
|
||||
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
|
||||
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
|
||||
export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS';
|
||||
export const PERMISSION_CONNECT_VOICE = 'CONNECT_VOICE';
|
||||
export const PERMISSION_MUTE_MEMBERS = 'MUTE_MEMBERS';
|
||||
export const PERMISSION_DEAFEN_MEMBERS = 'DEAFEN_MEMBERS';
|
||||
export const PERMISSION_MOVE_MEMBERS = 'MOVE_MEMBERS';
|
||||
|
||||
/** Есть ли у пользователя право с указанным именем. */
|
||||
export function hasPermission(permissions: readonly string[], name: string): boolean {
|
||||
@@ -99,6 +103,52 @@ export function canAdministerGuild(
|
||||
return isInstanceAdmin || canManageGuildSettings(permissions, ownerId, userId);
|
||||
}
|
||||
|
||||
/** Права модератора голосовых комнат: что доступно пользователю на сервере. */
|
||||
export interface VoiceModerationPermissions {
|
||||
mute: boolean;
|
||||
deafen: boolean;
|
||||
move: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Права на голосовую модерацию (AGENT.md 7.14): серверный мьют, глушение и
|
||||
* перемещение. Администратор инстанса видит все пункты, как и в остальном UI.
|
||||
*/
|
||||
export function voiceModerationPermissions(
|
||||
permissions: readonly string[],
|
||||
ownerId: string,
|
||||
userId: string | undefined,
|
||||
isInstanceAdmin = false,
|
||||
): VoiceModerationPermissions {
|
||||
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
|
||||
return { mute: true, deafen: true, move: true };
|
||||
}
|
||||
const administrator = hasPermission(permissions, PERMISSION_ADMINISTRATOR);
|
||||
return {
|
||||
mute: administrator || hasPermission(permissions, PERMISSION_MUTE_MEMBERS),
|
||||
deafen: administrator || hasPermission(permissions, PERMISSION_DEAFEN_MEMBERS),
|
||||
move: administrator || hasPermission(permissions, PERMISSION_MOVE_MEMBERS),
|
||||
};
|
||||
}
|
||||
|
||||
/** Может ли пользователь войти в голосовую комнату. */
|
||||
export function canConnectVoice(
|
||||
permissions: readonly string[],
|
||||
isInstanceAdmin = false,
|
||||
channelCanConnect?: boolean,
|
||||
): boolean {
|
||||
if (channelCanConnect === false) {
|
||||
return false;
|
||||
}
|
||||
if (isInstanceAdmin) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||
hasPermission(permissions, PERMISSION_CONNECT_VOICE)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
|
||||
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
|
||||
@@ -137,8 +187,7 @@ export function canManageEmojis(
|
||||
}
|
||||
|
||||
/** Может ли пользователь отправлять сообщения в комнату. */
|
||||
export function canSendMessages(
|
||||
permissions: readonly string[],
|
||||
export function canSendMessages( permissions: readonly string[],
|
||||
isInstanceAdmin = false,
|
||||
channelCanSend?: boolean,
|
||||
): boolean {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { ConnectionQuality, Room, RoomEvent, Track, type Participant } from 'livekit-client';
|
||||
|
||||
import type { VoiceDeviceKind } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
* Обёртка над `livekit-client`: единственное место, где клиент знает про SDK.
|
||||
*
|
||||
* Стор и компоненты работают с простыми снимками (`VoiceParticipant`) и
|
||||
* дескрипторами дорожек (`VoiceTrackHandle`), поэтому:
|
||||
* - UI не зависит от версии SDK и умеет рисовать участников без него;
|
||||
* - в тестах SDK подменяется моком `livekit-client`, а логика стора
|
||||
* проверяется настоящая.
|
||||
*/
|
||||
|
||||
/** Качество связи участника (значения совпадают с `ConnectionQuality` из SDK). */
|
||||
export type VoiceQuality = 'excellent' | 'good' | 'poor' | 'lost' | 'unknown';
|
||||
|
||||
/** Дескриптор медиа-дорожки: подключение к элементу и отключение от него. */
|
||||
export interface VoiceTrackHandle {
|
||||
kind: 'audio' | 'video';
|
||||
attach: (element: HTMLMediaElement) => void;
|
||||
detach: (element: HTMLMediaElement) => void;
|
||||
}
|
||||
|
||||
/** Снимок участника комнаты: всё, что нужно интерфейсу. */
|
||||
export interface VoiceParticipant {
|
||||
/** identity в LiveKit — это id пользователя. */
|
||||
userId: string;
|
||||
name: string;
|
||||
isLocal: boolean;
|
||||
isSpeaking: boolean;
|
||||
micEnabled: boolean;
|
||||
cameraEnabled: boolean;
|
||||
screenEnabled: boolean;
|
||||
quality: VoiceQuality;
|
||||
/** Микрофон собеседника (своя дорожка не нужна: себя не слушаем). */
|
||||
audio?: VoiceTrackHandle;
|
||||
/** Камера: своя — для предпросмотра, чужая — для плитки. */
|
||||
video?: VoiceTrackHandle;
|
||||
/** Демонстрация экрана. */
|
||||
screen?: VoiceTrackHandle;
|
||||
}
|
||||
|
||||
export type VoiceRoomStatus = 'connected' | 'reconnecting' | 'reconnected' | 'disconnected';
|
||||
|
||||
export interface VoiceRoomCallbacks {
|
||||
/** Изменился состав комнаты или медиа-состояние участников. */
|
||||
onParticipants: (participants: VoiceParticipant[]) => void;
|
||||
onStatus: (status: VoiceRoomStatus) => void;
|
||||
/** SDK сообщает, что свой микрофон говорит, хотя публикация выключена. */
|
||||
onSpeakingWhileMuted: () => void;
|
||||
}
|
||||
|
||||
export interface VoiceRoomHandle {
|
||||
connect: (url: string, token: string) => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
/** Снимает подписки на события SDK: после этого объект больше не нужен. */
|
||||
dispose: () => void;
|
||||
setMicrophoneEnabled: (enabled: boolean) => Promise<void>;
|
||||
setCameraEnabled: (enabled: boolean) => Promise<void>;
|
||||
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
|
||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||
participants: () => VoiceParticipant[];
|
||||
}
|
||||
|
||||
/** Запасной TTL токена, если `exp` не удалось разобрать (AGENT.md §7.14). */
|
||||
export const DEFAULT_TOKEN_TTL_MS = 10 * 60_000;
|
||||
|
||||
function qualityOf(participant: Participant): VoiceQuality {
|
||||
switch (participant.connectionQuality) {
|
||||
case ConnectionQuality.Excellent:
|
||||
return 'excellent';
|
||||
case ConnectionQuality.Good:
|
||||
return 'good';
|
||||
case ConnectionQuality.Poor:
|
||||
return 'poor';
|
||||
case ConnectionQuality.Lost:
|
||||
return 'lost';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function handleOf(
|
||||
track: Track | undefined,
|
||||
kind: 'audio' | 'video',
|
||||
): VoiceTrackHandle | undefined {
|
||||
if (track === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
attach: (element) => {
|
||||
track.attach(element);
|
||||
},
|
||||
detach: (element) => {
|
||||
track.detach(element);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Опубликована ли дорожка и не замьючена ли она. */
|
||||
function isLive(participant: Participant, source: Track.Source): boolean {
|
||||
const publication = participant.getTrackPublication(source);
|
||||
return publication !== undefined && publication.track !== undefined && !publication.isMuted;
|
||||
}
|
||||
|
||||
function snapshotOf(participant: Participant, isLocal: boolean): VoiceParticipant {
|
||||
const microphone = participant.getTrackPublication(Track.Source.Microphone)?.track;
|
||||
const camera = participant.getTrackPublication(Track.Source.Camera)?.track;
|
||||
const screen = participant.getTrackPublication(Track.Source.ScreenShare)?.track;
|
||||
const snapshot: VoiceParticipant = {
|
||||
userId: participant.identity,
|
||||
name: participant.name === undefined || participant.name === '' ? participant.identity : participant.name,
|
||||
isLocal,
|
||||
isSpeaking: participant.isSpeaking,
|
||||
micEnabled: isLive(participant, Track.Source.Microphone),
|
||||
cameraEnabled: isLive(participant, Track.Source.Camera),
|
||||
screenEnabled: isLive(participant, Track.Source.ScreenShare),
|
||||
quality: qualityOf(participant),
|
||||
};
|
||||
// Свой микрофон не слушаем: элемент нужен только для чужих дорожек.
|
||||
const audio = isLocal ? undefined : handleOf(microphone, 'audio');
|
||||
const video = handleOf(camera, 'video');
|
||||
const screenShare = handleOf(screen, 'video');
|
||||
if (audio !== undefined) {
|
||||
snapshot.audio = audio;
|
||||
}
|
||||
if (video !== undefined) {
|
||||
snapshot.video = video;
|
||||
}
|
||||
if (screenShare !== undefined) {
|
||||
snapshot.screen = screenShare;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function snapshotAll(room: Room): VoiceParticipant[] {
|
||||
const participants = [snapshotOf(room.localParticipant, true)];
|
||||
for (const remote of room.remoteParticipants.values()) {
|
||||
participants.push(snapshotOf(remote, false));
|
||||
}
|
||||
return participants;
|
||||
}
|
||||
|
||||
/** Создаёт комнату LiveKit и подписывается на её события. */
|
||||
export function createVoiceRoom(callbacks: VoiceRoomCallbacks): VoiceRoomHandle {
|
||||
// Пустой deviceId в предпочтениях означает «устройство по умолчанию».
|
||||
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||
const refresh = (): void => {
|
||||
callbacks.onParticipants(snapshotAll(room));
|
||||
};
|
||||
|
||||
room.on(RoomEvent.ParticipantConnected, refresh);
|
||||
room.on(RoomEvent.ParticipantDisconnected, refresh);
|
||||
room.on(RoomEvent.TrackSubscribed, refresh);
|
||||
room.on(RoomEvent.TrackUnsubscribed, refresh);
|
||||
room.on(RoomEvent.TrackPublished, refresh);
|
||||
room.on(RoomEvent.TrackUnpublished, refresh);
|
||||
room.on(RoomEvent.TrackMuted, refresh);
|
||||
room.on(RoomEvent.TrackUnmuted, refresh);
|
||||
room.on(RoomEvent.LocalTrackPublished, refresh);
|
||||
room.on(RoomEvent.LocalTrackUnpublished, refresh);
|
||||
room.on(RoomEvent.ConnectionQualityChanged, refresh);
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, () => {
|
||||
const local = room.localParticipant;
|
||||
if (local.isSpeaking && !local.isMicrophoneEnabled) {
|
||||
callbacks.onSpeakingWhileMuted();
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
room.on(RoomEvent.Reconnecting, () => callbacks.onStatus('reconnecting'));
|
||||
room.on(RoomEvent.Reconnected, () => callbacks.onStatus('reconnected'));
|
||||
room.on(RoomEvent.Disconnected, () => callbacks.onStatus('disconnected'));
|
||||
|
||||
return {
|
||||
async connect(url, token) {
|
||||
await room.connect(url, token);
|
||||
// Автовоспроизведение может быть запрещено до жеста пользователя —
|
||||
// это не ошибка подключения.
|
||||
try {
|
||||
await room.startAudio();
|
||||
} catch {
|
||||
// Звук включится при первом клике пользователя.
|
||||
}
|
||||
refresh();
|
||||
},
|
||||
|
||||
async disconnect() {
|
||||
await room.disconnect();
|
||||
},
|
||||
|
||||
dispose() {
|
||||
room.removeAllListeners();
|
||||
},
|
||||
|
||||
async setMicrophoneEnabled(enabled) {
|
||||
await room.localParticipant.setMicrophoneEnabled(enabled);
|
||||
refresh();
|
||||
},
|
||||
|
||||
async setCameraEnabled(enabled) {
|
||||
await room.localParticipant.setCameraEnabled(enabled);
|
||||
refresh();
|
||||
},
|
||||
|
||||
async setScreenShareEnabled(enabled) {
|
||||
await room.localParticipant.setScreenShareEnabled(enabled);
|
||||
refresh();
|
||||
},
|
||||
|
||||
async switchActiveDevice(kind, deviceId) {
|
||||
await room.switchActiveDevice(kind, deviceId === '' ? 'default' : deviceId);
|
||||
},
|
||||
|
||||
participants() {
|
||||
return snapshotAll(room);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Время истечения токена LiveKit (мс, эпоха) из поля `exp` JWT.
|
||||
* Если разобрать не удалось — считаем, что токен живёт `DEFAULT_TOKEN_TTL_MS`.
|
||||
*/
|
||||
export function tokenExpiresAt(token: string, now: number = Date.now()): number {
|
||||
const expires = readJwtExpiry(token);
|
||||
return expires === null ? now + DEFAULT_TOKEN_TTL_MS : expires * 1000;
|
||||
}
|
||||
|
||||
function readJwtExpiry(token: string): number | null {
|
||||
const payload = token.split('.')[1];
|
||||
if (payload === undefined || payload === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
|
||||
const decoded: unknown = JSON.parse(globalThis.atob(padded));
|
||||
if (typeof decoded !== 'object' || decoded === null) {
|
||||
return null;
|
||||
}
|
||||
const exp = (decoded as { exp?: unknown }).exp;
|
||||
return typeof exp === 'number' && Number.isFinite(exp) ? exp : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/** Устройство ввода/вывода в том виде, в каком его показывает интерфейс. */
|
||||
export interface MediaDevice {
|
||||
@@ -72,6 +73,9 @@ export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult {
|
||||
/**
|
||||
* Выбранное устройство нужного вида из стора предпочтений.
|
||||
* Одна обёртка на три вида — чтобы не дублировать селекторы в компонентах.
|
||||
*
|
||||
* Смена устройства сразу применяется к активной голосовой сессии
|
||||
* (`switchActiveDevice`), а выбор сохраняется в предпочтениях клиента.
|
||||
*/
|
||||
export function useDeviceChoice(kind: VoiceDeviceKind): {
|
||||
value: string;
|
||||
@@ -87,5 +91,11 @@ export function useDeviceChoice(kind: VoiceDeviceKind): {
|
||||
return state.outputDeviceId;
|
||||
});
|
||||
const selectDevice = useVoiceStore((state) => state.selectDevice);
|
||||
return { value, select: (deviceId: string) => selectDevice(kind, deviceId) };
|
||||
return {
|
||||
value,
|
||||
select: (deviceId: string) => {
|
||||
selectDevice(kind, deviceId);
|
||||
void useVoiceConnectionStore.getState().switchActiveDevice(kind, deviceId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user