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
+5
View File
@@ -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']),
+182
View File
@@ -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<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : 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<VoiceJoinResult> {
return request<VoiceJoinResult>(`/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<VoiceState[]> {
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<VoiceState[]> {
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<VoiceState[]> {
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<VoiceState[]> {
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);
}