import type { AccountDetail, AccountListResponse, AccountRatingDetail, AccountRatingListResponse, AccountStatsResponse, AccountStorageListResponse, SharedDeviceGroupListResponse, StorageStatsResponse, AdminLoginResult, AdminSession, BotDetail, BotListResponse, BotVerificationCountsResponse, BotVerifierListResponse, BroadcastListResponse, ChannelDetail, CustomVerificationListResponse, CustomVerificationRequestDetail, CustomVerificationRequestListResponse, VerificationIconListResponse, EmojiListResponse, ChannelListResponse, CollectibleUsernameDetail, CollectibleUsernameListResponse, CommandResult, GroupMessageDetail, GroupMessageListResponse, MessageDetail, MessageListResponse, DefaultGiftListResponse, ModerationCaseDetail, ModerationCaseRow, ModerationReport, OfficialStarGiftListResponse, StarGiftCollectiblePreview, StarGiftListResponse, StickerSetListResponse, VerificationApplicationDetail, VerificationApplicationListResponse, VerificationCountsResponse } from "./types"; export class APIError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } } // The backend publishes the CSRF token in a deliberately readable cookie and // refuses every mutating request whose X-CSRF-Token header does not repeat it // (cmd/telesrv-admin/security.go). Echoing it here — inside request — is what // keeps a new endpoint from silently shipping without the header. const csrfCookieName = "telesrv_admin_csrf"; const csrfHeaderName = "X-CSRF-Token"; // Login answers with the token in the body as well as in Set-Cookie. Keeping the // body value is the fallback for the window where the browser has not applied // the cookie yet, or where the cookie is not readable back to the script. let issuedCSRFToken = ""; export function rememberCSRFToken(token: string | undefined): void { issuedCSRFToken = (token ?? "").trim(); } function readCSRFCookie(): string { if (typeof document === "undefined") return ""; for (const chunk of document.cookie.split(";")) { const entry = chunk.trim(); const separator = entry.indexOf("="); if (separator <= 0 || entry.slice(0, separator) !== csrfCookieName) continue; try { return decodeURIComponent(entry.slice(separator + 1)); } catch { return entry.slice(separator + 1); } } return ""; } // The cookie wins: it is the value the server compares against, and it survives // a page reload that the in-memory copy does not. export function csrfToken(): string { return readCSRFCookie() || issuedCSRFToken; } // Same classification the backend uses: GET/HEAD/OPTIONS are safe, everything // else carries a token. function mutatingMethod(method: string | undefined): boolean { const verb = (method ?? "GET").toUpperCase(); return verb !== "GET" && verb !== "HEAD" && verb !== "OPTIONS"; } function plainHeaders(source: HeadersInit | undefined): Record { if (!source) return {}; if (source instanceof Headers) { const out: Record = {}; source.forEach((value, key) => { out[key] = value; }); return out; } if (Array.isArray(source)) { return Object.fromEntries(source); } return { ...source }; } async function request(url: string, init: RequestInit = {}): Promise { const isForm = typeof FormData !== "undefined" && init.body instanceof FormData; // A multipart body must keep the boundary the browser generates, so its // Content-Type is left alone; the CSRF header is added either way. const headers: Record = isForm ? {} : { "Content-Type": "application/json" }; Object.assign(headers, plainHeaders(init.headers)); if (mutatingMethod(init.method)) { const token = csrfToken(); if (token) { headers[csrfHeaderName] = token; } } const response = await fetch(url, { credentials: "same-origin", ...init, headers }); const text = await response.text(); const data = text ? JSON.parse(text) : null; if (!response.ok) { const message = data?.error || data?.Error || data?.message || response.statusText; throw new APIError(response.status, message); } return data as T; } export function errorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error); } export const api = { session: () => request("/api/session"), login: async (secret: string) => { const result = await request("/api/login", { method: "POST", body: JSON.stringify({ secret }) }); // Stashed here rather than in the caller so no login path can forget it. rememberCSRFToken(result.csrf_token); return result; }, logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }), accounts: (params: URLSearchParams) => request(`/api/accounts?${params.toString()}`), accountStats: () => request("/api/accounts/stats"), sharedDeviceGroups: (params: URLSearchParams) => request(`/api/accounts/shared-devices?${params.toString()}`), account: (id: number) => request(`/api/accounts/${id}`), channels: (params: URLSearchParams) => request(`/api/channels?${params.toString()}`), channel: (id: number) => request(`/api/channels/${id}`), bots: (params: URLSearchParams) => request(`/api/bots?${params.toString()}`), broadcasts: (params: URLSearchParams) => request(`/api/broadcasts?${params.toString()}`), bot: (id: number) => request(`/api/bots/${id}`), collectibleUsernames: (params: URLSearchParams) => request(`/api/collectible-usernames?${params.toString()}`), collectibleUsername: (id: string) => request(`/api/collectible-usernames/${encodeURIComponent(id)}`), accountRatings: (params: URLSearchParams) => request(`/api/account-ratings?${params.toString()}`), accountRating: (userID: string) => request(`/api/account-ratings/${encodeURIComponent(userID)}`), storageStats: () => request("/api/storage/stats"), storageAccounts: (params: URLSearchParams) => request(`/api/storage/accounts?${params.toString()}`), verificationApplications: (params: URLSearchParams) => request(`/api/verification/applications?${params.toString()}`), // The application id is an int64 decimal string end to end, so it is never // parsed into a number on the way to the URL. verificationApplication: (id: string) => request(`/api/verification/applications/${encodeURIComponent(id)}`), verificationCounts: () => request("/api/verification/counts"), // Third-party verification lives under its own prefix: the two mechanisms share // no state, so they share no route either. botVerifiers: (params: URLSearchParams) => request(`/api/botverification/verifiers?${params.toString()}`), verificationIcons: (params: URLSearchParams) => request(`/api/botverification/icons?${params.toString()}`), customVerifications: (params: URLSearchParams) => request(`/api/botverification/marks?${params.toString()}`), customVerificationRequests: (params: URLSearchParams) => request(`/api/botverification/requests?${params.toString()}`), // The application id is an int64 decimal string end to end, so it is never parsed // into a number on the way to the URL. customVerificationRequest: (id: string) => request(`/api/botverification/requests/${encodeURIComponent(id)}`), botVerificationCounts: () => request("/api/botverification/counts"), emoji: (params: URLSearchParams) => request(`/api/emoji?${params.toString()}`), emojiAnimation: (documentID: string) => request>(`/api/emoji/${encodeURIComponent(documentID)}/animation`), messages: (params: URLSearchParams) => request(`/api/messages?${params.toString()}`), message: (ownerUserID: number, msgID: number) => { const params = new URLSearchParams({ owner_user_id: String(ownerUserID), msg_id: String(msgID) }); return request(`/api/messages/detail?${params.toString()}`); }, groupMessages: (params: URLSearchParams) => request(`/api/messages/groups?${params.toString()}`), groupMessage: (channelID: number, msgID: number) => { const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) }); return request(`/api/messages/groups/detail?${params.toString()}`); }, moderationCases: (params: URLSearchParams) => request<{ cases: ModerationCaseRow[] }>(`/api/moderation/cases?${params.toString()}`), moderationCase: (id: number) => request(`/api/moderation/cases/${id}`), moderationReport: (id: number) => request(`/api/moderation/reports/${id}`), claimModerationCase: (id: number, expectedVersion: number) => request(`/api/moderation/cases/${id}/claim`, { method: "POST", body: JSON.stringify({ expected_version: expectedVersion }) }), decideModerationCase: (id: number, payload: Record) => request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${id}/decide`, { method: "POST", body: JSON.stringify(payload) }), reviewModerationAppeal: (caseID: number, appealID: number, payload: Record) => request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${caseID}/appeals/${appealID}/review`, { method: "POST", body: JSON.stringify(payload) }), gifts: () => request("/api/gifts"), stickerSets: (kind: string) => request(`/api/stickers?kind=${encodeURIComponent(kind)}`), stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`), stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`, createStickerSet: (form: FormData) => request("/api/actions/create-sticker-set", { method: "POST", body: form }), setAccountAvatar: (form: FormData) => request("/api/actions/set-account-avatar", { method: "POST", body: form }), setChannelAvatar: (form: FormData) => request("/api/actions/set-channel-avatar", { method: "POST", body: form }), addStickerToSet: (form: FormData) => request("/api/actions/add-sticker-to-set", { method: "POST", body: form }), defaultGifts: () => request("/api/default-gifts"), defaultGiftAnimation: (id: number) => request>(`/api/default-gifts/${id}/animation`), officialGifts: () => request("/api/official-gifts"), officialGiftAnimation: (id: string) => request>(`/api/official-gifts/${encodeURIComponent(id)}/animation`), giftAnimation: (id: string) => request>(`/api/gifts/${encodeURIComponent(id)}/animation`), giftCollectibles: (id: string) => request(`/api/gifts/${encodeURIComponent(id)}/collectibles`), giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`), importGift: (form: FormData) => request("/api/actions/import-gift", { method: "POST", body: form }), importDefaultGift: (payload: Record) => request("/api/actions/import-default-gift", { method: "POST", body: JSON.stringify(payload) }), importOfficialGift: (payload: Record) => request("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }), publishGiftCollectibles: (giftID: string, form: FormData) => request(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }), action: (path: string, payload: Record) => request(path, { method: "POST", body: JSON.stringify(payload) }) };