CI / test (push) Successful in 6m38s
The realtime connection now carries the IMVU chats a client is in: the hub tells each proxy in a chat which other T5DE users are there and what community products they wear (adult products only to adult accounts), on joining and on every change. Rooms T5DE users enter are looked up on IMVU; those that are public, listed and whose owner turned on "List my public rooms" appear on the new Rooms page, with the T5DE users in them who show they're online. AP rooms are shown to adults only. Co-Authored-By: Claude Opus 5.5 <[email protected]>
112 lines
4.1 KiB
TypeScript
112 lines
4.1 KiB
TypeScript
// 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<Room, "privacy" | "type" | "expired">) =>
|
|
r.privacy === "public" && r.type === "listed" && !r.expired;
|
|
|
|
/** Whether the room is for adults (Access Pass rooms). */
|
|
export const isAdultRoom = (r: Pick<Room, "contentRating" | "accessPass">) => r.accessPass || r.contentRating === "AP";
|
|
|
|
export class Rooms {
|
|
private checking = new Set<string>();
|
|
|
|
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<number> {
|
|
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));
|
|
}
|
|
}
|