diff --git a/internal/server/api_voice_webhook.go b/internal/server/api_voice_webhook.go
index 6d9463a..8d9e1b6 100644
--- a/internal/server/api_voice_webhook.go
+++ b/internal/server/api_voice_webhook.go
@@ -70,6 +70,9 @@ func (s *Server) handleLiveKitWebhook(w http.ResponseWriter, r *http.Request) {
return
}
s.applyLiveKitEvent(r.Context(), event)
+ // Пишем в лог: по нему видно, что SFU действительно доставляет события.
+ s.logger.InfoContext(r.Context(), "livekit webhook handled",
+ slog.String("event", event.Event), slog.String("room", event.Room.Name))
httpxWriteJSON(w, http.StatusOK, map[string]any{"ok": true})
}
diff --git a/web/src/api/voice.ts b/web/src/api/voice.ts
index 8196306..9fcdcac 100644
--- a/web/src/api/voice.ts
+++ b/web/src/api/voice.ts
@@ -47,8 +47,7 @@ export interface VoiceModerationPatch {
/** Разобранное событие `VOICE_STATE_UPDATE`: состояние или уход из комнаты. */
export type VoiceStateEvent =
- | { kind: 'state'; state: VoiceState }
- | { kind: 'left'; guildId: string; userId: string };
+ { kind: 'state'; state: VoiceState } | { kind: 'left'; guildId: string; userId: string };
export const guildVoiceStatesQueryKey = (guildId: string) =>
['guilds', guildId, 'voice-states'] as const;
diff --git a/web/src/components/voice/VoiceChannelItem.tsx b/web/src/components/voice/VoiceChannelItem.tsx
index c5cbcc1..80b8c1b 100644
--- a/web/src/components/voice/VoiceChannelItem.tsx
+++ b/web/src/components/voice/VoiceChannelItem.tsx
@@ -59,7 +59,7 @@ export function VoiceChannelItem({ guildId, channel, selected }: VoiceChannelIte
event.preventDefault();
const userId = draggedUserId(event);
if (userId !== '') {
- void moveParticipant(userId, channel.id);
+ void moveParticipant(guildId, userId, channel.id);
}
};
diff --git a/web/src/components/voice/VoiceIndicators.tsx b/web/src/components/voice/VoiceIndicators.tsx
index 62553d0..7eecc18 100644
--- a/web/src/components/voice/VoiceIndicators.tsx
+++ b/web/src/components/voice/VoiceIndicators.tsx
@@ -94,17 +94,12 @@ export function VoiceFlags({
{camera ? : null}
{screen ? : null}
);
}
-
-/** Кольцо вокруг аватара, когда участник говорит. */
-export function speakingRingClass(speaking: boolean): string {
- return speaking
- ? 'ring-2 ring-success ring-offset-1 ring-offset-surface-1'
- : 'ring-1 ring-border/60';
-}
diff --git a/web/src/components/voice/VoiceOccupantRow.tsx b/web/src/components/voice/VoiceOccupantRow.tsx
index 1a3e296..86a2f1b 100644
--- a/web/src/components/voice/VoiceOccupantRow.tsx
+++ b/web/src/components/voice/VoiceOccupantRow.tsx
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '@/components/ui/Avatar';
-import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators';
+import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
import {
useVoiceChannels,
@@ -10,6 +10,7 @@ import {
type VoiceOccupant,
} from '@/components/voice/useVoiceOccupants';
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
+import { speakingRingClass } from '@/components/voice/voiceStyles';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
/**
@@ -91,9 +92,9 @@ export function VoiceOccupantRow({
onClose={close}
permissions={permissions}
moveTargets={moveTargets}
- onMute={(userId, muted) => void setParticipantMuted(userId, muted)}
- onDeafen={(userId, deafened) => void setParticipantDeafened(userId, deafened)}
- onMove={(userId, targetId) => void moveParticipant(userId, targetId)}
+ onMute={(userId, muted) => void setParticipantMuted(guildId, userId, muted)}
+ onDeafen={(userId, deafened) => void setParticipantDeafened(guildId, userId, deafened)}
+ onMove={(userId, targetId) => void moveParticipant(guildId, userId, targetId)}
/>
);
diff --git a/web/src/components/voice/VoiceRoomPanel.tsx b/web/src/components/voice/VoiceRoomPanel.tsx
index 2d582da..50475df 100644
--- a/web/src/components/voice/VoiceRoomPanel.tsx
+++ b/web/src/components/voice/VoiceRoomPanel.tsx
@@ -5,7 +5,7 @@ import type { Channel } from '@/api/types';
import { Avatar } from '@/components/ui/Avatar';
import { Card, Button } from '@/components/ui/primitives';
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
-import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators';
+import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
@@ -16,6 +16,7 @@ import {
type VoiceOccupant,
} from '@/components/voice/useVoiceOccupants';
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
+import { speakingRingClass } from '@/components/voice/voiceStyles';
import type { VoiceModerationPermissions } from '@/lib/identity';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useVoiceStore } from '@/stores/voice';
@@ -195,12 +196,7 @@ function VoiceControlsRow({
active={screenEnabled}
onClick={onScreen}
/>
-
+
);
}
@@ -231,7 +227,9 @@ function VoiceControlButton({
disabled={disabled}
onClick={onClick}
className={`rounded-[var(--radius-md)] border px-3 py-1.5 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 ${
- active ? 'border-border/60 bg-surface-2 text-fg' : 'border-danger/60 bg-danger/10 text-danger'
+ active
+ ? 'border-border/60 bg-surface-2 text-fg'
+ : 'border-danger/60 bg-danger/10 text-danger'
}`}
>
{glyph}
@@ -278,16 +276,17 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
const failed = here && status === 'error';
const onMute = useCallback(
- (userId: string, muted: boolean) => void setParticipantMuted(userId, muted),
- [setParticipantMuted],
+ (userId: string, muted: boolean) => void setParticipantMuted(guildId, userId, muted),
+ [guildId, setParticipantMuted],
);
const onDeafen = useCallback(
- (userId: string, deafenedNext: boolean) => void setParticipantDeafened(userId, deafenedNext),
- [setParticipantDeafened],
+ (userId: string, deafenedNext: boolean) =>
+ void setParticipantDeafened(guildId, userId, deafenedNext),
+ [guildId, setParticipantDeafened],
);
const onMove = useCallback(
- (userId: string, targetId: string) => void moveParticipant(userId, targetId),
- [moveParticipant],
+ (userId: string, targetId: string) => void moveParticipant(guildId, userId, targetId),
+ [guildId, moveParticipant],
);
if (!voiceEnabled) {
diff --git a/web/src/components/voice/useVoiceEnabled.ts b/web/src/components/voice/useVoiceEnabled.ts
index 96fb429..b10f7b4 100644
--- a/web/src/components/voice/useVoiceEnabled.ts
+++ b/web/src/components/voice/useVoiceEnabled.ts
@@ -16,5 +16,6 @@ export function useVoiceEnabled(): boolean {
staleTime: 300_000,
retry: 0,
});
- return meta.data?.features.voice_enabled !== false;
+ // Ответ может быть неполным (старый сервер, тестовый мок) — тогда голос включён.
+ return meta.data?.features?.voice_enabled !== false;
}
diff --git a/web/src/components/voice/useVoiceOccupants.ts b/web/src/components/voice/useVoiceOccupants.ts
index 3f22428..9f1ce21 100644
--- a/web/src/components/voice/useVoiceOccupants.ts
+++ b/web/src/components/voice/useVoiceOccupants.ts
@@ -38,7 +38,10 @@ const NO_OCCUPANTS: VoiceOccupant[] = [];
* Кто в комнате: список строится из `voice_states` (один источник правды),
* имена и аватары — из участников сервера, речь и качество — из SDK.
*/
-export function useVoiceOccupants(guildId: string | null, channelId: string | null): VoiceOccupant[] {
+export function useVoiceOccupants(
+ guildId: string | null,
+ channelId: string | null,
+): VoiceOccupant[] {
const voiceStates = useSessionStore((state) => state.voiceStates);
const sessionUser = useSessionStore((state) => state.user);
const mediaParticipants = useVoiceConnectionStore((state) =>
diff --git a/web/src/components/voice/voiceStyles.ts b/web/src/components/voice/voiceStyles.ts
new file mode 100644
index 0000000..d10f736
--- /dev/null
+++ b/web/src/components/voice/voiceStyles.ts
@@ -0,0 +1,11 @@
+/**
+ * Классы состояний голосового интерфейса. Вынесены из компонентов, чтобы
+ * файлы с разметкой экспортировали только компоненты (fast refresh).
+ */
+
+/** Кольцо вокруг аватара: зелёное, когда участник говорит. */
+export function speakingRingClass(speaking: boolean): string {
+ return speaking
+ ? 'ring-2 ring-success ring-offset-1 ring-offset-surface-1'
+ : 'ring-1 ring-border/60';
+}
diff --git a/web/src/lib/identity.ts b/web/src/lib/identity.ts
index 4a1bf9a..b459325 100644
--- a/web/src/lib/identity.ts
+++ b/web/src/lib/identity.ts
@@ -187,7 +187,8 @@ export function canManageEmojis(
}
/** Может ли пользователь отправлять сообщения в комнату. */
-export function canSendMessages( permissions: readonly string[],
+export function canSendMessages(
+ permissions: readonly string[],
isInstanceAdmin = false,
channelCanSend?: boolean,
): boolean {
diff --git a/web/src/lib/livekit.ts b/web/src/lib/livekit.ts
index c514dcd..2ebf2dc 100644
--- a/web/src/lib/livekit.ts
+++ b/web/src/lib/livekit.ts
@@ -81,10 +81,7 @@ function qualityOf(participant: Participant): VoiceQuality {
}
}
-function handleOf(
- track: Track | undefined,
- kind: 'audio' | 'video',
-): VoiceTrackHandle | undefined {
+function handleOf(track: Track | undefined, kind: 'audio' | 'video'): VoiceTrackHandle | undefined {
if (track === undefined) {
return undefined;
}
@@ -111,7 +108,10 @@ function snapshotOf(participant: Participant, isLocal: boolean): VoiceParticipan
const screen = participant.getTrackPublication(Track.Source.ScreenShare)?.track;
const snapshot: VoiceParticipant = {
userId: participant.identity,
- name: participant.name === undefined || participant.name === '' ? participant.identity : participant.name,
+ name:
+ participant.name === undefined || participant.name === ''
+ ? participant.identity
+ : participant.name,
isLocal,
isSpeaking: participant.isSpeaking,
micEnabled: isLive(participant, Track.Source.Microphone),
diff --git a/web/src/pages/app/GuildView.tsx b/web/src/pages/app/GuildView.tsx
index 7907e1f..16a8588 100644
--- a/web/src/pages/app/GuildView.tsx
+++ b/web/src/pages/app/GuildView.tsx
@@ -7,6 +7,7 @@ import { ChatView } from '@/components/chat/ChatView';
import { PinsPanel } from '@/components/chat/PinsPanel';
import { SearchPanel } from '@/components/chat/SearchPanel';
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
+import { Card } from '@/components/ui/primitives';
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
import { canManageMessages, canSendMessages } from '@/lib/identity';
import { useMessagesStore } from '@/stores/messages';
diff --git a/web/src/stores/voiceConnection.ts b/web/src/stores/voiceConnection.ts
index 8f3310f..a4de83a 100644
--- a/web/src/stores/voiceConnection.ts
+++ b/web/src/stores/voiceConnection.ts
@@ -76,9 +76,12 @@ export interface VoiceConnectionState {
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise;
/** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */
setVolume: (userId: string, volume: number) => void;
- setParticipantMuted: (userId: string, muted: boolean) => Promise;
- setParticipantDeafened: (userId: string, deafened: boolean) => Promise;
- moveParticipant: (userId: string, channelId: string) => Promise;
+ /** Серверный мьют участника: право `MUTE_MEMBERS` (модерация без входа в комнату). */
+ setParticipantMuted: (guildId: string, userId: string, muted: boolean) => Promise;
+ /** Серверное глушение участника: право `DEAFEN_MEMBERS`. */
+ setParticipantDeafened: (guildId: string, userId: string, deafened: boolean) => Promise;
+ /** Перемещение участника: право `MOVE_MEMBERS`. */
+ moveParticipant: (guildId: string, userId: string, channelId: string) => Promise;
/** Переносит серверные флаги (server_mute/server_deaf) в локальную сессию. */
syncServerState: () => void;
dismissSpeakingWarning: () => void;
@@ -485,11 +488,7 @@ export const useVoiceConnectionStore = create((set, get) =
}
},
- setParticipantMuted: async (userId, muted) => {
- const guildId = get().guildId;
- if (guildId === null) {
- return;
- }
+ setParticipantMuted: async (guildId, userId, muted) => {
try {
const states = await moderateVoiceState(guildId, userId, { server_mute: muted });
useSessionStore.getState().setGuildVoiceStates(guildId, states);
@@ -499,11 +498,7 @@ export const useVoiceConnectionStore = create((set, get) =
}
},
- setParticipantDeafened: async (userId, deafened) => {
- const guildId = get().guildId;
- if (guildId === null) {
- return;
- }
+ setParticipantDeafened: async (guildId, userId, deafened) => {
try {
const states = await moderateVoiceState(guildId, userId, { server_deaf: deafened });
useSessionStore.getState().setGuildVoiceStates(guildId, states);
@@ -513,11 +508,7 @@ export const useVoiceConnectionStore = create((set, get) =
}
},
- moveParticipant: async (userId, channelId) => {
- const guildId = get().guildId;
- if (guildId === null) {
- return;
- }
+ moveParticipant: async (guildId, userId, channelId) => {
try {
const states = await moveVoiceMember(guildId, userId, channelId);
useSessionStore.getState().setGuildVoiceStates(guildId, states);
diff --git a/web/tests/chat.test.tsx b/web/tests/chat.test.tsx
index 50ebab0..e323e01 100644
--- a/web/tests/chat.test.tsx
+++ b/web/tests/chat.test.tsx
@@ -206,7 +206,7 @@ describe('чат комнаты: лента', () => {
});
});
- it('оставляет голосовую комнату с заглушкой', async () => {
+ it('открывает панель голосовой комнаты вместо чата', async () => {
await boot({
path: '/app/g-1/c-voice',
channels: [
@@ -215,7 +215,9 @@ describe('чат комнаты: лента', () => {
],
});
- expect(await screen.findByText('Голосовая комната')).toBeVisible();
+ // Вместо ленты сообщений — панель голоса с кнопкой подключения.
+ expect(await screen.findByTestId('voice-join')).toBeVisible();
+ expect(screen.getByText('В комнате пока никого нет')).toBeVisible();
expect(screen.queryByTestId('message-list')).toBeNull();
expect(screen.queryByTestId('composer')).toBeNull();
});
diff --git a/web/tests/fakeLiveKit.ts b/web/tests/fakeLiveKit.ts
new file mode 100644
index 0000000..ee2a4bd
--- /dev/null
+++ b/web/tests/fakeLiveKit.ts
@@ -0,0 +1,287 @@
+/**
+ * Подставной `livekit-client` для тестов: комната, участники и события под
+ * управлением теста. В jsdom настоящий SDK не работает (нет WebRTC), поэтому
+ * `vi.mock('livekit-client', () => import('./fakeLiveKit'))` подменяет его
+ * целиком, а логика `lib/livekit.ts` и стора проверяется настоящая.
+ */
+
+export const RoomEvent = {
+ ParticipantConnected: 'participantConnected',
+ ParticipantDisconnected: 'participantDisconnected',
+ TrackSubscribed: 'trackSubscribed',
+ TrackUnsubscribed: 'trackUnsubscribed',
+ TrackPublished: 'trackPublished',
+ TrackUnpublished: 'trackUnpublished',
+ TrackMuted: 'trackMuted',
+ TrackUnmuted: 'trackUnmuted',
+ LocalTrackPublished: 'localTrackPublished',
+ LocalTrackUnpublished: 'localTrackUnpublished',
+ ConnectionQualityChanged: 'connectionQualityChanged',
+ ActiveSpeakersChanged: 'activeSpeakersChanged',
+ Reconnecting: 'reconnecting',
+ Reconnected: 'reconnected',
+ Disconnected: 'disconnected',
+} as const;
+
+export const ConnectionQuality = {
+ Excellent: 'excellent',
+ Good: 'good',
+ Poor: 'poor',
+ Lost: 'lost',
+ Unknown: 'unknown',
+} as const;
+
+export const Track = {
+ Source: {
+ Camera: 'camera',
+ Microphone: 'microphone',
+ ScreenShare: 'screen_share',
+ ScreenShareAudio: 'screen_share_audio',
+ },
+} as const;
+
+/** Дорожка: помнит, к каким элементам её подключили. */
+export class FakeTrack {
+ readonly kind: 'audio' | 'video';
+ readonly attached: HTMLMediaElement[] = [];
+
+ constructor(kind: 'audio' | 'video') {
+ this.kind = kind;
+ }
+
+ attach(element: HTMLMediaElement): HTMLMediaElement {
+ this.attached.push(element);
+ return element;
+ }
+
+ detach(element: HTMLMediaElement): HTMLMediaElement[] {
+ const index = this.attached.indexOf(element);
+ if (index >= 0) {
+ this.attached.splice(index, 1);
+ }
+ return [];
+ }
+}
+
+export class FakePublication {
+ constructor(
+ readonly source: string,
+ readonly track: FakeTrack,
+ public isMuted: boolean,
+ ) {}
+}
+
+type Handler = (...args: never[]) => void;
+
+/** Участник комнаты: публикации, флаги и записанные вызовы SDK. */
+export class FakeParticipant {
+ isSpeaking = false;
+ connectionQuality: string = ConnectionQuality.Excellent;
+ identity: string;
+ name: string;
+ readonly isLocal: boolean;
+ /** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */
+ readonly calls: { method: string; value: unknown }[] = [];
+ private readonly publications = new Map();
+
+ constructor(
+ private readonly room: FakeRoom,
+ identity: string,
+ name: string,
+ isLocal = false,
+ ) {
+ this.identity = identity;
+ this.name = name;
+ this.isLocal = isLocal;
+ }
+
+ get isMicrophoneEnabled(): boolean {
+ return this.isLive(Track.Source.Microphone);
+ }
+
+ get isCameraEnabled(): boolean {
+ return this.isLive(Track.Source.Camera);
+ }
+
+ get isScreenShareEnabled(): boolean {
+ return this.isLive(Track.Source.ScreenShare);
+ }
+
+ getTrackPublication(source: string): FakePublication | undefined {
+ return this.publications.get(source);
+ }
+
+ /** Публикация дорожки: используется и для своих, и для чужих участников. */
+ setPublication(source: string, muted = false): FakePublication {
+ const publication = new FakePublication(
+ source,
+ new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video'),
+ muted,
+ );
+ this.publications.set(source, publication);
+ return publication;
+ }
+
+ removePublication(source: string): void {
+ this.publications.delete(source);
+ this.room.emit(RoomEvent.TrackUnsubscribed);
+ }
+
+ setSpeaking(speaking: boolean): void {
+ this.isSpeaking = speaking;
+ }
+
+ private isLive(source: string): boolean {
+ const publication = this.publications.get(source);
+ return publication !== undefined && !publication.isMuted;
+ }
+
+ private setLocal(source: string, enabled: boolean, method: string): void {
+ this.calls.push({ method, value: enabled });
+ const existing = this.publications.get(source);
+ const track =
+ existing?.track ?? new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video');
+ this.publications.set(source, new FakePublication(source, track, !enabled));
+ this.room.emit(RoomEvent.LocalTrackPublished);
+ }
+
+ setMicrophoneEnabled(enabled: boolean): Promise {
+ this.setLocal(Track.Source.Microphone, enabled, 'setMicrophoneEnabled');
+ return Promise.resolve();
+ }
+
+ setCameraEnabled(enabled: boolean): Promise {
+ this.setLocal(Track.Source.Camera, enabled, 'setCameraEnabled');
+ return Promise.resolve();
+ }
+
+ setScreenShareEnabled(enabled: boolean): Promise {
+ this.setLocal(Track.Source.ScreenShare, enabled, 'setScreenShareEnabled');
+ return Promise.resolve();
+ }
+}
+
+export interface RemoteOptions {
+ muted?: boolean;
+ camera?: boolean;
+ screen?: boolean;
+ quality?: string;
+}
+
+/** Подставная комната: те же методы, что использует `lib/livekit.ts`. */
+export class FakeRoom {
+ static instances: FakeRoom[] = [];
+
+ static reset(): void {
+ FakeRoom.instances = [];
+ }
+
+ static latest(): FakeRoom {
+ const room = FakeRoom.instances.at(-1);
+ if (room === undefined) {
+ throw new Error('комната LiveKit ещё не создавалась');
+ }
+ return room;
+ }
+
+ readonly localParticipant: FakeParticipant;
+ readonly remoteParticipants = new Map();
+ readonly connectCalls: { url: string; token: string }[] = [];
+ readonly deviceSwitches: { kind: string; deviceId: string }[] = [];
+ disconnectCalls = 0;
+ startAudioCalls = 0;
+ private readonly handlers = new Map>();
+
+ constructor(readonly options?: unknown) {
+ FakeRoom.instances.push(this);
+ this.localParticipant = new FakeParticipant(this, 'user-1', 'Alice', true);
+ }
+
+ on(event: string, handler: Handler): this {
+ const handlers = this.handlers.get(event) ?? new Set();
+ handlers.add(handler);
+ this.handlers.set(event, handlers);
+ return this;
+ }
+
+ off(event: string, handler: Handler): this {
+ this.handlers.get(event)?.delete(handler);
+ return this;
+ }
+
+ removeAllListeners(): this {
+ this.handlers.clear();
+ return this;
+ }
+
+ connect(url: string, token: string): Promise {
+ this.connectCalls.push({ url, token });
+ return Promise.resolve();
+ }
+
+ disconnect(): Promise {
+ this.disconnectCalls += 1;
+ this.emit(RoomEvent.Disconnected);
+ return Promise.resolve();
+ }
+
+ startAudio(): Promise {
+ this.startAudioCalls += 1;
+ return Promise.resolve();
+ }
+
+ switchActiveDevice(kind: string, deviceId: string): Promise {
+ this.deviceSwitches.push({ kind, deviceId });
+ return Promise.resolve(true);
+ }
+
+ emit(event: string, ...args: unknown[]): void {
+ for (const handler of [...(this.handlers.get(event) ?? [])]) {
+ (handler as (...values: unknown[]) => void)(...args);
+ }
+ }
+
+ /** Подключает удалённого участника: как `ParticipantConnected` из SDK. */
+ addRemote(userId: string, name: string, options: RemoteOptions = {}): FakeParticipant {
+ const participant = new FakeParticipant(this, userId, name);
+ participant.connectionQuality = options.quality ?? ConnectionQuality.Excellent;
+ participant.setPublication(Track.Source.Microphone, options.muted ?? false);
+ if (options.camera === true) {
+ participant.setPublication(Track.Source.Camera);
+ }
+ if (options.screen === true) {
+ participant.setPublication(Track.Source.ScreenShare);
+ }
+ this.remoteParticipants.set(userId, participant);
+ this.emit(RoomEvent.ParticipantConnected, participant);
+ this.emit(RoomEvent.TrackSubscribed);
+ return participant;
+ }
+
+ removeRemote(userId: string): void {
+ const participant = this.remoteParticipants.get(userId);
+ if (participant === undefined) {
+ return;
+ }
+ this.remoteParticipants.delete(userId);
+ this.emit(RoomEvent.ParticipantDisconnected, participant);
+ }
+
+ /** Кто-то заговорил: `ActiveSpeakersChanged` из SDK. */
+ activeSpeakers(userIds: string[]): void {
+ const speakerIds = new Set(userIds);
+ this.localParticipant.setSpeaking(speakerIds.has(this.localParticipant.identity));
+ for (const participant of this.remoteParticipants.values()) {
+ participant.setSpeaking(speakerIds.has(participant.identity));
+ }
+ this.emit(RoomEvent.ActiveSpeakersChanged, []);
+ }
+
+ setQuality(userId: string, quality: string): void {
+ const participant = this.remoteParticipants.get(userId);
+ if (participant !== undefined) {
+ participant.connectionQuality = quality;
+ this.emit(RoomEvent.ConnectionQualityChanged, participant);
+ }
+ }
+}
diff --git a/web/tests/helpers.tsx b/web/tests/helpers.tsx
index 86f5d49..8c06d0c 100644
--- a/web/tests/helpers.tsx
+++ b/web/tests/helpers.tsx
@@ -13,6 +13,7 @@ import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
import { useVoiceStore } from '@/stores/voice';
+import { useVoiceConnectionStore } from '@/stores/voiceConnection';
import type { User } from '@/api/types';
/** Ответ-заглушка с JSON-телом (как у настоящего API). */
@@ -32,7 +33,8 @@ export interface FetchRoute {
/** Подстрока URL, по которой выбирается обработчик. */
match: string;
method?: string;
- response: () => Response | Promise;
+ /** Ответ на запрос: обработчик видит url, метод и разобранное тело. */
+ response: (request: RecordedRequest) => Response | Promise;
}
/**
@@ -54,7 +56,7 @@ export function installFetch(routes: FetchRoute[]) {
if (!url.includes(route.match)) {
continue;
}
- return Promise.resolve(route.response());
+ return Promise.resolve(route.response({ url, method, body: parseRequestBody(init?.body) }));
}
return Promise.resolve(apiError('not_found', 404));
});
@@ -62,6 +64,18 @@ export function installFetch(routes: FetchRoute[]) {
return fetchMock;
}
+/** Тело запроса как JSON: у multipart и пустого тела его нет. */
+function parseRequestBody(body: BodyInit | null | undefined): unknown {
+ if (typeof body !== 'string') {
+ return undefined;
+ }
+ try {
+ return JSON.parse(body) as unknown;
+ } catch {
+ return undefined;
+ }
+}
+
/** Мок fetch, который падает на уровне сети (сервер недоступен). */
export function installFailingFetch() {
const fetchMock = vi.fn(() => Promise.reject(new TypeError('Failed to fetch')));
@@ -136,6 +150,8 @@ export function resetStores(): void {
useSessionStore.getState().reset();
useMessagesStore.getState().reset();
useVoiceStore.getState().reset();
+ // Голосовая сессия могла остаться от прошлого теста: гасим её без запросов.
+ useVoiceConnectionStore.getState().reset();
useFriendsStore.getState().reset();
resetUiStore();
// `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста
@@ -205,6 +221,18 @@ interface SnapshotGuildInput {
my_nickname?: string;
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
emojis?: { id: string; name: string; file_id: string; animated?: boolean; token?: string }[];
+ /** Кто сидит в голосовых комнатах (AGENT.md 7.14). */
+ voice_states?: {
+ user_id: string;
+ channel_id: string;
+ self_mute?: boolean;
+ self_deaf?: boolean;
+ server_mute?: boolean;
+ server_deaf?: boolean;
+ camera?: boolean;
+ screen?: boolean;
+ joined_at?: string;
+ }[];
channels?: {
id: string;
name: string;
@@ -269,6 +297,18 @@ export function makeReadySnapshot(
animated: emoji.animated ?? false,
token: emoji.token ?? `<:${emoji.name}:${emoji.file_id}>`,
})),
+ voice_states: (guild.voice_states ?? []).map((state, index) => ({
+ user_id: state.user_id,
+ channel_id: state.channel_id,
+ guild_id: guild.id,
+ self_mute: state.self_mute ?? false,
+ self_deaf: state.self_deaf ?? false,
+ server_mute: state.server_mute ?? false,
+ server_deaf: state.server_deaf ?? false,
+ camera: state.camera ?? false,
+ screen: state.screen ?? false,
+ joined_at: state.joined_at ?? `2026-09-19T10:0${String(index)}:00Z`,
+ })),
})),
dm_channels: dmChannels.map((channel) => ({
id: channel.id,
diff --git a/web/tests/voice.test.tsx b/web/tests/voice.test.tsx
new file mode 100644
index 0000000..f2479ba
--- /dev/null
+++ b/web/tests/voice.test.tsx
@@ -0,0 +1,488 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+vi.mock('livekit-client', async () => {
+ const fake = await import('./fakeLiveKit');
+ return {
+ Room: fake.FakeRoom,
+ RoomEvent: fake.RoomEvent,
+ ConnectionQuality: fake.ConnectionQuality,
+ Track: fake.Track,
+ };
+});
+
+import { FakeRoom } from './fakeLiveKit';
+import {
+ installFetch,
+ installGatewaySocket,
+ json,
+ makeReadySnapshot,
+ makeUser,
+ recordedRequests,
+ renderApp,
+ type FetchRoute,
+ type RecordedRequest,
+} from './helpers';
+import { makeVoiceState, voiceRoutes, type VoiceRoutesOptions } from './voiceRoutes';
+import type { InstanceMeta } from '@/api/meta';
+import { useVoiceConnectionStore } from '@/stores/voiceConnection';
+
+const ALL_PERMISSIONS = [
+ 'VIEW_GUILD',
+ 'SEND_MESSAGES',
+ 'CONNECT_VOICE',
+ 'MUTE_MEMBERS',
+ 'DEAFEN_MEMBERS',
+ 'MOVE_MEMBERS',
+];
+
+const members = [
+ {
+ user_id: 'user-1',
+ username: 'alice',
+ display_name: 'Alice',
+ status: 'online',
+ is_instance_admin: false,
+ joined_at: '2026-09-19T09:00:00Z',
+ role_ids: [],
+ },
+ {
+ user_id: 'user-2',
+ username: 'bob',
+ display_name: 'Bob',
+ status: 'online',
+ is_instance_admin: false,
+ joined_at: '2026-09-19T09:00:00Z',
+ role_ids: [],
+ },
+];
+
+const enabledMeta: InstanceMeta = {
+ name: 'glchat',
+ version: 'v0.1.0-test',
+ commit: 'deadbee',
+ build_date: '2026-09-19T00:00:00Z',
+ api_version: 'v1',
+ base_url: 'https://gl.localhost',
+ files_url: 'https://files.gl.localhost',
+ gateway_url: 'wss://gl.localhost/gateway',
+ rtc_path: '/rtc',
+ max_upload_size: 26_214_400,
+ features: {
+ registration_enabled: true,
+ anti_bot_enabled: false,
+ voice_enabled: true,
+ web_push_enabled: false,
+ passkeys_enabled: false,
+ oauth_enabled: false,
+ },
+};
+
+interface BootOptions extends VoiceRoutesOptions {
+ path?: string;
+ permissions?: string[];
+ /** Участники, которые уже сидят в комнатах (снапшот READY). */
+ seated?: Parameters[0][];
+ meta?: InstanceMeta;
+}
+
+/** Поднимает приложение, играет рукопожатие шлюза и мокает голосовые ручки. */
+async function bootVoice(options: BootOptions = {}) {
+ const snapshot = makeReadySnapshot([
+ {
+ id: 'g-1',
+ name: 'Сервер',
+ is_main: true,
+ my_permissions: options.permissions ?? ALL_PERMISSIONS,
+ channels: [
+ { id: 'c-general', name: 'общий', position: 0 },
+ { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 1 },
+ { id: 'c-other', name: 'Вторая', type: 'voice', position: 2 },
+ ],
+ voice_states: options.seated ?? [],
+ },
+ ]);
+
+ const voice = voiceRoutes({
+ channelIds: ['c-voice', 'c-other'],
+ ...options,
+ // READY и REST должны рассказывать про один и тот же состав комнат.
+ states: (options.states ?? options.seated ?? []).map((state) => makeVoiceState(state)),
+ });
+ const fetchMock = installFetch([
+ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
+ { match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
+ { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
+ { match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
+ { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
+ { match: '/api/v1/meta', response: () => json(options.meta ?? enabledMeta) },
+ ...voice.routes,
+ ] as FetchRoute[]);
+
+ const gateway = installGatewaySocket(snapshot);
+ renderApp(options.path ?? '/app/g-1/c-general');
+ await screen.findByTestId('guild-rail');
+ await gateway.greet();
+ return { ...voice, fetchMock, gateway };
+}
+
+/** Последний запрос по фильтру: так проверяются PATCH и leave. */
+function lastRequest(
+ fetchMock: ReturnType,
+ predicate: { url: string; method?: string },
+): RecordedRequest | undefined {
+ return recordedRequests(fetchMock)
+ .filter(
+ (request) =>
+ request.url.includes(predicate.url) &&
+ (predicate.method === undefined || request.method === predicate.method.toUpperCase()),
+ )
+ .at(-1);
+}
+
+/** Клик по голосовой комнате в сайдбаре: вход и переход на её страницу. */
+async function clickChannel(name: string): Promise {
+ await userEvent.click(await screen.findByLabelText(`Открыть комнату ${name}`));
+}
+
+async function waitConnected(): Promise {
+ await waitFor(() => {
+ expect(useVoiceConnectionStore.getState().status).toBe('connected');
+ });
+}
+
+beforeEach(() => {
+ FakeRoom.reset();
+});
+
+describe('голосовая комната: вход', () => {
+ it('входит по клику на канал и рисует участников', async () => {
+ const voice = await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
+
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ // Сервер записал состояние, комната SDK создана.
+ expect(voice.joinCount()).toBe(1);
+ expect(FakeRoom.latest().connectCalls).toHaveLength(1);
+ expect(await screen.findByTestId('voice-tile-user-2')).toBeVisible();
+ expect(screen.getByTestId('voice-tile-user-1')).toBeVisible();
+ // Список участников под голосовой комнатой в сайдбаре.
+ const list = screen.getByLabelText('Участники комнаты «Голосовая»');
+ expect(within(list).getByLabelText('Участник Bob')).toBeVisible();
+ });
+
+ it('подключается кнопкой в панели комнаты', async () => {
+ await bootVoice({ path: '/app/g-1/c-voice' });
+
+ await userEvent.click(await screen.findByTestId('voice-join'));
+
+ await waitConnected();
+ expect(FakeRoom.latest().connectCalls).toHaveLength(1);
+ });
+
+ it('при выключенном голосе показывает подсказку вместо подключения', async () => {
+ const voice = await bootVoice({
+ meta: { ...enabledMeta, features: { ...enabledMeta.features, voice_enabled: false } },
+ });
+
+ await clickChannel('Голосовая');
+
+ expect(await screen.findByTestId('voice-disabled')).toHaveTextContent(
+ 'Голос не настроен на инстансе',
+ );
+ expect(screen.queryByTestId('voice-join')).toBeNull();
+ expect(voice.joinCount()).toBe(0);
+ });
+
+ it('переходит в другую комнату через выход из старой', async () => {
+ const voice = await bootVoice();
+
+ await clickChannel('Голосовая');
+ await waitConnected();
+ await clickChannel('Вторая');
+ await waitFor(() => {
+ expect(useVoiceConnectionStore.getState().channelId).toBe('c-other');
+ });
+
+ const requests = recordedRequests(voice.fetchMock)
+ .filter((request) => request.url.includes('/voice/'))
+ .map((request) => `${request.method} ${request.url.split('/api/v1')[1] ?? ''}`);
+ expect(requests).toEqual([
+ 'POST /channels/c-voice/voice/join',
+ 'POST /channels/c-voice/voice/leave',
+ 'POST /channels/c-other/voice/join',
+ ]);
+ });
+
+ it('показывает ошибку «в комнате нет свободных мест»', async () => {
+ await bootVoice({ joinError: { code: 'voice.channel_full', status: 403 } });
+
+ await clickChannel('Голосовая');
+
+ // Ошибку показывают и сайдбар под комнатой, и панель комнаты.
+ expect((await screen.findAllByText('В комнате нет свободных мест.')).length).toBeGreaterThan(0);
+ expect(useVoiceConnectionStore.getState().status).toBe('error');
+ });
+
+ it('показывает ошибку нехватки прав', async () => {
+ await bootVoice({ joinError: { code: 'perm.denied', status: 403 } });
+
+ await clickChannel('Голосовая');
+
+ expect(
+ (await screen.findAllByText('Недостаточно прав для этого действия.')).length,
+ ).toBeGreaterThan(0);
+ });
+});
+
+describe('голосовая комната: панель пользователя', () => {
+ it('кнопки микрофона, камеры и наушников управляют SDK и уходят на сервер', async () => {
+ const { fetchMock } = await bootVoice();
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ const panel = screen.getByLabelText('Панель пользователя');
+ await userEvent.click(within(panel).getByLabelText('Выключить микрофон'));
+
+ expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
+ method: 'setMicrophoneEnabled',
+ value: false,
+ });
+ await waitFor(() => {
+ expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
+ self_mute: true,
+ });
+ });
+ expect(within(panel).getByLabelText('Включить микрофон')).toHaveAttribute(
+ 'aria-pressed',
+ 'false',
+ );
+
+ await userEvent.click(within(panel).getByLabelText('Включить камеру'));
+ expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
+ method: 'setCameraEnabled',
+ value: true,
+ });
+ await waitFor(() => {
+ expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
+ camera: true,
+ });
+ });
+
+ await userEvent.click(within(panel).getByLabelText('Выключить звук'));
+ await waitFor(() => {
+ expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
+ self_deaf: true,
+ });
+ });
+ });
+
+ it('серверный мьют блокирует кнопку микрофона с подсказкой', async () => {
+ const { gateway } = await bootVoice();
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ act(() => {
+ gateway.dispatch(
+ 'VOICE_STATE_UPDATE',
+ makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_mute: true }),
+ );
+ });
+
+ const panel = screen.getByLabelText('Панель пользователя');
+ const mic = await within(panel).findByLabelText('Микрофон выключен модератором');
+ expect(mic).toBeDisabled();
+ expect(mic).toHaveAttribute('title', 'Микрофон выключен модератором');
+ expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
+ });
+
+ it('серверное глушение блокирует кнопку наушников с подсказкой', async () => {
+ const { gateway } = await bootVoice();
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ act(() => {
+ gateway.dispatch(
+ 'VOICE_STATE_UPDATE',
+ makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_deaf: true }),
+ );
+ });
+
+ const panel = screen.getByLabelText('Панель пользователя');
+ const sound = await within(panel).findByLabelText('Звук выключен модератором');
+ expect(sound).toBeDisabled();
+ });
+
+ it('смена устройства применяется к сессии', async () => {
+ await bootVoice();
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ await act(async () => {
+ await useVoiceConnectionStore.getState().switchActiveDevice('audioinput', 'mic-2');
+ });
+
+ expect(FakeRoom.latest().deviceSwitches).toEqual([{ kind: 'audioinput', deviceId: 'mic-2' }]);
+ });
+});
+
+describe('голосовая комната: события шлюза', () => {
+ it('VOICE_STATE_UPDATE добавляет и убирает участника без перезагрузки', async () => {
+ const { gateway } = await bootVoice();
+
+ expect(screen.queryByLabelText('Участник Bob')).toBeNull();
+
+ act(() => {
+ gateway.dispatch(
+ 'VOICE_STATE_UPDATE',
+ makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' }),
+ );
+ });
+ expect(await screen.findByLabelText('Участник Bob')).toBeVisible();
+
+ act(() => {
+ gateway.dispatch('VOICE_STATE_UPDATE', {
+ guild_id: 'g-1',
+ channel_id: 'c-voice',
+ user_id: 'user-2',
+ left: true,
+ });
+ });
+ await waitFor(() => {
+ expect(screen.queryByLabelText('Участник Bob')).toBeNull();
+ });
+ });
+
+ it('показывает индикацию речи участника', async () => {
+ await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ act(() => {
+ FakeRoom.latest().addRemote('user-2', 'Bob');
+ FakeRoom.latest().activeSpeakers(['user-2']);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('voice-tile-user-2')).toHaveAttribute('data-speaking', 'true');
+ });
+ });
+
+ it('показывает и скрывает подсказку о речи при выключенном микрофоне', async () => {
+ await bootVoice();
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ await act(async () => {
+ await useVoiceConnectionStore.getState().setMicEnabled(false);
+ });
+ act(() => {
+ FakeRoom.latest().activeSpeakers(['user-1']);
+ });
+
+ const notice = await screen.findByTestId('voice-speaking-notice');
+ expect(notice).toHaveTextContent('Вы говорите с выключенным микрофоном');
+
+ await userEvent.click(within(notice).getByLabelText('Скрыть'));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('voice-speaking-notice')).toBeNull();
+ });
+ });
+});
+
+describe('голосовая комната: выход', () => {
+ it('отключается по кнопке и очищает состояние', async () => {
+ const { fetchMock } = await bootVoice({
+ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }],
+ });
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ const tile = screen.getByTestId('voice-tile-user-1');
+ await userEvent.click(within(tile).getByLabelText('Отключиться'));
+
+ await waitFor(() => {
+ expect(useVoiceConnectionStore.getState().status).toBe('idle');
+ });
+ expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
+ expect(screen.queryByTestId('voice-tile-user-1')).toBeNull();
+ // Своя строка исчезла из сайдбара, а оставшийся участник — на месте.
+ expect(screen.queryByLabelText('Участник Alice')).toBeNull();
+ expect(screen.getAllByLabelText('Участник Bob').length).toBeGreaterThan(0);
+ });
+
+ it('отключается по двойному клику на комнату в сайдбаре', async () => {
+ const { fetchMock } = await bootVoice();
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ await userEvent.dblClick(await screen.findByLabelText('Открыть комнату Голосовая'));
+
+ await waitFor(() => {
+ expect(useVoiceConnectionStore.getState().status).toBe('idle');
+ });
+ expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
+ });
+});
+
+describe('голосовая комната: модерация', () => {
+ it('серверный мьют участника из меню', async () => {
+ const voice = await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ await userEvent.click(
+ within(await screen.findByTestId('voice-tile-user-2')).getByLabelText('Участник Bob'),
+ );
+ await userEvent.click(await screen.findByRole('menuitem', { name: 'Выключить микрофон' }));
+
+ await waitFor(() => {
+ expect(voice.states.find((state) => state.user_id === 'user-2')?.server_mute).toBe(true);
+ });
+ expect(
+ lastRequest(voice.fetchMock, { url: '/voice-states/user-2', method: 'PATCH' })?.body,
+ ).toEqual({ server_mute: true });
+ });
+
+ it('перемещение участника в другую комнату из меню', async () => {
+ const voice = await bootVoice({
+ channelIds: ['c-voice', 'c-other'],
+ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }],
+ });
+ await clickChannel('Голосовая');
+ await waitConnected();
+
+ await userEvent.click(
+ within(await screen.findByTestId('voice-tile-user-2')).getByLabelText('Участник Bob'),
+ );
+ await userEvent.click(await screen.findByRole('menuitem', { name: 'Переместить в «Вторая»' }));
+
+ await waitFor(() => {
+ expect(voice.states.find((state) => state.user_id === 'user-2')?.channel_id).toBe('c-other');
+ });
+ });
+
+ it('перемещает участника перетаскиванием на голосовую комнату', async () => {
+ const voice = await bootVoice({
+ channelIds: ['c-voice', 'c-other'],
+ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }],
+ });
+ const target = await screen.findByLabelText('Открыть комнату Вторая');
+ const dataTransfer = {
+ types: ['application/x-glchat-voice-user'],
+ getData: () => 'user-2',
+ setData: vi.fn(),
+ dropEffect: 'none',
+ };
+
+ fireEvent.dragOver(target, { dataTransfer });
+ fireEvent.drop(target, { dataTransfer });
+
+ await waitFor(() => {
+ expect(voice.states.find((state) => state.user_id === 'user-2')?.channel_id).toBe('c-other');
+ });
+ });
+});
diff --git a/web/tests/voiceConnection.test.ts b/web/tests/voiceConnection.test.ts
new file mode 100644
index 0000000..cb85514
--- /dev/null
+++ b/web/tests/voiceConnection.test.ts
@@ -0,0 +1,505 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('livekit-client', async () => {
+ const fake = await import('./fakeLiveKit');
+ return {
+ Room: fake.FakeRoom,
+ RoomEvent: fake.RoomEvent,
+ ConnectionQuality: fake.ConnectionQuality,
+ Track: fake.Track,
+ };
+});
+
+import { FakeRoom, RoomEvent } from './fakeLiveKit';
+import {
+ installFetch,
+ makeReadySnapshot,
+ recordedRequests,
+ resetStores,
+ type RecordedRequest,
+} from './helpers';
+import { fakeVoiceToken, makeVoiceState, voiceRoutes, type VoiceRoutes } from './voiceRoutes';
+import { parseGatewaySnapshot } from '@/api/gateway';
+import { dispatchGatewayEvent } from '@/stores/gateway';
+import { useSessionStore } from '@/stores/session';
+import { useVoiceStore } from '@/stores/voice';
+import {
+ SPEAKING_WARNING_INTERVAL_MS,
+ SPEAKING_WARNING_VISIBLE_MS,
+ TOKEN_REFRESH_MIN_DELAY_MS,
+ useVoiceConnectionStore,
+ VOICE_SESSION_EXPIRED_CODE,
+} from '@/stores/voiceConnection';
+
+const ALL_PERMISSIONS = [
+ 'VIEW_GUILD',
+ 'CONNECT_VOICE',
+ 'MUTE_MEMBERS',
+ 'DEAFEN_MEMBERS',
+ 'MOVE_MEMBERS',
+];
+
+/** Сессия: пользователь и сервер с двумя голосовыми комнатами. */
+function seedSession(voiceStates: Parameters[0][] = []): void {
+ const snapshot = parseGatewaySnapshot(
+ makeReadySnapshot([
+ {
+ id: 'g-1',
+ name: 'Сервер',
+ my_permissions: ALL_PERMISSIONS,
+ channels: [
+ { id: 'c-1', name: 'общий' },
+ { id: 'c-voice', name: 'Голосовая', type: 'voice' },
+ { id: 'c-other', name: 'Вторая', type: 'voice' },
+ ],
+ voice_states: voiceStates,
+ },
+ ]),
+ );
+ if (snapshot === null) {
+ throw new Error('снимок READY не разобрался');
+ }
+ useSessionStore.getState().applyReady(snapshot);
+}
+
+/** Ждёт выполнения цепочки промисов (в том числе под фейковыми таймерами). */
+async function flush(times = 12): Promise {
+ for (let index = 0; index < times; index += 1) {
+ await Promise.resolve();
+ }
+}
+
+/** Последний запрос, подходящий под фильтр. */
+function lastRequest(
+ fetchMock: ReturnType,
+ predicate: { url: string; method?: string },
+): RecordedRequest | undefined {
+ const matching = recordedRequests(fetchMock).filter(
+ (request) =>
+ request.url.includes(predicate.url) &&
+ (predicate.method === undefined || request.method === predicate.method.toUpperCase()),
+ );
+ return matching.at(-1);
+}
+
+/** Вход в комнату «как есть»: с готовыми маршрутами и сессией. */
+async function joinRoom(
+ channelId = 'c-voice',
+ voice: VoiceRoutes = voiceRoutes(),
+): Promise> {
+ const fetchMock = installFetch([...voice.routes]);
+ await useVoiceConnectionStore.getState().join('g-1', channelId);
+ return fetchMock;
+}
+
+beforeEach(() => {
+ resetStores();
+ FakeRoom.reset();
+ seedSession();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('голосовая сессия: подключение', () => {
+ it('входит в комнату по токену и публикует микрофон', async () => {
+ const voice = voiceRoutes();
+ const fetchMock = installFetch([...voice.routes]);
+
+ await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
+
+ const state = useVoiceConnectionStore.getState();
+ expect(state.status).toBe('connected');
+ expect(state.guildId).toBe('g-1');
+ expect(state.channelId).toBe('c-voice');
+ expect(state.error).toBeNull();
+ expect(state.participants.map((participant) => participant.userId)).toEqual(['user-1']);
+
+ const [call] = FakeRoom.latest().connectCalls;
+ expect(call?.url).toBe('wss://gl.localhost/rtc');
+ expect(call?.token).not.toBe('');
+ expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
+ method: 'setMicrophoneEnabled',
+ value: true,
+ });
+
+ // Сервер уже знает о нас: состояние легло в снапшот сессии.
+ expect(lastRequest(fetchMock, { url: '/voice/join', method: 'POST' })).toBeDefined();
+ expect(
+ useSessionStore.getState().voiceStates['g-1']?.some((item) => item.user_id === 'user-1'),
+ ).toBe(true);
+ });
+
+ it('показывает участников комнаты из SDK с речью и качеством связи', async () => {
+ await joinRoom();
+ const room = FakeRoom.latest();
+ room.addRemote('user-2', 'Bob', { quality: 'poor' });
+ room.activeSpeakers(['user-2']);
+
+ const state = useVoiceConnectionStore.getState();
+ expect(state.participants.map((participant) => participant.userId)).toEqual([
+ 'user-1',
+ 'user-2',
+ ]);
+ const bob = state.participants.find((participant) => participant.userId === 'user-2');
+ expect(bob?.isSpeaking).toBe(true);
+ expect(bob?.quality).toBe('poor');
+ expect(bob?.audio).toBeDefined();
+ });
+
+ it('повторный вход в ту же комнату ничего не делает', async () => {
+ const voice = voiceRoutes();
+ const fetchMock = await joinRoom('c-voice', voice);
+
+ await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
+
+ expect(voice.joinCount()).toBe(1);
+ expect(lastRequest(fetchMock, { url: '/voice/join', method: 'POST' })).toBeDefined();
+ });
+
+ it('переходит в другую комнату через выход и вход', async () => {
+ const voice = voiceRoutes({ channelIds: ['c-voice', 'c-other'] });
+ const fetchMock = installFetch([...voice.routes]);
+
+ await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
+ await useVoiceConnectionStore.getState().join('g-1', 'c-other');
+
+ const voiceRequests = recordedRequests(fetchMock)
+ .filter((request) => request.url.includes('/voice/'))
+ .map((request) => `${request.method} ${request.url.split('/api/v1')[1] ?? ''}`);
+ expect(voiceRequests).toEqual([
+ 'POST /channels/c-voice/voice/join',
+ 'POST /channels/c-voice/voice/leave',
+ 'POST /channels/c-other/voice/join',
+ ]);
+ expect(useVoiceConnectionStore.getState().channelId).toBe('c-other');
+ });
+
+ it('выходит из комнаты и очищает состояние', async () => {
+ const fetchMock = await joinRoom();
+ FakeRoom.latest().addRemote('user-2', 'Bob');
+
+ await useVoiceConnectionStore.getState().leave();
+
+ const state = useVoiceConnectionStore.getState();
+ expect(state.status).toBe('idle');
+ expect(state.channelId).toBeNull();
+ expect(state.participants).toEqual([]);
+ expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
+ expect(FakeRoom.latest().disconnectCalls).toBe(1);
+ });
+});
+
+describe('голосовая сессия: флаги', () => {
+ it('кнопки микрофона, камеры и наушников управляют SDK и уходят на сервер', async () => {
+ const fetchMock = await joinRoom();
+ const store = useVoiceConnectionStore.getState();
+
+ await store.setMicEnabled(false);
+ expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
+ method: 'setMicrophoneEnabled',
+ value: false,
+ });
+ expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
+ self_mute: true,
+ });
+
+ await useVoiceConnectionStore.getState().setCameraEnabled(true);
+ expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
+ method: 'setCameraEnabled',
+ value: true,
+ });
+ expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
+ camera: true,
+ });
+
+ await useVoiceConnectionStore.getState().setDeafened(true);
+ expect(useVoiceConnectionStore.getState().deafened).toBe(true);
+ expect(useVoiceStore.getState().headphonesEnabled).toBe(false);
+ expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
+ self_deaf: true,
+ });
+
+ await useVoiceConnectionStore.getState().setScreenShareEnabled(true);
+ expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
+ method: 'setScreenShareEnabled',
+ value: true,
+ });
+ expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
+ screen: true,
+ });
+ });
+
+ it('без подключения кнопки только сохраняют предпочтения', async () => {
+ const fetchMock = installFetch([]);
+
+ await useVoiceConnectionStore.getState().setMicEnabled(false);
+
+ expect(useVoiceStore.getState().micEnabled).toBe(false);
+ expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })).toBeUndefined();
+ });
+
+ it('серверный мьют выключает микрофон и блокирует включение', async () => {
+ const fetchMock = await joinRoom();
+
+ dispatchGatewayEvent({
+ op: 0,
+ t: 'VOICE_STATE_UPDATE',
+ s: 1,
+ d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_mute: true }),
+ });
+ await flush();
+
+ const state = useVoiceConnectionStore.getState();
+ expect(state.serverMuted).toBe(true);
+ expect(state.micEnabled).toBe(false);
+ expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
+ method: 'setMicrophoneEnabled',
+ value: false,
+ });
+
+ // Пользователь не может включить микрофон, пока модератор не снимет мьют.
+ await state.setMicEnabled(true);
+ expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
+ expect(useVoiceStore.getState().micEnabled).toBe(true);
+
+ // Модератор снял мьют — кнопка снова доступна.
+ dispatchGatewayEvent({
+ op: 0,
+ t: 'VOICE_STATE_UPDATE',
+ s: 2,
+ d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_mute: false }),
+ });
+ await flush();
+ expect(useVoiceConnectionStore.getState().serverMuted).toBe(false);
+ await useVoiceConnectionStore.getState().setMicEnabled(true);
+ expect(useVoiceConnectionStore.getState().micEnabled).toBe(true);
+ expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
+ self_mute: false,
+ });
+ });
+
+ it('серверное глушение блокирует кнопку наушников', async () => {
+ await joinRoom();
+
+ dispatchGatewayEvent({
+ op: 0,
+ t: 'VOICE_STATE_UPDATE',
+ s: 1,
+ d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_deaf: true }),
+ });
+ await flush();
+
+ expect(useVoiceConnectionStore.getState().serverDeafened).toBe(true);
+ expect(useVoiceConnectionStore.getState().deafened).toBe(true);
+ });
+
+ it('личная громкость участника хранится локально', async () => {
+ await joinRoom();
+
+ useVoiceConnectionStore.getState().setVolume('user-2', 40);
+
+ expect(useVoiceStore.getState().participantVolumes['user-2']).toBe(40);
+ });
+
+ it('перемещение и модерация уходят на сервер', async () => {
+ const voice = voiceRoutes({
+ states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })],
+ });
+ const fetchMock = await joinRoom('c-voice', voice);
+
+ await useVoiceConnectionStore.getState().setParticipantMuted('g-1', 'user-2', true);
+ expect(voice.states.find((item) => item.user_id === 'user-2')?.server_mute).toBe(true);
+ expect(lastRequest(fetchMock, { url: '/voice-states/user-2', method: 'PATCH' })?.body).toEqual({
+ server_mute: true,
+ });
+
+ await useVoiceConnectionStore.getState().setParticipantDeafened('g-1', 'user-2', true);
+ expect(voice.states.find((item) => item.user_id === 'user-2')?.server_deaf).toBe(true);
+ expect(lastRequest(fetchMock, { url: '/voice-states/user-2', method: 'PATCH' })?.body).toEqual({
+ server_deaf: true,
+ });
+
+ await useVoiceConnectionStore.getState().moveParticipant('g-1', 'user-2', 'c-other');
+ expect(voice.states.find((item) => item.user_id === 'user-2')?.channel_id).toBe('c-other');
+ expect(
+ lastRequest(fetchMock, { url: '/voice-states/user-2/move', method: 'POST' })?.body,
+ ).toEqual({ channel_id: 'c-other' });
+ });
+});
+
+describe('голосовая сессия: синхронизация с сервером', () => {
+ it('VOICE_STATE_UPDATE добавляет и убирает участника', async () => {
+ await joinRoom();
+
+ dispatchGatewayEvent({
+ op: 0,
+ t: 'VOICE_STATE_UPDATE',
+ s: 1,
+ d: makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' }),
+ });
+ expect(
+ useSessionStore.getState().voiceStates['g-1']?.some((item) => item.user_id === 'user-2'),
+ ).toBe(true);
+
+ dispatchGatewayEvent({
+ op: 0,
+ t: 'VOICE_STATE_UPDATE',
+ s: 2,
+ d: { guild_id: 'g-1', channel_id: 'c-voice', user_id: 'user-2', left: true },
+ });
+ expect(
+ useSessionStore.getState().voiceStates['g-1']?.some((item) => item.user_id === 'user-2'),
+ ).toBe(false);
+ });
+
+ it('переподключается в комнату, куда нас переместил модератор', async () => {
+ const voice = voiceRoutes({ channelIds: ['c-voice', 'c-other'] });
+ const fetchMock = installFetch([...voice.routes]);
+ await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
+ const first = FakeRoom.latest();
+
+ dispatchGatewayEvent({
+ op: 0,
+ t: 'VOICE_STATE_UPDATE',
+ s: 1,
+ d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-other' }),
+ });
+ await flush(24);
+
+ expect(useVoiceConnectionStore.getState().channelId).toBe('c-other');
+ // Старая комната закрыта, для новой создана своя сессия SDK.
+ expect(first.disconnectCalls).toBe(1);
+ expect(FakeRoom.instances).toHaveLength(2);
+ expect(FakeRoom.latest().connectCalls).toHaveLength(1);
+ expect(
+ lastRequest(fetchMock, { url: '/channels/c-other/voice/join', method: 'POST' }),
+ ).toBeDefined();
+ });
+
+ it('окончательный разрыв соединения возвращает в idle и шлёт leave', async () => {
+ const fetchMock = await joinRoom();
+
+ FakeRoom.latest().emit(RoomEvent.Disconnected);
+ await flush(24);
+
+ expect(useVoiceConnectionStore.getState().status).toBe('idle');
+ expect(useVoiceConnectionStore.getState().participants).toEqual([]);
+ expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
+ });
+
+ it('во время переподключения показывает статус reconnecting', async () => {
+ await joinRoom();
+
+ FakeRoom.latest().emit(RoomEvent.Reconnecting);
+ expect(useVoiceConnectionStore.getState().status).toBe('reconnecting');
+
+ FakeRoom.latest().emit(RoomEvent.Reconnected);
+ expect(useVoiceConnectionStore.getState().status).toBe('connected');
+ });
+});
+
+describe('голосовая сессия: продление токена', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-09-19T10:00:00Z'));
+ });
+
+ it('за минуту до истечения берёт новый токен и переподключается с флагами', async () => {
+ const first = fakeVoiceToken(30);
+ const second = fakeVoiceToken(3600);
+ const voice = voiceRoutes({ tokens: [first, second] });
+ const fetchMock = await joinRoom('c-voice', voice);
+
+ await useVoiceConnectionStore.getState().setMicEnabled(false);
+ await useVoiceConnectionStore.getState().setCameraEnabled(true);
+
+ // exp через 30 секунд: продление планируется на минимальную задержку.
+ await vi.advanceTimersByTimeAsync(TOKEN_REFRESH_MIN_DELAY_MS + 1);
+ await flush(24);
+
+ const room = FakeRoom.latest();
+ expect(room.connectCalls.map((call) => call.token)).toEqual([first, second]);
+ expect(room.disconnectCalls).toBe(1);
+ expect(voice.joinCount()).toBe(2);
+ // Флаги применились заново и подтверждены на сервере.
+ expect(
+ room.localParticipant.calls.filter((call) => call.method === 'setMicrophoneEnabled'),
+ ).toEqual([
+ { method: 'setMicrophoneEnabled', value: true },
+ { method: 'setMicrophoneEnabled', value: false },
+ { method: 'setMicrophoneEnabled', value: false },
+ ]);
+ expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
+ self_mute: true,
+ self_deaf: false,
+ camera: true,
+ screen: false,
+ });
+ expect(useVoiceConnectionStore.getState().status).toBe('connected');
+ expect(useVoiceConnectionStore.getState().refreshing).toBe(false);
+ });
+
+ it('после трёх неудач выходит из комнаты и показывает ошибку', async () => {
+ const voice = voiceRoutes({
+ tokens: [fakeVoiceToken(30)],
+ joinSuccesses: 1,
+ });
+ const fetchMock = await joinRoom('c-voice', voice);
+
+ await vi.advanceTimersByTimeAsync(TOKEN_REFRESH_MIN_DELAY_MS + 1);
+ await flush(40);
+
+ const state = useVoiceConnectionStore.getState();
+ expect(voice.joinCount()).toBe(4);
+ expect(state.status).toBe('error');
+ expect(state.error).toEqual({ code: VOICE_SESSION_EXPIRED_CODE });
+ expect(state.participants).toEqual([]);
+ expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
+ });
+});
+
+describe('голосовая сессия: предупреждение о речи', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-09-19T10:00:00Z'));
+ });
+
+ it('показывает подсказку не чаще одного раза в 5 секунд', async () => {
+ await joinRoom();
+ await useVoiceConnectionStore.getState().setMicEnabled(false);
+ const room = FakeRoom.latest();
+
+ room.activeSpeakers(['user-1']);
+ expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(true);
+
+ // Подсказка сама исчезает, но раньше пяти секунд не повторяется.
+ await vi.advanceTimersByTimeAsync(SPEAKING_WARNING_VISIBLE_MS + 1);
+ expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false);
+ room.activeSpeakers(['user-1']);
+ expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false);
+
+ await vi.advanceTimersByTimeAsync(SPEAKING_WARNING_INTERVAL_MS);
+ room.activeSpeakers(['user-1']);
+ expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(true);
+ });
+
+ it('молчит, когда микрофон включён', async () => {
+ await joinRoom();
+
+ FakeRoom.latest().activeSpeakers(['user-1']);
+
+ expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false);
+ });
+
+ it('подсказку можно скрыть вручную', async () => {
+ await joinRoom();
+ await useVoiceConnectionStore.getState().setMicEnabled(false);
+ FakeRoom.latest().activeSpeakers(['user-1']);
+
+ useVoiceConnectionStore.getState().dismissSpeakingWarning();
+
+ expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false);
+ });
+});
diff --git a/web/tests/voiceRoutes.ts b/web/tests/voiceRoutes.ts
new file mode 100644
index 0000000..491fc9d
--- /dev/null
+++ b/web/tests/voiceRoutes.ts
@@ -0,0 +1,192 @@
+import { apiError, json, type FetchRoute } from './helpers';
+import type { VoiceState } from '@/api/voice';
+
+/**
+ * Маршруты голосовых ручек для тестов: вход/выход, свои флаги, модерация и
+ * перемещение. Состояние живёт в замыкании, поэтому ответы согласованы между
+ * запросами (как у настоящего сервера).
+ */
+
+/** JWT с `exp` через `expiresInSeconds`: из него клиент считает время продления. */
+export function fakeVoiceToken(expiresInSeconds = 3600): string {
+ const exp = Math.floor(Date.now() / 1000) + expiresInSeconds;
+ const payload = btoa(JSON.stringify({ exp }))
+ .replace(/=+$/u, '')
+ .replace(/\+/gu, '-')
+ .replace(/\//gu, '_');
+ return `header.${payload}.signature`;
+}
+
+export function makeVoiceState(
+ overrides: Partial & Pick,
+): VoiceState {
+ return {
+ guild_id: 'g-1',
+ self_mute: false,
+ self_deaf: false,
+ server_mute: false,
+ server_deaf: false,
+ camera: false,
+ screen: false,
+ joined_at: '2026-09-19T10:00:00Z',
+ ...overrides,
+ };
+}
+
+export interface VoiceRoutesOptions {
+ guildId?: string;
+ /** Комнаты, для которых работает вход (по умолчанию одна). */
+ channelIds?: string[];
+ /** Токены, которые выдаёт join по порядку; последний повторяется. */
+ tokens?: string[];
+ /** Стартовое состояние сервера: кто в комнатах. */
+ states?: VoiceState[];
+ /** Код ошибки входа (`voice.channel_full`, `perm.denied`, ...). */
+ joinError?: { code: string; status: number };
+ /** Сколько первых входов проходят успешно (для проверки продления токена). */
+ joinSuccesses?: number;
+}
+
+export interface VoiceRoutes {
+ routes: FetchRoute[];
+ /** Текущее состояние «сервера»: его проверяют тесты. */
+ states: VoiceState[];
+ /** Сколько раз запросили вход. */
+ joinCount: () => number;
+}
+
+export function voiceRoutes(options: VoiceRoutesOptions = {}): VoiceRoutes {
+ const guildId = options.guildId ?? 'g-1';
+ const channelIds = options.channelIds ?? ['c-voice'];
+ const tokens = options.tokens ?? [fakeVoiceToken()];
+ const states = [...(options.states ?? [])];
+ let issued = 0;
+ let joins = 0;
+
+ const nextToken = (): string => {
+ const token = tokens[Math.min(issued, tokens.length - 1)];
+ issued += 1;
+ return token ?? '';
+ };
+
+ const list = (): VoiceState[] => states;
+
+ const routes: FetchRoute[] = [
+ {
+ match: '/voice/join',
+ method: 'POST',
+ response: ({ url }) => {
+ joins += 1;
+ if (options.joinError !== undefined) {
+ return apiError(options.joinError.code, options.joinError.status);
+ }
+ if (options.joinSuccesses !== undefined && joins > options.joinSuccesses) {
+ return apiError('internal.error', 500);
+ }
+ const channelId =
+ channelIds.find((id) => url.includes(`/channels/${id}/`)) ?? channelIds[0] ?? '';
+ const state = makeVoiceState({
+ user_id: 'user-1',
+ channel_id: channelId,
+ guild_id: guildId,
+ self_mute: false,
+ });
+ const index = states.findIndex((item) => item.user_id === 'user-1');
+ if (index === -1) {
+ states.push(state);
+ } else {
+ states[index] = state;
+ }
+ return json({
+ token: nextToken(),
+ url: 'wss://gl.localhost/rtc',
+ room: `guild-${guildId}-${channelId}`,
+ channel_id: channelId,
+ guild_id: guildId,
+ state,
+ });
+ },
+ },
+ {
+ match: '/voice/leave',
+ method: 'POST',
+ response: () => {
+ const index = states.findIndex((item) => item.user_id === 'user-1');
+ if (index >= 0) {
+ states.splice(index, 1);
+ }
+ return json({ ok: true });
+ },
+ },
+ {
+ match: `/guilds/${guildId}/voice-states/@me`,
+ method: 'PATCH',
+ response: ({ body }) => {
+ const patch = (body ?? {}) as Record;
+ const index = states.findIndex((item) => item.user_id === 'user-1');
+ const current =
+ index === -1 ? makeVoiceState({ user_id: 'user-1', channel_id: '' }) : states[index];
+ if (current !== undefined) {
+ const next: VoiceState = { ...current };
+ if (typeof patch['self_mute'] === 'boolean') {
+ next.self_mute = patch['self_mute'];
+ }
+ if (typeof patch['self_deaf'] === 'boolean') {
+ next.self_deaf = patch['self_deaf'];
+ }
+ if (typeof patch['camera'] === 'boolean') {
+ next.camera = patch['camera'];
+ }
+ if (typeof patch['screen'] === 'boolean') {
+ next.screen = patch['screen'];
+ }
+ if (index === -1) {
+ states.push(next);
+ } else {
+ states[index] = next;
+ }
+ }
+ return json({ voice_states: list() });
+ },
+ },
+ {
+ match: `/guilds/${guildId}/voice-states/`,
+ method: 'PATCH',
+ response: ({ url, body }) => {
+ const userId = url.split('/voice-states/')[1]?.split(/[/?]/u)[0] ?? '';
+ const patch = (body ?? {}) as Record;
+ const index = states.findIndex((item) => item.user_id === userId);
+ const current = states[index];
+ if (current !== undefined) {
+ if (typeof patch['server_mute'] === 'boolean') {
+ current.server_mute = patch['server_mute'];
+ }
+ if (typeof patch['server_deaf'] === 'boolean') {
+ current.server_deaf = patch['server_deaf'];
+ }
+ }
+ return json({ voice_states: list() });
+ },
+ },
+ {
+ match: `/guilds/${guildId}/voice-states/`,
+ method: 'POST',
+ response: ({ url, body }) => {
+ const userId = url.split('/voice-states/')[1]?.split('/')[0] ?? '';
+ const target = ((body ?? {}) as Record)['channel_id'];
+ const index = states.findIndex((item) => item.user_id === userId);
+ const current = states[index];
+ if (current !== undefined && typeof target === 'string') {
+ current.channel_id = target;
+ }
+ return json({ voice_states: list() });
+ },
+ },
+ {
+ match: `/guilds/${guildId}/voice-states`,
+ response: () => json({ voice_states: list() }),
+ },
+ ];
+
+ return { routes, states, joinCount: () => joins };
+}