193 lines
6.6 KiB
TypeScript
193 lines
6.6 KiB
TypeScript
|
|
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 };
|
|||
|
|
}
|