feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):
- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
не участник не получает прав роли @user; калькулятор прав общий для API и
Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.
Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { request } from './client';
|
||||
import type { SessionInfo, User } from './types';
|
||||
|
||||
export interface RegisterInput {
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface LoginInput {
|
||||
email: string;
|
||||
password: string;
|
||||
totp_code?: string;
|
||||
}
|
||||
|
||||
export interface LogoutAllResponse {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export const sessionsQueryKey = ['auth', 'sessions'] as const;
|
||||
|
||||
export function register(input: RegisterInput): Promise<{ user: User }> {
|
||||
return request<{ user: User }>('/auth/register', { method: 'POST', body: input });
|
||||
}
|
||||
|
||||
export function login(input: LoginInput): Promise<{ user: User }> {
|
||||
return request<{ user: User }>('/auth/login', { method: 'POST', body: input });
|
||||
}
|
||||
|
||||
export function logout(): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>('/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function logoutAll(): Promise<LogoutAllResponse> {
|
||||
return request<LogoutAllResponse>('/auth/logout-all', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchSessions(signal?: AbortSignal): Promise<SessionInfo[]> {
|
||||
const payload = await request<{ sessions: SessionInfo[] }>(
|
||||
'/auth/sessions',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.sessions;
|
||||
}
|
||||
|
||||
/** Подтверждение личности перед сменой пароля и включением 2FA. */
|
||||
export function stepUp(password: string, totpCode?: string): Promise<{ ok: true }> {
|
||||
const body: { password: string; totp_code?: string } = { password };
|
||||
if (totpCode !== undefined && totpCode !== '') {
|
||||
body.totp_code = totpCode;
|
||||
}
|
||||
return request<{ ok: true }>('/auth/step-up', { method: 'POST', body });
|
||||
}
|
||||
|
||||
export function setupTotp(): Promise<{ secret: string; otpauth_url: string }> {
|
||||
return request<{ secret: string; otpauth_url: string }>('/auth/2fa/setup', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function enableTotp(code: string): Promise<string[]> {
|
||||
const payload = await request<{ recovery_codes: string[] }>('/auth/2fa/enable', {
|
||||
method: 'POST',
|
||||
body: { code },
|
||||
});
|
||||
return payload.recovery_codes;
|
||||
}
|
||||
|
||||
/** Смена пароля: требует предварительного step-up. */
|
||||
export function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>('/users/@me/password', {
|
||||
method: 'POST',
|
||||
body: { current_password: currentPassword, new_password: newPassword },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import type { Channel, Role, UserStatus } from './types';
|
||||
|
||||
/**
|
||||
* Клиент шлюза (WebSocket) Фазы 1.
|
||||
*
|
||||
* Контракт: сервер присылает HELLO (op 10) с интервалом heartbeat, клиент
|
||||
* отвечает IDENTIFY (op 2, пустой токен — браузер аутентифицируется cookie
|
||||
* на рукопожатии), затем получает READY/RESUMED. Разрыв соединения лечится
|
||||
* экспоненциальной задержкой и RESUME (op 3) с последним полученным `s`.
|
||||
*
|
||||
* Модуль намеренно не зависит от React и Zustand: он принимает колбэки,
|
||||
* поэтому его можно проверить юнит-тестом с подставным WebSocket.
|
||||
*/
|
||||
|
||||
export const GatewayOp = {
|
||||
DISPATCH: 0,
|
||||
HEARTBEAT: 1,
|
||||
IDENTIFY: 2,
|
||||
RESUME: 3,
|
||||
INVALID_SESSION: 4,
|
||||
HELLO: 10,
|
||||
HEARTBEAT_ACK: 11,
|
||||
RECONNECT: 12,
|
||||
} as const;
|
||||
|
||||
export const GATEWAY_CLOSE_INVALID_SESSION = 4000;
|
||||
|
||||
export interface GatewayHello {
|
||||
heartbeat_interval_ms: number;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
/** Сервер в снапшоте READY: каналы/роли/права приходят сразу. */
|
||||
export interface GatewayGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
is_main: boolean;
|
||||
icon_file_id?: string;
|
||||
channels: Channel[];
|
||||
roles: Role[];
|
||||
member_ids: string[];
|
||||
my_role_ids: string[];
|
||||
my_nickname?: string;
|
||||
my_permissions?: string[];
|
||||
}
|
||||
|
||||
export interface GatewayUser {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_file_id?: string;
|
||||
is_instance_admin: boolean;
|
||||
badges: string[];
|
||||
status?: UserStatus;
|
||||
custom_status?: string;
|
||||
custom_status_emoji?: string;
|
||||
}
|
||||
|
||||
export interface GatewaySnapshot {
|
||||
user: GatewayUser;
|
||||
guilds: GatewayGuild[];
|
||||
dm_channels: Channel[];
|
||||
read_states: unknown[];
|
||||
session_id: string;
|
||||
heartbeat_interval_ms: number;
|
||||
}
|
||||
|
||||
export interface GatewayDispatch {
|
||||
op: 0;
|
||||
t: string;
|
||||
s: number;
|
||||
d: unknown;
|
||||
}
|
||||
|
||||
export type GatewayStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
|
||||
|
||||
export interface GatewayHandlers {
|
||||
/** Любое событие DISPATCH (READY, RESUMED и события гильдий/каналов). */
|
||||
onDispatch?: (event: GatewayDispatch) => void;
|
||||
onStatus?: (status: GatewayStatus) => void;
|
||||
/** Сессия недействительна — нужно разлогинить пользователя. */
|
||||
onInvalidSession?: () => void;
|
||||
/** RESUMED без снапшота: данные нужно перечитать по REST. */
|
||||
onResumeIncomplete?: () => void;
|
||||
}
|
||||
|
||||
export interface GatewayLogger {
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface GatewayConnectionOptions {
|
||||
url: string;
|
||||
handlers?: GatewayHandlers;
|
||||
/** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */
|
||||
socketFactory?: (url: string) => WebSocket;
|
||||
logger?: GatewayLogger;
|
||||
}
|
||||
|
||||
const DEFAULT_HEARTBEAT_MS = 45_000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
const BASE_BACKOFF_MS = 1_000;
|
||||
|
||||
interface GatewayPacket {
|
||||
op: number;
|
||||
t?: string;
|
||||
s?: number;
|
||||
d?: unknown;
|
||||
}
|
||||
|
||||
function isGatewayPacket(value: unknown): value is GatewayPacket {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as { op?: unknown }).op === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
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 : undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
}
|
||||
|
||||
function parseChannel(value: unknown): Channel | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const name = asString(record['name']);
|
||||
const type = asString(record['type']);
|
||||
if (id === undefined || name === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (type !== 'text' && type !== 'voice' && type !== 'category') {
|
||||
return null;
|
||||
}
|
||||
const channel: Channel = {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position: asNumber(record['position']) ?? 0,
|
||||
};
|
||||
const guildId = asString(record['guild_id']);
|
||||
const parentId = asString(record['parent_id']);
|
||||
const userLimit = asNumber(record['user_limit']);
|
||||
const slowmode = asNumber(record['slowmode_seconds']);
|
||||
if (guildId !== undefined) {
|
||||
channel.guild_id = guildId;
|
||||
}
|
||||
if (parentId !== undefined) {
|
||||
channel.parent_id = parentId;
|
||||
}
|
||||
if (userLimit !== undefined) {
|
||||
channel.user_limit = userLimit;
|
||||
}
|
||||
if (slowmode !== undefined) {
|
||||
channel.slowmode_seconds = slowmode;
|
||||
}
|
||||
if (typeof record['can_send'] === 'boolean') {
|
||||
channel.can_send = record['can_send'];
|
||||
}
|
||||
if (typeof record['can_connect'] === 'boolean') {
|
||||
channel.can_connect = record['can_connect'];
|
||||
}
|
||||
if (typeof record['can_view'] === 'boolean') {
|
||||
channel.can_view = record['can_view'];
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
function parseRole(value: unknown): Role | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const name = asString(record['name']);
|
||||
if (id === undefined || name === undefined) {
|
||||
return null;
|
||||
}
|
||||
const permissions = record['permissions'];
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
color: asNumber(record['color']) ?? 0,
|
||||
position: asNumber(record['position']) ?? 0,
|
||||
permissions:
|
||||
typeof permissions === 'string'
|
||||
? permissions
|
||||
: typeof permissions === 'number'
|
||||
? String(permissions)
|
||||
: '0',
|
||||
is_default: record['is_default'] === true,
|
||||
hoist: record['hoist'] === true,
|
||||
mentionable: record['mentionable'] === true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGatewayUser(value: unknown): GatewayUser | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const username = asString(record['username']);
|
||||
if (id === undefined || username === undefined) {
|
||||
return null;
|
||||
}
|
||||
const user: GatewayUser = {
|
||||
id,
|
||||
username,
|
||||
display_name: asString(record['display_name']) ?? username,
|
||||
is_instance_admin: record['is_instance_admin'] === true,
|
||||
badges: asStringArray(record['badges']),
|
||||
};
|
||||
const avatar = asString(record['avatar_file_id']);
|
||||
const status = asString(record['status']);
|
||||
const customStatus = asString(record['custom_status']);
|
||||
const customEmoji = asString(record['custom_status_emoji']);
|
||||
if (avatar !== undefined) {
|
||||
user.avatar_file_id = avatar;
|
||||
}
|
||||
if (status !== undefined) {
|
||||
user.status = status as UserStatus;
|
||||
}
|
||||
if (customStatus !== undefined) {
|
||||
user.custom_status = customStatus;
|
||||
}
|
||||
if (customEmoji !== undefined) {
|
||||
user.custom_status_emoji = customEmoji;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
function parseGuild(value: unknown): GatewayGuild | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const name = asString(record['name']);
|
||||
if (id === undefined || name === undefined) {
|
||||
return null;
|
||||
}
|
||||
const channels = Array.isArray(record['channels'])
|
||||
? record['channels'].map(parseChannel).filter((item): item is Channel => item !== null)
|
||||
: [];
|
||||
const roles = Array.isArray(record['roles'])
|
||||
? record['roles'].map(parseRole).filter((item): item is Role => item !== null)
|
||||
: [];
|
||||
const guild: GatewayGuild = {
|
||||
id,
|
||||
name,
|
||||
owner_id: asString(record['owner_id']) ?? '',
|
||||
is_main: record['is_main'] === true,
|
||||
channels,
|
||||
roles,
|
||||
member_ids: asStringArray(record['member_ids']),
|
||||
my_role_ids: asStringArray(record['my_role_ids']),
|
||||
my_permissions: asStringArray(record['my_permissions']),
|
||||
};
|
||||
const icon = asString(record['icon_file_id']);
|
||||
const nickname = asString(record['my_nickname']);
|
||||
if (icon !== undefined) {
|
||||
guild.icon_file_id = icon;
|
||||
}
|
||||
if (nickname !== undefined) {
|
||||
guild.my_nickname = nickname;
|
||||
}
|
||||
return guild;
|
||||
}
|
||||
|
||||
/** Разбирает снапшот READY, отбрасывая всё, что не совпало с контрактом. */
|
||||
export function parseGatewaySnapshot(value: unknown): GatewaySnapshot | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const user = parseGatewayUser(record['user']);
|
||||
if (user === null) {
|
||||
return null;
|
||||
}
|
||||
const guilds = Array.isArray(record['guilds'])
|
||||
? record['guilds'].map(parseGuild).filter((item): item is GatewayGuild => item !== null)
|
||||
: [];
|
||||
const dmChannels = Array.isArray(record['dm_channels'])
|
||||
? record['dm_channels'].map(parseChannel).filter((item): item is Channel => item !== null)
|
||||
: [];
|
||||
return {
|
||||
user,
|
||||
guilds,
|
||||
dm_channels: dmChannels,
|
||||
read_states: Array.isArray(record['read_states']) ? record['read_states'] : [],
|
||||
session_id: asString(record['session_id']) ?? '',
|
||||
heartbeat_interval_ms: asNumber(record['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export function gatewayUrl(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '/gateway';
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/gateway`;
|
||||
}
|
||||
|
||||
export type GatewaySend = (packet: { op: number; d: unknown }) => void;
|
||||
|
||||
export class GatewayConnection {
|
||||
private readonly url: string;
|
||||
private readonly handlers: GatewayHandlers;
|
||||
private readonly socketFactory: (url: string) => WebSocket;
|
||||
private readonly logger: GatewayLogger;
|
||||
|
||||
private socket: WebSocket | null = null;
|
||||
private status: GatewayStatus = 'idle';
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private helloTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS;
|
||||
private awaitingAck = false;
|
||||
private attempt = 0;
|
||||
private sessionId: string | null = null;
|
||||
private lastSeq = 0;
|
||||
private closedByUser = false;
|
||||
|
||||
constructor(options: GatewayConnectionOptions) {
|
||||
this.url = options.url;
|
||||
this.handlers = options.handlers ?? {};
|
||||
this.logger = options.logger ?? { warn: () => undefined, error: () => undefined };
|
||||
this.socketFactory =
|
||||
options.socketFactory ??
|
||||
((url) => {
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
throw new Error('WebSocket is not available in this environment');
|
||||
}
|
||||
return new WebSocket(url);
|
||||
});
|
||||
}
|
||||
|
||||
getStatus(): GatewayStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getSessionId(): string | null {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
getLastSeq(): number {
|
||||
return this.lastSeq;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.closedByUser = false;
|
||||
if (this.socket !== null) {
|
||||
return;
|
||||
}
|
||||
this.open(undefined);
|
||||
}
|
||||
|
||||
/** Плановое закрытие: без переподключения. */
|
||||
close(): void {
|
||||
this.closedByUser = true;
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.stopHeartbeat();
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
if (socket !== null) {
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
socket.onclose = null;
|
||||
socket.onerror = null;
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Сокет мог уже закрыться — это не ошибка.
|
||||
}
|
||||
}
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
|
||||
private open(reason: unknown): void {
|
||||
this.setStatus(this.attempt === 0 ? 'connecting' : 'reconnecting');
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = this.socketFactory(this.url);
|
||||
} catch (error) {
|
||||
this.logger.warn(`gateway: cannot open socket (${String(error)})`);
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.socket = socket;
|
||||
if (reason !== undefined) {
|
||||
this.logger.warn(`gateway: reconnecting (${String(reason)})`);
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
// Ждём HELLO; если его нет — соединение считаем нерабочим.
|
||||
this.helloTimeout = setTimeout(() => {
|
||||
this.logger.warn('gateway: HELLO timeout');
|
||||
this.dropSocket();
|
||||
}, 15_000);
|
||||
};
|
||||
|
||||
socket.onmessage = (event: MessageEvent) => {
|
||||
this.handleMessage(event.data);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
this.logger.warn('gateway: socket error');
|
||||
};
|
||||
|
||||
socket.onclose = (event: CloseEvent) => {
|
||||
const wasCurrent = this.socket === socket;
|
||||
this.socket = null;
|
||||
this.stopHeartbeat();
|
||||
if (!wasCurrent || this.closedByUser) {
|
||||
return;
|
||||
}
|
||||
if (event.code === GATEWAY_CLOSE_INVALID_SESSION) {
|
||||
this.setStatus('disconnected');
|
||||
this.handlers.onInvalidSession?.();
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
private dropSocket(): void {
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
this.stopHeartbeat();
|
||||
if (socket !== null) {
|
||||
socket.onclose = null;
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Игнорируем: сокет уже не нужен.
|
||||
}
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closedByUser || this.reconnectTimer !== null) {
|
||||
return;
|
||||
}
|
||||
this.attempt += 1;
|
||||
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
this.setStatus('reconnecting');
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.open('backoff');
|
||||
}, backoff + jitter);
|
||||
}
|
||||
|
||||
private setStatus(status: GatewayStatus): void {
|
||||
if (this.status === status) {
|
||||
return;
|
||||
}
|
||||
this.status = status;
|
||||
this.handlers.onStatus?.(status);
|
||||
}
|
||||
|
||||
private send(op: number, d: unknown): void {
|
||||
const socket = this.socket;
|
||||
if (socket === null || socket.readyState !== 1) {
|
||||
return;
|
||||
}
|
||||
socket.send(JSON.stringify({ op, d }));
|
||||
}
|
||||
|
||||
private handleMessage(raw: unknown): void {
|
||||
if (typeof raw !== 'string') {
|
||||
return;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
this.logger.warn('gateway: cannot parse frame');
|
||||
return;
|
||||
}
|
||||
if (!isGatewayPacket(parsed)) {
|
||||
return;
|
||||
}
|
||||
switch (parsed.op) {
|
||||
case GatewayOp.HELLO: {
|
||||
const data = asRecord(parsed.d);
|
||||
this.heartbeatIntervalMs =
|
||||
asNumber(data?.['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS;
|
||||
this.sessionId = asString(data?.['session_id']) ?? this.sessionId;
|
||||
if (this.helloTimeout !== null) {
|
||||
clearTimeout(this.helloTimeout);
|
||||
this.helloTimeout = null;
|
||||
}
|
||||
this.identify();
|
||||
this.startHeartbeat();
|
||||
return;
|
||||
}
|
||||
case GatewayOp.HEARTBEAT_ACK: {
|
||||
this.awaitingAck = false;
|
||||
return;
|
||||
}
|
||||
case GatewayOp.DISPATCH: {
|
||||
const seq = asNumber(parsed.s) ?? 0;
|
||||
if (seq > this.lastSeq) {
|
||||
this.lastSeq = seq;
|
||||
}
|
||||
const type = asString(parsed.t) ?? '';
|
||||
if (type === 'READY') {
|
||||
this.attempt = 0;
|
||||
}
|
||||
if (type === 'RESUMED' && parsed.d === null) {
|
||||
this.handlers.onResumeIncomplete?.();
|
||||
}
|
||||
this.setStatus('connected');
|
||||
this.handlers.onDispatch?.({
|
||||
op: 0,
|
||||
t: type,
|
||||
s: seq,
|
||||
d: parsed.d,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case GatewayOp.INVALID_SESSION: {
|
||||
const resumable = parsed.d === true;
|
||||
if (resumable) {
|
||||
this.logger.warn('gateway: invalid session, retrying resume');
|
||||
this.dropSocket();
|
||||
return;
|
||||
}
|
||||
this.sessionId = null;
|
||||
this.lastSeq = 0;
|
||||
this.closedByUser = true;
|
||||
this.setStatus('disconnected');
|
||||
this.handlers.onInvalidSession?.();
|
||||
return;
|
||||
}
|
||||
case GatewayOp.RECONNECT: {
|
||||
this.dropSocket();
|
||||
return;
|
||||
}
|
||||
default:
|
||||
// Неизвестные opcode игнорируем: контракт может расширяться.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private identify(): void {
|
||||
// Пустой токен допустим: браузер аутентифицируется cookie на рукопожатии.
|
||||
const token = '';
|
||||
if (this.sessionId !== null && this.lastSeq > 0) {
|
||||
this.send(GatewayOp.RESUME, { token, resume_seq: this.lastSeq });
|
||||
return;
|
||||
}
|
||||
this.send(GatewayOp.IDENTIFY, { token, resume_seq: 0 });
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.awaitingAck = false;
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.awaitingAck) {
|
||||
this.logger.warn('gateway: heartbeat was not acknowledged');
|
||||
this.dropSocket();
|
||||
return;
|
||||
}
|
||||
this.awaitingAck = true;
|
||||
this.send(GatewayOp.HEARTBEAT, null);
|
||||
}, this.heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer !== null) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.helloTimeout !== null) {
|
||||
clearTimeout(this.helloTimeout);
|
||||
this.helloTimeout = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedConnection: GatewayConnection | null = null;
|
||||
|
||||
export function getGatewayConnection(): GatewayConnection | null {
|
||||
return sharedConnection;
|
||||
}
|
||||
|
||||
/** Создаёт (или переиспользует) единственное соединение с шлюзом. */
|
||||
export function connectGateway(options: {
|
||||
url?: string;
|
||||
handlers: GatewayHandlers;
|
||||
socketFactory?: (url: string) => WebSocket;
|
||||
}): GatewayConnection {
|
||||
if (sharedConnection !== null) {
|
||||
sharedConnection.close();
|
||||
}
|
||||
const socketFactory = options.socketFactory;
|
||||
sharedConnection = new GatewayConnection({
|
||||
url: options.url ?? gatewayUrl(),
|
||||
handlers: options.handlers,
|
||||
...(socketFactory === undefined ? {} : { socketFactory }),
|
||||
});
|
||||
sharedConnection.connect();
|
||||
return sharedConnection;
|
||||
}
|
||||
|
||||
export function disconnectGateway(): void {
|
||||
sharedConnection?.close();
|
||||
sharedConnection = null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { request } from './client';
|
||||
import type { Channel, ChannelType, Guild, GuildMember, GuildSummary, Role } from './types';
|
||||
|
||||
export interface CreateChannelInput {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateGuildInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const guildQueryKey = (guildId: string) => ['guilds', guildId] as const;
|
||||
export const guildChannelsQueryKey = (guildId: string) => ['guilds', guildId, 'channels'] as const;
|
||||
export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const;
|
||||
export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const;
|
||||
|
||||
export async function createGuild(name: string): Promise<GuildSummary> {
|
||||
const payload = await request<{ guild: GuildSummary }>('/guilds', {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
});
|
||||
return payload.guild;
|
||||
}
|
||||
|
||||
export async function fetchGuild(guildId: string, signal?: AbortSignal): Promise<Guild> {
|
||||
const payload = await request<{ guild: Guild }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.guild;
|
||||
}
|
||||
|
||||
export async function updateGuild(guildId: string, input: UpdateGuildInput): Promise<Guild> {
|
||||
const payload = await request<{ guild: Guild }>(`/guilds/${encodeURIComponent(guildId)}`, {
|
||||
method: 'PATCH',
|
||||
body: input,
|
||||
});
|
||||
return payload.guild;
|
||||
}
|
||||
|
||||
export async function fetchChannels(guildId: string, signal?: AbortSignal): Promise<Channel[]> {
|
||||
const payload = await request<{ channels: Channel[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/channels`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.channels;
|
||||
}
|
||||
|
||||
export async function createChannel(guildId: string, input: CreateChannelInput): Promise<Channel> {
|
||||
const payload = await request<{ channel: Channel }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/channels`,
|
||||
{ method: 'POST', body: input },
|
||||
);
|
||||
return payload.channel;
|
||||
}
|
||||
|
||||
export async function fetchMembers(guildId: string, signal?: AbortSignal): Promise<GuildMember[]> {
|
||||
const payload = await request<{ members: GuildMember[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/members`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.members;
|
||||
}
|
||||
|
||||
export async function fetchRoles(guildId: string, signal?: AbortSignal): Promise<Role[]> {
|
||||
const payload = await request<{ roles: Role[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/roles`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.roles;
|
||||
}
|
||||
|
||||
export function joinGuild(guildId: string): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function leaveGuild(guildId: string): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/leave`, { method: 'POST' });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { request } from './client';
|
||||
import type { AuditEntry, InstanceGuild, InstanceInfo, InstanceUser } from './types';
|
||||
|
||||
export interface InstanceSettings {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const instanceQueryKey = ['instance'] as const;
|
||||
export const instanceSettingsQueryKey = ['instance', 'settings'] as const;
|
||||
export const instanceGuildsQueryKey = ['instance', 'guilds'] as const;
|
||||
export const instanceUsersQueryKey = ['instance', 'users'] as const;
|
||||
export const instanceAuditQueryKey = (limit: number) => ['instance', 'audit', limit] as const;
|
||||
|
||||
/** Публичная информация об инстансе: нужна на логине/регистрации и в /app. */
|
||||
export async function fetchInstance(signal?: AbortSignal): Promise<InstanceInfo> {
|
||||
const payload = await request<{ instance: InstanceInfo }>(
|
||||
'/instance',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.instance;
|
||||
}
|
||||
|
||||
export async function fetchInstanceSettings(signal?: AbortSignal): Promise<InstanceSettings> {
|
||||
const payload = await request<{ settings: InstanceSettings }>(
|
||||
'/instance/settings',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.settings;
|
||||
}
|
||||
|
||||
export async function updateInstanceSettings(
|
||||
settings: Partial<InstanceSettings>,
|
||||
): Promise<InstanceSettings> {
|
||||
const payload = await request<{ settings: InstanceSettings }>('/instance/settings', {
|
||||
method: 'PATCH',
|
||||
body: settings,
|
||||
});
|
||||
return payload.settings;
|
||||
}
|
||||
|
||||
export async function fetchInstanceGuilds(signal?: AbortSignal): Promise<InstanceGuild[]> {
|
||||
const payload = await request<{ guilds: InstanceGuild[] }>(
|
||||
'/instance/guilds',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.guilds;
|
||||
}
|
||||
|
||||
export async function fetchInstanceUsers(signal?: AbortSignal): Promise<InstanceUser[]> {
|
||||
const payload = await request<{ users: InstanceUser[] }>(
|
||||
'/instance/users',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.users;
|
||||
}
|
||||
|
||||
export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise<AuditEntry[]> {
|
||||
const payload = await request<{ entries: AuditEntry[] }>(
|
||||
`/instance/audit?limit=${String(limit)}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.entries;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Общие типы API Фазы 1. Все структуры строгие: поля, которых нет в контракте,
|
||||
* не выдумываем, а необязательные помечаем `?` (на клиенте их может не быть
|
||||
* в зависимости от прав и состояния сервера).
|
||||
*/
|
||||
|
||||
/** Виды присутствия, которые сервер принимает в PATCH /users/@me. */
|
||||
export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const;
|
||||
export type UserStatus = (typeof userStatuses)[number];
|
||||
|
||||
/** Тип комнаты: текст, голосовой канал или категория. */
|
||||
export type ChannelType = 'text' | 'voice' | 'category';
|
||||
|
||||
/** Пользователь в объёме, достаточном для интерфейса (GET /users/{id}). */
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
bio: string;
|
||||
status: UserStatus;
|
||||
custom_status: string;
|
||||
custom_status_emoji: string;
|
||||
avatar_file_id?: string;
|
||||
banner_file_id?: string;
|
||||
is_instance_admin: boolean;
|
||||
badges: string[];
|
||||
locale: string;
|
||||
onboarding_completed: boolean;
|
||||
}
|
||||
|
||||
/** Урезанное представление пользователя (участники сервера, READY). */
|
||||
export interface UserSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_file_id?: string;
|
||||
is_instance_admin: boolean;
|
||||
badges: string[];
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
user_agent: string;
|
||||
ip: string;
|
||||
created_at: string;
|
||||
last_seen: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
/** Битовая маска прав (строка — чтобы не терять старшие биты в JSON). */
|
||||
permissions: string;
|
||||
is_default: boolean;
|
||||
hoist: boolean;
|
||||
mentionable: boolean;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
guild_id?: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
position: number;
|
||||
parent_id?: string;
|
||||
user_limit?: number;
|
||||
slowmode_seconds?: number;
|
||||
can_view?: boolean;
|
||||
can_send?: boolean;
|
||||
can_connect?: boolean;
|
||||
}
|
||||
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_file_id?: string;
|
||||
owner_id: string;
|
||||
is_main: boolean;
|
||||
description: string;
|
||||
member_count: number;
|
||||
roles: Role[];
|
||||
my_role_ids: string[];
|
||||
my_permissions: string[];
|
||||
}
|
||||
|
||||
/** Сервер в списке GET /users/@me/guilds. */
|
||||
export interface GuildSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_file_id?: string;
|
||||
owner_id: string;
|
||||
is_main: boolean;
|
||||
member_count: number;
|
||||
my_role_ids: string[];
|
||||
my_permissions: string[];
|
||||
}
|
||||
|
||||
export interface GuildMember {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
nickname?: string;
|
||||
avatar_file_id?: string;
|
||||
status: UserStatus;
|
||||
is_instance_admin: boolean;
|
||||
joined_at: string;
|
||||
role_ids: string[];
|
||||
}
|
||||
|
||||
export interface InstanceInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
registration_enabled: boolean;
|
||||
allow_guild_creation: boolean;
|
||||
max_guilds_per_user: number;
|
||||
max_members_per_guild: number;
|
||||
max_message_length: number;
|
||||
main_guild_id?: string;
|
||||
user_count: number;
|
||||
guild_count: number;
|
||||
}
|
||||
|
||||
/** Сервер из админского списка GET /instance/guilds. */
|
||||
export interface InstanceGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
member_count: number;
|
||||
owner_id: string;
|
||||
is_main: boolean;
|
||||
}
|
||||
|
||||
/** Пользователь из админского списка GET /instance/users. */
|
||||
export interface InstanceUser {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_instance_admin: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
actor_id: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
created_at: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function parsePermission(value: string): bigint | null {
|
||||
try {
|
||||
return BigInt(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { request } from './client';
|
||||
import type { GuildSummary, User, UserSummary } from './types';
|
||||
|
||||
export interface UpdateProfileInput {
|
||||
display_name?: string;
|
||||
bio?: string;
|
||||
status?: string;
|
||||
custom_status?: string;
|
||||
custom_status_emoji?: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface OnboardingInput {
|
||||
display_name?: string;
|
||||
bio?: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const currentUserQueryKey = ['users', '@me'] as const;
|
||||
export const myGuildsQueryKey = ['users', '@me', 'guilds'] as const;
|
||||
|
||||
export function userQueryKey(userId: string) {
|
||||
return ['users', userId] as const;
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(signal?: AbortSignal): Promise<User> {
|
||||
const payload = await request<{ user: User }>(
|
||||
'/users/@me',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.user;
|
||||
}
|
||||
|
||||
export async function updateProfile(input: UpdateProfileInput): Promise<User> {
|
||||
const payload = await request<{ user: User }>('/users/@me', { method: 'PATCH', body: input });
|
||||
return payload.user;
|
||||
}
|
||||
|
||||
export async function completeOnboarding(input: OnboardingInput): Promise<User> {
|
||||
const payload = await request<{ user: User }>('/users/@me/onboarding/complete', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
return payload.user;
|
||||
}
|
||||
|
||||
export async function fetchMyGuilds(signal?: AbortSignal): Promise<GuildSummary[]> {
|
||||
const payload = await request<{ guilds: GuildSummary[] }>(
|
||||
'/users/@me/guilds',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.guilds;
|
||||
}
|
||||
|
||||
export async function fetchUser(userId: string, signal?: AbortSignal): Promise<UserSummary> {
|
||||
const payload = await request<{ user: UserSummary }>(
|
||||
`/users/${encodeURIComponent(userId)}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.user;
|
||||
}
|
||||
Reference in New Issue
Block a user