fix(install): LiveKit перезапускается при изменении конфига
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в примонтированном файле — из-за этого вебхуки не включались после обновления. Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
This commit is contained in:
@@ -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']),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user