CI / test (push) Successful in 1m11s
The header shows T5DE's logo (the client's, also the tab icon) and Online, Rooms, Products and Catalogs; the person's pages (Account, Library, Console for moderators) and signing out are in a menu under their picture: their first linked IMVU account's avatar picture, which /me now gives (kept an hour), or their initial. Creating a product moves to Products (New product, /products/new; /create redirects there), as creating a room is on Rooms. Co-Authored-By: Claude Opus 5.5 <[email protected]>
189 lines
6.9 KiB
TypeScript
189 lines
6.9 KiB
TypeScript
// 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; // "<owner>-<n>"
|
|
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<ImvuUser> {
|
|
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<ImvuUser> {
|
|
return this.get(`/user/user-${id}`).then(parse);
|
|
}
|
|
|
|
/** A room by its instance ID ("<owner>-<n>"); undefined if there's no such room. */
|
|
async room(instanceId: string): Promise<ImvuRoom | undefined> {
|
|
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<ImvuProduct | null> {
|
|
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<unknown> {
|
|
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<string, { data?: Record<string, unknown> }> })?.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<string, { data?: Record<string, unknown> }> })?.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-<id> entry.
|
|
export function parse(answer: unknown): ImvuUser {
|
|
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.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),
|
|
};
|
|
}
|