const API_ORIGIN_PROXY_PREFIX = '/idp-api'; const configuredApiUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, ''); const configuredWsUrl = (process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws').replace(/\/$/, ''); function resolveBrowserApiBaseUrl(): string { if (typeof window === 'undefined') { return configuredApiUrl; } try { const configured = new URL(configuredApiUrl); if (configured.hostname !== window.location.hostname) { return API_ORIGIN_PROXY_PREFIX; } } catch { // keep configured URL } return configuredApiUrl; } function resolveBrowserWsBaseUrl(): string { if (typeof window === 'undefined') { return configuredWsUrl; } const apiBase = resolveBrowserApiBaseUrl(); if (apiBase === API_ORIGIN_PROXY_PREFIX) { const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`; } try { const configured = new URL(configuredWsUrl); if (configured.hostname !== window.location.hostname) { const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`; } } catch { // keep configured URL } return configuredWsUrl; } export function getApiUrl(): string { return resolveBrowserApiBaseUrl(); } export function getWsUrl(): string { return resolveBrowserWsBaseUrl(); } /** @deprecated Используйте getApiUrl() — URL может переключаться на same-origin /idp-api в браузере */ export const API_URL = configuredApiUrl; export interface PublicUser { id: string; email?: string | null; phone?: string | null; backupEmail?: string | null; backupPhone?: string | null; displayName: string; username?: string | null; avatarUrl?: string | null; hasAvatar?: boolean; isSuperAdmin: boolean; status: string; roles?: string[]; permissions?: string[]; canAccessAdmin?: boolean; canManageRoles?: boolean; canViewOAuth?: boolean; canManageOAuth?: boolean; canManageAllOAuth?: boolean; canManageUsers?: boolean; canManageAllUsers?: boolean; canManageSettings?: boolean; canViewUsers?: boolean; canViewUserDocuments?: boolean; hasPassword?: boolean; isVerified?: boolean; verificationIcon?: string | null; canVerifyUsers?: boolean; canManageBots?: boolean; } export interface AdminUser { id: string; email?: string | null; phone?: string | null; displayName: string; username?: string | null; isSuperAdmin: boolean; status: string; createdAt: string; roles: string[]; directPermissions?: string[]; isVerified?: boolean; verificationIcon?: string | null; isBot?: boolean; linkedBotUsername?: string | null; isSystemBot?: boolean; } export interface AdminRole { id: string; slug: string; name: string; description?: string; isSystem?: boolean; isDefault?: boolean; permissions: { id: string; slug: string; name: string; description?: string }[]; } export interface AdminPermission { id: string; slug: string; name: string; description?: string; } export interface OAuthScope { id: string; slug: string; name: string; description?: string; } export interface OAuthConsentScopeInfo { slug: string; name: string; description?: string; } export interface OAuthConsentCheckResponse { granted: boolean; client?: { clientId: string; name: string; }; requestedScopes?: OAuthConsentScopeInfo[]; } export interface OAuthUserConsent { id: string; clientId: string; clientName: string; scopes: OAuthConsentScopeInfo[]; grantedAt: string; updatedAt: string; } export interface OAuthClient { id: string; name: string; clientId: string; redirectUris: string[]; scopes: string[]; isActive: boolean; type: string; clientSecret?: string; createdById?: string | null; createdByDisplayName?: string | null; canManage?: boolean; } export interface AuthTokens { accessToken: string; refreshToken: string; expiresAt: string; pinVerified: boolean; user: PublicUser; sessionId: string; requiresTotp?: boolean; totpChallengeToken?: string; } export interface UserProfileResponse { birthDate?: string | null; email?: string | null; phone?: string | null; backupEmail?: string | null; backupPhone?: string | null; displayName?: string; firstName?: string | null; lastName?: string | null; } export interface QrLoginSession { sessionId: string; status: 'PENDING' | 'APPROVED' | 'EXPIRED'; expiresAt: string; qrPayload?: string; auth?: AuthTokens & { user?: PublicUser }; } export async function createQrLoginSession(deviceName: string) { return apiFetch('/auth/advanced/qr/session', { method: 'POST', body: JSON.stringify({ deviceName, fingerprint: getDeviceFingerprint(), deviceType: 'WEB' }) }); } export async function pollQrLoginSession(sessionId: string) { return apiFetch(`/auth/advanced/qr/session/${sessionId}`); } export interface TotpSetupResponse { secret: string; otpauthUrl: string; } export interface TotpStatusResponse { isEnabled: boolean; } export interface PresignedUploadResponse { uploadUrl: string; storageKey: string; expiresAt?: string; uploadToken?: string; } export async function uploadMediaObject( presigned: PresignedUploadResponse, file: Blob, contentType: string ) { if (presigned.uploadToken) { const apiBase = getApiUrl().replace(/\/$/, ''); const formData = new FormData(); formData.append('file', file, file instanceof File ? file.name : 'upload.bin'); const response = await fetch(`${apiBase}/media/upload`, { method: 'POST', headers: { 'X-Upload-Token': presigned.uploadToken }, body: formData }); if (!response.ok) { throw new Error('Не удалось загрузить файл'); } return; } const response = await fetch(presigned.uploadUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: file }); if (!response.ok) { throw new Error('Не удалось загрузить файл'); } } export interface FamilyInviteCandidate { id: string; displayName: string; email?: string | null; phone?: string | null; username?: string | null; hasAvatar?: boolean; isVerified?: boolean; verificationIcon?: string | null; isBot?: boolean; botUsername?: string; botId?: string; ownerDisplayName?: string; } export async function searchFamilyInviteUsers(groupId: string, query: string, token?: string | null) { return apiFetch<{ users?: FamilyInviteCandidate[] }>( `/family/groups/${groupId}/invite-search?q=${encodeURIComponent(query)}`, {}, token ); } export interface PasswordlessAuthResponse { requiresPassword: boolean; requiresTotp?: boolean; totpChallengeToken?: string; tempAuthToken?: string; auth?: AuthTokens; } export interface OtpChannel { channel: 'email' | 'phone' | 'backupEmail' | 'backupPhone' | string; masked: string; } export interface LoginMethod { kind: 'password' | 'otp'; channel: string; masked: string; } export interface IdentifyResponse { exists: boolean; hasPassword: boolean; isPinEnabled: boolean; isTotpEnabled?: boolean; otpChannels?: OtpChannel[]; methods?: LoginMethod[]; } export interface BeginTotpLoginResponse { totpChallengeToken: string; } export interface OtpSendResponse { sent: boolean; expiresAt: string; maskedTarget: string; resendAvailableAt?: string; } export interface PinVerificationResponse { accessToken: string; pinVerified: boolean; } export interface RefreshSessionResponse { requiresPin: boolean; accessToken?: string; sessionId?: string; pinVerified: boolean; user?: PublicUser; } export interface AuthSessionResponse { requiresPin: boolean; sessionId?: string | null; pinVerified: boolean; user: PublicUser; } export const AUTH_TOKEN_KEY = 'lendry_access_token'; export const AUTH_REFRESH_KEY = 'lendry_refresh_token'; export const AUTH_SESSION_KEY = 'lendry_session_id'; export const AUTH_USER_CACHE_KEY = 'lendry_cached_user'; export class ApiError extends Error { status: number; code?: string; sessionId?: string; constructor(message: string, status: number, code?: string, sessionId?: string) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; this.sessionId = sessionId; } } function mapFetchError(error: unknown): ApiError { if (error instanceof TypeError) { const message = error.message.toLowerCase(); if (message.includes('failed to fetch') || message.includes('networkerror')) { return new ApiError( 'Нет связи с сервером. При самоподписанном HTTPS примите сертификат сайта или выполните ./install.sh --fix-proxy на сервере.', 0, 'NETWORK_ERROR' ); } } if (error instanceof ApiError) { return error; } return new ApiError(error instanceof Error ? error.message : 'Не удалось выполнить запрос', 0, 'UNKNOWN'); } type PinRequiredHandler = (sessionId: string) => void; export function setPinRequiredHandler(handler: PinRequiredHandler | null) { pinRequiredHandler = handler; if (!handler) { pinNotificationSent = false; } } interface ErrorBody { message?: string | string[]; error?: string; code?: string; sessionId?: string; } async function parseErrorResponse(response: Response): Promise { try { const body = (await response.clone().json()) as ErrorBody; const message = Array.isArray(body.message) ? body.message.join('\n') : (body.message ?? body.error ?? 'Не удалось выполнить запрос'); const pinRequired = body.code === 'PIN_REQUIRED' || (response.status === 403 && typeof message === 'string' && message.toLowerCase().includes('pin')); return new ApiError(message, response.status, pinRequired ? 'PIN_REQUIRED' : body.code, body.sessionId); } catch { return new ApiError('Не удалось выполнить запрос', response.status); } } export function isPinRequiredError(error: unknown): error is ApiError { return error instanceof ApiError && error.code === 'PIN_REQUIRED'; } export function getApiErrorMessage(error: unknown, fallback: string): string | null { if (isPinRequiredError(error)) return null; if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) { return null; } return error instanceof Error ? error.message : fallback; } let pinRequiredHandler: PinRequiredHandler | null = null; let pinNotificationSent = false; export function resetPinRequiredNotification() { pinNotificationSent = false; } function notifyPinRequired(sessionId: string) { if (!pinRequiredHandler || pinNotificationSent) return; pinNotificationSent = true; pinRequiredHandler(sessionId); } export async function fetchAuthSession(token?: string | null): Promise { return apiFetch('/auth/session', {}, token); } export async function approveOAuthAuthorization(params: URLSearchParams, token: string) { const body = { client_id: params.get('client_id') ?? params.get('clientId') ?? undefined, redirect_uri: params.get('redirect_uri') ?? params.get('redirectUri') ?? undefined, scope: params.get('scope') ?? 'openid profile', state: params.get('state') ?? undefined, nonce: params.get('nonce') ?? undefined }; return apiFetch<{ redirectUrl?: string }>('/oauth/consent/approve', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, token); } export async function checkOAuthConsent(params: URLSearchParams, token: string) { const query = new URLSearchParams(); const clientId = params.get('client_id') ?? params.get('clientId'); const scope = params.get('scope') ?? 'openid profile'; if (clientId) query.set('client_id', clientId); query.set('scope', scope); return apiFetch(`/oauth/consent/check?${query.toString()}`, {}, token); } export async function fetchOAuthClientPublicInfo(clientId: string) { return apiFetch<{ clientId: string; name: string }>(`/oauth/clients/${encodeURIComponent(clientId)}/public`); } export async function fetchUserOAuthConsents(userId: string, token: string) { return apiFetch<{ consents?: OAuthUserConsent[] }>(`/oauth/consents/users/${userId}`, {}, token); } export async function revokeOAuthConsent(userId: string, consentId: string, token: string) { return apiFetch(`/oauth/consents/users/${userId}/${consentId}`, { method: 'DELETE' }, token); } export async function refreshAuthSession(): Promise { if (typeof window === 'undefined') { throw new ApiError('Refresh token недоступен на сервере', 401, 'NO_REFRESH'); } const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY); const sessionId = window.localStorage.getItem(AUTH_SESSION_KEY); if (!refreshToken) { throw new ApiError('Refresh token отсутствует', 401, 'NO_REFRESH'); } const response = await fetch(`${getApiUrl()}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined }) }); if (!response.ok) { throw await parseErrorResponse(response); } return response.json() as Promise; } export async function syncFedcmSession(accessToken: string) { if (typeof window === 'undefined' || !accessToken.trim()) return; try { await fetch(`${getApiUrl()}/fedcm/session/sync`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken.trim()}` }, credentials: 'include' }); } catch { // фоновая синхронизация FedCM cookie } } async function trySilentTokenRefresh(): Promise { try { const refreshed = await refreshAuthSession(); if (refreshed.requiresPin) { notifyPinRequired(refreshed.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); return refreshed; } if (refreshed.accessToken) { window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken); if (refreshed.sessionId) { window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId); } if (refreshed.user) { window.dispatchEvent(new CustomEvent('lendry:auth-refreshed', { detail: refreshed })); } } return refreshed; } catch { return null; } } export interface UserDocument { id: string; userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string; } export interface SystemSetting { id: string; key: string; value: string; description?: string | null; isSecret: boolean; } export interface SocialProvider { id: string; providerName: string; clientId: string; isEnabled: boolean; } export interface ActiveDevice { id: string; name: string; type: string; lastIp?: string | null; lastSeenAt: string; trusted: boolean; } export interface SignInEvent { id: string; success: boolean; reason?: string | null; ipAddress?: string | null; userAgent?: string | null; createdAt: string; } export interface ActiveSession { id: string; userId: string; deviceId?: string | null; pinVerified: boolean; status: string; ipAddress?: string | null; userAgent?: string | null; expiresAt: string; createdAt: string; updatedAt: string; } async function parseError(response: Response): Promise { const error = await parseErrorResponse(response); return error.message; } export function buildSystemSettingPayload(setting: Pick) { return { key: setting.key, value: setting.value, description: setting.description ?? undefined, isSecret: setting.isSecret }; } export async function apiFetch(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise { const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null); let response: Response; try { response = await fetch(`${getApiUrl()}${path}`, { ...options, headers: { 'Content-Type': 'application/json', ...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}), ...options.headers } }); } catch (error) { throw mapFetchError(error); } if (!response.ok) { const error = await parseErrorResponse(response); if (error.code === 'PIN_REQUIRED') { notifyPinRequired(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); throw error; } if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') { const refreshed = await trySilentTokenRefresh(); if (refreshed?.accessToken) { return apiFetch(path, options, refreshed.accessToken, false); } if (refreshed?.requiresPin) { throw error; } } throw error; } return response.json() as Promise; } export function getDeviceFingerprint(): string { if (typeof window === 'undefined') return 'server'; const key = 'lendry_device_fingerprint'; const existing = window.localStorage.getItem(key); if (existing) return existing; const value = crypto.randomUUID(); window.localStorage.setItem(key, value); return value; } export interface MediaAccessResponse { accessUrl: string; expiresAt: string; } export async function fetchDocumentPhotoUrl(userId: string, storageKey: string, token?: string | null) { const query = new URLSearchParams({ storageKey }); return apiFetch(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token); } export interface AccountDeletionStatus { pending: boolean; deletionRequestedAt?: string; effectiveAt?: string; graceDays: number; } export async function requestAccountDeletion(userId: string, token?: string | null) { return apiFetch( `/profile/users/${userId}/self-delete`, { method: 'POST' }, token ); } export async function cancelAccountDeletion(userId: string, token?: string | null) { return apiFetch<{ userId: string; cancelled: boolean }>( `/profile/users/${userId}/self-delete/cancel`, { method: 'POST' }, token ); } export async function fetchAccountDeletionStatus(userId: string, token?: string | null) { return apiFetch(`/profile/users/${userId}/self-delete/status`, {}, token); } /** @deprecated Используйте requestAccountDeletion — удаление теперь отложенное */ export async function softDeleteProfile(userId: string, token?: string | null) { return requestAccountDeletion(userId, token); } export interface UserAddress { id: string; userId: string; label: 'HOME' | 'WORK' | 'OTHER'; city: string; street: string; house: string; apartment?: string; comment?: string; latitude?: number; longitude?: number; fullAddress?: string; createdAt: string; updatedAt: string; } export async function fetchUserAddresses(userId: string, token?: string | null) { return apiFetch<{ addresses?: UserAddress[] }>(`/addresses/users/${userId}`, {}, token); } export async function upsertUserAddress( userId: string, body: { addressId?: string; label: UserAddress['label']; city: string; street: string; house: string; apartment?: string; comment?: string; latitude?: number; longitude?: number; fullAddress?: string; }, token?: string | null ) { return apiFetch(`/addresses/users/${userId}`, { method: 'POST', body: JSON.stringify(body) }, token); } export async function deleteUserAddress(userId: string, addressId: string, token?: string | null) { return apiFetch<{ count: number }>(`/addresses/users/${userId}/${addressId}`, { method: 'DELETE' }, token); } export interface FamilyMember { id: string; userId: string; role: string; displayName: string; hasAvatar: boolean; isVerified?: boolean; verificationIcon?: string | null; isBot?: boolean; botUsername?: string; } export interface FamilyGroup { id: string; ownerId: string; name: string; hasAvatar: boolean; members?: FamilyMember[]; botFatherAvailable?: boolean; getMyIdBotAvailable?: boolean; } export interface BotChatMessage { id: string; direction: 'in' | 'out'; scope?: 'private' | 'broadcast'; text: string; messageType: string; messageId: number; createdAt: string; replyMarkupJson?: string | null; isPinned?: boolean; pinnedAt?: string | null; } export interface BotChatMeta { botId?: string; botOwnerId?: string; botUsername?: string; botDisplayName?: string; composerWebAppUrl?: string | null; composerMenuButtonJson?: string | null; manageWebAppUrl?: string; } export interface FamilyInvite { id: string; groupId: string; groupName: string; inviterId: string; inviterName: string; inviteeUserId?: string; targetEmail?: string; targetPhone?: string; status: string; expiresAt: string; } export interface AppNotification { id: string; type: string; title: string; message: string; payloadJson?: string; isRead: boolean; createdAt: string; } export interface ChatPollOption { id: string; text: string; voteCount: number; votedByMe: boolean; } export interface ChatMessageReaction { emoji: string; count: number; reactedByMe: boolean; } export interface ChatMessage { id: string; roomId: string; senderId: string; senderName: string; senderHasAvatar: boolean; senderIsVerified?: boolean; senderVerificationIcon?: string | null; type: string; content?: string; isEncrypted?: boolean; replyToId?: string; storageKey?: string; mimeType?: string; metadataJson?: string; createdAt: string; editedAt?: string; isDeleted?: boolean; isPinned?: boolean; pinnedAt?: string; pinnedById?: string; readByAll?: boolean; poll?: { id: string; question: string; allowsMultiple: boolean; isAnonymous: boolean; options: ChatPollOption[]; }; reactions?: ChatMessageReaction[]; } export interface FamilyPresenceMember { userId: string; displayName: string; online: boolean; lastSeenAt?: string; } export interface FamilyPresence { members: FamilyPresenceMember[]; onlineCount: number; } export interface ChatRoomMember { id: string; userId: string; displayName: string; hasAvatar: boolean; isVerified?: boolean; verificationIcon?: string | null; notificationsMuted: boolean; isPinned?: boolean; pinnedAt?: string; familyRole?: string; isChatCreator?: boolean; inFamily?: boolean; } export interface ChatRoom { id: string; groupId: string; type: string; name: string; peerUserId?: string; botUsername?: string; isE2E?: boolean; hasAvatar: boolean; createdById?: string; updatedAt: string; isPinned?: boolean; pinnedAt?: string; peerLeftFamily?: boolean; members: ChatRoomMember[]; lastMessage?: ChatMessage; } export async function fetchFamilyGroups(userId: string, token?: string | null) { return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, token); } export async function fetchFamilyGroup(groupId: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}`, {}, token); } export async function updateFamilyGroup(groupId: string, name: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token); } export async function deleteFamilyGroup(groupId: string, token?: string | null) { return apiFetch<{ count: number }>(`/family/groups/${groupId}`, { method: 'DELETE' }, token); } export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId: string; target?: string }, token?: string | null) { return apiFetch(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token); } export async function fetchBotChatMessages(botRef: string, token?: string | null, roomId?: string) { const suffix = roomId ? `?roomId=${encodeURIComponent(roomId)}` : ''; return apiFetch<{ messages?: BotChatMessage[]; botUsername?: string; botDisplayName?: string; botId?: string; botOwnerId?: string; composerWebAppUrl?: string; composerMenuButtonJson?: string; manageWebAppUrl?: string; }>(`/bots/by-username/${encodeURIComponent(botRef)}/messages${suffix}`, {}, token); } export async function sendBotMessage(botRef: string, text: string, token?: string | null, roomId?: string) { return apiFetch<{ updateId?: number; messageId?: number; chatId?: string }>( `/bots/by-username/${encodeURIComponent(botRef)}/messages`, { method: 'POST', body: JSON.stringify({ text, roomId }) }, token ); } export async function submitBotCallback(botRef: string, messageId: number, callbackData: string, token?: string | null) { return apiFetch<{ updateId?: number; callbackQueryId?: string; messageId?: number }>( `/bots/by-username/${encodeURIComponent(botRef)}/callback`, { method: 'POST', body: JSON.stringify({ messageId, callbackData }) }, token ); } export async function fetchFamilyInvites(token?: string | null) { return apiFetch<{ invites?: FamilyInvite[] }>('/family/invites', {}, token); } export async function respondFamilyInvite(inviteId: string, accept: boolean, token?: string | null) { return apiFetch(`/family/invites/${inviteId}/respond`, { method: 'POST', body: JSON.stringify({ accept }) }, token); } export async function fetchNotifications(token?: string | null, unreadOnly = false) { return apiFetch<{ notifications?: AppNotification[] }>(`/notifications?unreadOnly=${unreadOnly}`, {}, token); } export async function fetchUnreadNotificationCount(token?: string | null) { return apiFetch<{ count: number }>('/notifications/unread-count', {}, token); } export async function markNotificationRead(notificationId: string, token?: string | null) { return apiFetch(`/notifications/${notificationId}/read`, { method: 'PATCH', body: '{}' }, token); } export async function markAllNotificationsRead(token?: string | null) { return apiFetch('/notifications/read-all', { method: 'POST' }, token); } export async function fetchChatRooms(groupId: string, token?: string | null) { return apiFetch<{ rooms?: ChatRoom[] }>(`/chat/groups/${groupId}/rooms`, {}, token); } export async function createChatRoom(groupId: string, name: string, memberUserIds: string[], token?: string | null) { return apiFetch(`/chat/groups/${groupId}/rooms`, { method: 'POST', body: JSON.stringify({ name, memberUserIds }) }, token); } export async function addChatRoomMember(roomId: string, memberUserId: string, token?: string | null) { return apiFetch(`/chat/rooms/${roomId}/members`, { method: 'POST', body: JSON.stringify({ memberUserId }) }, token); } export async function removeChatRoomMember(roomId: string, memberUserId: string, token?: string | null) { return apiFetch(`/chat/rooms/${roomId}/members/${memberUserId}`, { method: 'DELETE' }, token); } export async function removeFamilyMember(memberId: string, token?: string | null) { return apiFetch<{ count: number }>(`/family/members/${memberId}`, { method: 'DELETE' }, token); } export async function leaveFamilyGroup(groupId: string, token?: string | null, newOwnerUserId?: string) { return apiFetch<{ count: number; deleted?: boolean }>( `/family/groups/${groupId}/leave`, { method: 'POST', body: JSON.stringify(newOwnerUserId ? { newOwnerUserId } : {}) }, token ); } export async function transferFamilyOwnership(groupId: string, newOwnerUserId: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}/transfer-ownership`, { method: 'POST', body: JSON.stringify({ newOwnerUserId }) }, token); } export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) { const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : ''; return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token); } export async function sendChatMessage( roomId: string, body: { type: string; content?: string; isEncrypted?: boolean; replyToId?: string; storageKey?: string; mimeType?: string; metadataJson?: string; poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean }; }, token?: string | null ) { return apiFetch(`/chat/rooms/${roomId}/messages`, { method: 'POST', body: JSON.stringify(body) }, token); } export async function fetchUserE2EPublicKey(userId: string, token?: string | null) { return apiFetch<{ userId?: string; e2ePublicKey?: string }>(`/profile/users/${userId}/e2e-public-key`, {}, token); } export async function setUserE2EPublicKey(userId: string, publicKey: string, token?: string | null) { return apiFetch<{ userId?: string; e2ePublicKey?: string }>( `/profile/users/${userId}/e2e-public-key`, { method: 'PATCH', body: JSON.stringify({ publicKey }) }, token ); } export interface ManagedBot { id: string; name: string; username: string; tokenPrefix?: string; description?: string | null; aboutText?: string | null; botPicUrl?: string | null; menuButtonJson?: string | null; webAppUrl?: string | null; isActive?: boolean; isSystemBot?: boolean; ownerId?: string; createdAt?: string; updatedAt?: string; owner?: { id: string; displayName: string; username?: string }; messageCount?: number; chatCount?: number; } export interface AdminBotMetrics { totalBots?: number; activeBots?: number; blockedBots?: number; totalMessages?: number; totalChats?: number; } export async function fetchAdminBots( params: { search?: string; page?: number; limit?: number }, token?: string | null ) { const query = new URLSearchParams(); if (params.search?.trim()) query.set('search', params.search.trim()); if (params.page) query.set('page', String(params.page)); if (params.limit) query.set('limit', String(params.limit)); const suffix = query.toString() ? `?${query.toString()}` : ''; return apiFetch<{ bots?: ManagedBot[]; total?: number }>(`/admin/bots${suffix}`, {}, token); } export async function fetchAdminBotMetrics(token?: string | null) { return apiFetch('/admin/bots/metrics', {}, token); } export async function fetchAdminBotAccounts(search: string | undefined, token?: string | null) { const suffix = search?.trim() ? `?search=${encodeURIComponent(search.trim())}` : ''; return apiFetch<{ users: AdminUser[] }>(`/admin/users/bot-accounts${suffix}`, {}, token); } export async function fetchUserTotpStatus(userId: string, token?: string | null) { return apiFetch(`/security/users/${userId}/totp/status`, {}, token); } export async function adminDisableUserTotp(userId: string, token?: string | null) { return apiFetch(`/admin/users/${userId}/totp/admin-disable`, { method: 'POST' }, token); } export async function setAdminBotActive(botId: string, isActive: boolean, token?: string | null) { return apiFetch(`/admin/bots/${botId}/active`, { method: 'PATCH', body: JSON.stringify({ isActive }) }, token); } export async function createE2ERoom(groupId: string, peerUserId: string, token?: string | null) { return apiFetch(`/chat/groups/${groupId}/e2e-rooms`, { method: 'POST', body: JSON.stringify({ peerUserId }) }, token); } export async function fetchMyBots(token?: string | null) { return apiFetch<{ bots?: ManagedBot[]; total?: number }>('/bots', {}, token); } export async function createManagedBot(body: { name: string; username: string }, token?: string | null) { return apiFetch<{ bot?: ManagedBot; token?: string }>('/bots', { method: 'POST', body: JSON.stringify(body) }, token); } export async function fetchBot(botId: string, token?: string | null) { return apiFetch(`/bots/${botId}`, {}, token); } export async function updateManagedBot(botId: string, body: { name?: string; username?: string }, token?: string | null) { return apiFetch(`/bots/${botId}`, { method: 'PATCH', body: JSON.stringify(body) }, token); } export async function updateManagedBotProfile( botId: string, body: { description?: string; aboutText?: string; botPicUrl?: string; menuButtonUrl?: string; menuButtonText?: string; menuButtonJson?: string; }, token?: string | null ) { return apiFetch(`/bots/${botId}/profile`, { method: 'PATCH', body: JSON.stringify(body) }, token); } export async function revokeManagedBotToken(botId: string, token?: string | null) { return apiFetch<{ bot?: ManagedBot; token?: string }>(`/bots/${botId}/revoke-token`, { method: 'POST' }, token); } export async function voteChatPoll(messageId: string, optionIds: string[], token?: string | null) { return apiFetch(`/chat/messages/${messageId}/vote`, { method: 'POST', body: JSON.stringify({ optionIds }) }, token); } export async function setChatRoomMuted(roomId: string, muted: boolean, token?: string | null) { return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token); } export async function setChatRoomPinned(roomId: string, pinned: boolean, token?: string | null) { return apiFetch(`/chat/rooms/${roomId}`, { method: 'PATCH', body: JSON.stringify({ pinned }) }, token); } export async function editChatMessage(messageId: string, content: string, token?: string | null) { return apiFetch(`/chat/messages/${messageId}`, { method: 'PATCH', body: JSON.stringify({ content }) }, token); } export async function deleteChatMessage(messageId: string, token?: string | null) { return apiFetch(`/chat/messages/${messageId}`, { method: 'DELETE' }, token); } export async function toggleChatMessageReaction(messageId: string, emoji: string, token?: string | null) { return apiFetch(`/chat/messages/${messageId}/reactions`, { method: 'POST', body: JSON.stringify({ emoji }) }, token); } export async function setChatMessagePinned(messageId: string, pinned: boolean, token?: string | null) { return apiFetch(`/chat/messages/${messageId}/pin`, { method: 'POST', body: JSON.stringify({ pinned }) }, token); } export async function forwardChatMessages(targetRoomId: string, messageIds: string[], token?: string | null) { return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${targetRoomId}/forward`, { method: 'POST', body: JSON.stringify({ messageIds }) }, token); } export async function deleteChatRoom(roomId: string, token?: string | null) { return apiFetch<{ count?: number }>(`/chat/rooms/${roomId}`, { method: 'DELETE' }, token); } export async function markChatRoomRead(roomId: string, lastMessageId: string | undefined, token?: string | null) { return apiFetch<{ count: number }>(`/chat/rooms/${roomId}/read`, { method: 'POST', body: JSON.stringify({ lastMessageId }) }, token); } export async function reportChatTyping(roomId: string, token?: string | null) { return apiFetch<{ success?: boolean }>(`/chat/rooms/${roomId}/typing`, { method: 'POST' }, token); } export async function fetchFamilyPresence(groupId: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}/presence`, {}, token); } /** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */ export const WS_URL = configuredWsUrl;