Room channel and rooms list
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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 18:14:43 -07:00
1 parent 7db9fc0370
commit 6ba4d83317
18 files changed
+1838 -50

No files matched your search

+73 -10
View File
@@ -8,10 +8,11 @@ import { AgeError, AgeService } from "./age";
import { imvuAgeProvider, imvuResult } from "./age/imvu";
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms";
const { user, session, imvuLink, linkChallenge, audit } = schema;
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService };
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService; rooms?: Rooms };
/** An error shown to the person: the website displays message as is. */
export class Problem extends Error {
@@ -61,13 +62,20 @@ class Limiter {
}
}
export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Deps) {
export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService }: Deps) {
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
const rooms = roomsService ?? new Rooms(db, imvu);
const realtime =
rt ??
new Realtime(async (imvuId) => {
const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
return l?.userId;
new Realtime({
owner: async (imvuId) => {
const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
return l?.userId;
},
adult: async (userId, imvuId) => (await age.adultAccounts(userId, [imvuId])).has(imvuId),
room: (instanceId) => {
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
},
});
const limiter = new Limiter();
@@ -101,6 +109,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
checkedAt: (check?.checkedAt ?? l.linkedAt).toISOString(),
showBadge: l.showBadge,
showPresence: l.showPresence,
listRooms: l.listRooms,
online: live !== undefined,
outfit: live?.outfit ?? [],
};
@@ -365,8 +374,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
// Age from IMVU, for this account; a moved account's earlier checks belonged to its previous owner.
if (result.moved) await age.forgetAccount(found.id);
await age.record({ userId: me.id, imvuId: found.id }, "imvu", imvuResult(found));
if (result.moved) realtime.unlinked(result.previousOwner!, found.id);
realtime.linked(me.id, found.id);
if (result.moved) {
await rooms.forgetOwner(found.id);
realtime.unlinked(result.previousOwner!, found.id);
}
await realtime.linked(me.id, found.id);
return { link: await linkView(result.link), moved: result.moved };
},
{ signedIn: true, params: imvuIdParam },
@@ -388,6 +400,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
.set({
...(body.showBadge !== undefined ? { showBadge: body.showBadge } : {}),
...(body.showPresence !== undefined ? { showPresence: body.showPresence } : {}),
...(body.listRooms !== undefined ? { listRooms: body.listRooms } : {}),
})
.where(eq(imvuLink.imvuId, params.imvu))
.returning();
@@ -396,7 +409,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
{
signedIn: true,
params: imvuIdParam,
body: t.Object({ showBadge: t.Optional(t.Boolean()), showPresence: t.Optional(t.Boolean()) }),
body: t.Object({
showBadge: t.Optional(t.Boolean()),
showPresence: t.Optional(t.Boolean()),
listRooms: t.Optional(t.Boolean()),
}),
},
)
.post(
@@ -416,6 +433,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
const l = await ownLink(me, params.imvu);
await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId));
await age.forgetAccount(l.imvuId);
await rooms.forgetOwner(l.imvuId);
realtime.unlinked(me.id, l.imvuId);
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username });
set.status = 204;
@@ -450,6 +468,46 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
)
// IMVU rooms whose owners list them, with the T5DE users in each now (those showing they're online).
.get("/rooms", async ({ person: me }) => {
const viewerAdult =
me !== undefined &&
(
await age.adultAccounts(
me.id,
(await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, me.id))).map((l) => l.id),
)
).size > 0;
const listed = (await rooms.listed()).filter((r) => viewerAdult || !isAdultRoom(r.room));
const here = new Map<string, number[]>();
for (const o of realtime.online()) for (const r of o.rooms) here.set(r, [...(here.get(r) ?? []), o.imvuId]);
const ids = [...new Set([...here.values()].flat())];
const shown = ids.length
? await db
.select({ imvuId: imvuLink.imvuId, username: imvuLink.username, badge: imvuLink.showBadge })
.from(imvuLink)
.where(and(inArray(imvuLink.imvuId, ids), eq(imvuLink.showPresence, true)))
: [];
const out = listed.map(({ room: r, owner }) => {
const t5de = shown.filter((u) => here.get(r.instanceId)?.includes(u.imvuId));
return {
instanceId: r.instanceId,
name: r.name,
description: r.description,
imageURL: r.imageURL,
owner: { imvuId: r.ownerImvuId, username: owner },
adult: isAdultRoom(r),
occupancy: r.occupancy,
capacity: r.capacity,
url: `https://go.imvu.com/chat/room-${r.instanceId}`,
t5de,
seenAt: r.seenAt.toISOString(),
};
});
out.sort((a, b) => b.t5de.length - a.t5de.length);
return { rooms: out };
})
// Age verification beyond IMVU's: the providers in use, and the person's own checks.
.get(
"/age",
@@ -645,7 +703,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
},
open(ws) {
const { person } = ws.data as unknown as { person: Person };
realtime.open(ws.id, person.id, () => ws.close(4001, "signed out"));
realtime.open(
ws.id,
person.id,
() => ws.close(4001, "signed out"),
(m) => ws.send(JSON.stringify(m)),
);
ws.send(JSON.stringify({ type: "welcome" }));
},
async message(ws, message) {
@@ -663,7 +726,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
.get("/health", () => ({ ok: true }))
.use(api);
return { app, realtime, age };
return { app, realtime, age, rooms };
}
export type App = ReturnType<typeof createApp>["app"];