Files

193 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<VoiceState> & Pick<VoiceState, 'user_id' | 'channel_id'>,
): 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<string, unknown>;
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<string, unknown>;
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<string, unknown>)['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 };
}