// The rooms list: IMVU rooms owned by linked accounts that chose to list them. The hub learns of a room when a T5DE // user enters it, and asks IMVU about it (owner, public and listed or not); it's checked again while it's in use, // so a room made unlisted or closed drops off the list. import { and, desc, eq, gt, lt, schema, type Database } from "@t5de/db"; import type { ImvuClient } from "./imvu"; const { room, imvuLink } = schema; export type Room = typeof room.$inferSelect; const recheckAfter = 60 * 60 * 1000; // Rooms nobody entered for this long aren't checked again or listed. const forgetAfter = 30 * 24 * 60 * 60 * 1000; /** Whether IMVU says anyone may find and enter the room. */ export const isListable = (r: Pick) => r.privacy === "public" && r.type === "listed" && !r.expired; /** Whether the room is for adults (Access Pass rooms). */ export const isAdultRoom = (r: Pick) => r.accessPass || r.contentRating === "AP"; export class Rooms { private checking = new Set(); constructor( private readonly db: Database, private readonly imvu: ImvuClient, ) {} /** A T5DE user entered a room: remember it if its owner is linked, asking IMVU about it if it's due. */ async seen(instanceId: string) { const ownerId = Number(instanceId.split("-")[0]); const [owner] = await this.db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.imvuId, ownerId)); if (!owner) return; const [known] = await this.db.select().from(room).where(eq(room.instanceId, instanceId)); if (known && Date.now() - known.checkedAt.getTime() < recheckAfter) { await this.db.update(room).set({ seenAt: new Date() }).where(eq(room.instanceId, instanceId)); return; } await this.check(instanceId); } /** Asks IMVU about a room again, and records what it says (or forgets the room if it's gone). */ async check(instanceId: string) { if (this.checking.has(instanceId)) return; this.checking.add(instanceId); try { const r = await this.imvu.room(instanceId); if (!r || String(r.ownerId) !== instanceId.split("-")[0]) { await this.db.delete(room).where(eq(room.instanceId, instanceId)); return; } const values = { ownerImvuId: r.ownerId, name: r.name, description: r.description, imageURL: r.imageURL, privacy: r.privacy, type: r.type, contentRating: r.contentRating, accessPass: r.accessPass, occupancy: r.occupancy, capacity: r.capacity, expired: r.expired, checkedAt: new Date(), seenAt: new Date(), }; await this.db .insert(room) .values({ instanceId, ...values }) .onConflictDoUpdate({ target: room.instanceId, set: values }); } finally { this.checking.delete(instanceId); } } /** Checks again rooms in use whose answer is old (at most limit). */ async recheckDue(limit = 20): Promise { const due = await this.db .select({ instanceId: room.instanceId }) .from(room) .where(and(lt(room.checkedAt, new Date(Date.now() - recheckAfter)), gt(room.seenAt, new Date(Date.now() - forgetAfter)))) .limit(limit); let n = 0; for (const r of due) { try { await this.check(r.instanceId); n++; } catch { // IMVU unavailable: try again next time. } } return n; } /** The rooms to list: listable, owner chose to list, entered lately; with the owner's IMVU name. */ async listed() { const rows = await this.db .select({ room, owner: imvuLink.username }) .from(room) .innerJoin(imvuLink, eq(imvuLink.imvuId, room.ownerImvuId)) .where(and(eq(imvuLink.listRooms, true), gt(room.seenAt, new Date(Date.now() - forgetAfter)))) .orderBy(desc(room.seenAt)); return rows.filter((r) => isListable(r.room)); } /** Forgets an IMVU account's rooms (it was unlinked). */ async forgetOwner(imvuId: number) { await this.db.delete(room).where(eq(room.ownerImvuId, imvuId)); } }