// IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass; // rooms (for the rooms list); and products (the IMVU products T5 products derive from). The hub calls it rarely // (linking, age checks, rooms T5DE users enter, uploads) and depends on it for nothing else. export type ImvuUser = { id: number; username: string; tagline: string; ageVerified: boolean; accessPass: boolean; picture: string | null; // the avatar's picture (https, on *.imvu.com) }; export type ImvuRoom = { instanceId: string; // "-" ownerId: number; name: string; description: string; imageURL: string; privacy: string; // "public", ... type: string; // "listed", "unlisted", ... contentRating: string; // "GA", "AP" accessPass: boolean; occupancy: number; capacity: number; expired: boolean; }; /** An IMVU product's public info, as T5 products derived from it show it. */ export type ImvuProduct = { image: string | null; // https, on *.imvu.com categories: { id: number; name: string }[]; // outermost first, e.g. Clothing / Clothing for Female / Tops gender: string; // "female", "male", or "" (either) }; export class NoSuchImvuUser extends Error { constructor() { super("no such IMVU account"); } } export class ImvuUnavailable extends Error {} // Calls are spaced out so the hub never floods IMVU. const minInterval = 200; export class ImvuClient { private next = 0; constructor( readonly base = "https://api.imvu.com", private readonly fetchFn: typeof fetch = fetch, ) {} byUsername(username: string): Promise { const name = username.trim(); if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser()); return this.get(`/user?username=${encodeURIComponent(name)}`).then(parse); } byId(id: number): Promise { return this.get(`/user/user-${id}`).then(parse); } /** A room by its instance ID ("-"); undefined if there's no such room. */ async room(instanceId: string): Promise { if (!/^[0-9]{1,12}-[0-9]{1,12}$/.test(instanceId)) return undefined; try { return parseRoom(await this.get(`/room/room-${instanceId}`), instanceId); } catch (e) { if (e instanceof NoSuchImvuUser) return undefined; throw e; } } /** * An IMVU product's public info; null if IMVU shows no such product publicly (it answers 401 for some, such as * Access Pass ones). Throws ImvuUnavailable if IMVU can't be reached. */ async product(id: number): Promise { if (!Number.isSafeInteger(id) || id <= 0) return null; try { return parseProduct(await this.get(`/product/product-${id}`, [401, 403]), id); } catch (e) { if (e instanceof NoSuchImvuUser) return null; throw e; } } private async wait() { const now = Date.now(); const at = Math.max(now, this.next); this.next = at + minInterval; if (at > now) await Bun.sleep(at - now); } /** GETs an IMVU API path; 404 (and the statuses in `missing`) throw NoSuchImvuUser. */ private async get(path: string, missing: number[] = []): Promise { await this.wait(); let response: Response; try { response = await this.fetchFn(this.base + path, { headers: { "User-Agent": "t5de-hub (https://git.t5de.dev/t5de/t5de-hub)" }, signal: AbortSignal.timeout(15_000), }); } catch (e) { throw new ImvuUnavailable(String(e)); } if (response.status === 404 || missing.includes(response.status)) throw new NoSuchImvuUser(); if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`); return response.json(); } } export function parseRoom(answer: unknown, instanceId: string): ImvuRoom | undefined { const den = (answer as { denormalized?: Record }> })?.denormalized ?? {}; const entry = Object.entries(den).find(([key]) => key.endsWith(`/room/room-${instanceId}`)); const d = entry?.[1].data; if (!d) return undefined; const str = (v: unknown) => (typeof v === "string" ? v : ""); const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0); let image = str(d.image_url); if (image.startsWith("//")) image = "https:" + image; if (!image.startsWith("https://")) image = ""; return { instanceId, ownerId: num(d.customers_id) || Number(instanceId.split("-")[0]), name: str(d.name).slice(0, 200), description: str(d.description).slice(0, 2000), imageURL: image, privacy: str(d.privacy), type: str(d.type), contentRating: str(d.content_rating), accessPass: d.is_ap === true, occupancy: num(d.occupancy), capacity: num(d.capacity), expired: d.is_expired === true, }; } /** Whether an image address is one of IMVU's (https, on imvu.com or a subdomain). */ export function imvuImage(v: unknown): string | null { if (typeof v !== "string" || v.length > 1000) return null; let url: URL; try { url = new URL(v.startsWith("//") ? "https:" + v : v); } catch { return null; } if (url.protocol !== "https:" || url.username || url.password || url.port) return null; if (url.hostname !== "imvu.com" && !url.hostname.endsWith(".imvu.com")) return null; return url.href; } export function parseProduct(answer: unknown, id: number): ImvuProduct | null { const den = (answer as { denormalized?: Record }> })?.denormalized ?? {}; const entry = Object.entries(den).find(([key]) => key.endsWith(`/product/product-${id}`)); const d = entry?.[1].data; if (!d) return null; const path = Array.isArray(d.category_path) ? d.category_path : []; const categories = path.slice(0, 10).flatMap((c: unknown) => { const { id, name } = (c ?? {}) as { id?: unknown; name?: unknown }; const n = Number(id); if (!Number.isSafeInteger(n) || n <= 0 || typeof name !== "string" || !name.trim()) return []; return [{ id: n, name: name.trim().slice(0, 100) }]; }); const gender = d.gender === "female" || d.gender === "male" ? d.gender : ""; return { image: imvuImage(d.product_image), categories, gender }; } // IMVU's "denormalized" answers: the user is the one /user/user- entry. export function parse(answer: unknown): ImvuUser { const den = (answer as { denormalized?: Record }> })?.denormalized ?? {}; const users = Object.entries(den).filter(([key]) => key.includes("/user/user-")); if (users.length !== 1) throw new NoSuchImvuUser(); const data = users[0]![1].data ?? {}; const id = Number(data.legacy_cid); const username = typeof data.username === "string" ? data.username : ""; if (!Number.isSafeInteger(id) || id <= 0 || !username) throw new NoSuchImvuUser(); return { id, username, tagline: typeof data.tagline === "string" ? data.tagline : "", ageVerified: data.is_ageverified === true, accessPass: data.is_ap === true, picture: imvuImage(data.thumbnail_url), }; }