fix(install): LiveKit перезапускается при изменении конфига

LiveKit читает livekit.yaml только при старте, а compose не замечает правок в
примонтированном файле — из-за этого вебхуки не включались после обновления.
Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
This commit is contained in:
2026-09-20 18:38:54 +03:00
parent 9e7d023f7e
commit ecd7b3be10
27 changed files with 2637 additions and 37 deletions
+51 -2
View File
@@ -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 {
+248
View File
@@ -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;
}
}
+11 -1
View File
@@ -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);
},
};
}