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

+127 -24
View File
@@ -1,34 +1,69 @@
// The live connection with signed-in proxies: which IMVU account each client is using (presence), and the
// community products it wears. Later, room channels.
// The live connection with signed-in proxies: which IMVU account each client is using (presence), the community
// products it wears, and the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
// community products).
//
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
// a bearer token):
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
// proxy -> hub {"type": "outfit", "items": [{"id", "name"}]} the community products it wears now
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
// proxy -> hub {"type": "outfit", "items": [{"id", "name", "adult"}]} the community products it wears now
// proxy -> hub {"type": "join", "chat": "1379947638", "room": "1-2"} the client is in this IMVU chat (room: the
// IMVU room instance, for a room's chat)
// proxy -> hub {"type": "leave", "chat": "1379947638"}
// hub -> proxy {"type": "chat", "chat": "...", "avatars": [{"imvu_id", "items"}]}
// the other T5DE users in that chat and what they wear; sent on join and on every change. Adult
// products only go to clients whose IMVU account counts as adult.
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
export type Item = { id: string; name: string };
export type Item = { id: string; name: string; adult: boolean };
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[] };
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is).
type Session = { userId: string; imvuId: number; requested: number; since: Date; outfit: Item[]; close: () => void };
// chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told.
type Session = {
userId: string;
imvuId: number;
requested: number;
adult: boolean;
since: Date;
outfit: Item[];
chats: Map<string, string>;
lastSent: Map<string, string>;
send: (message: object) => void;
close: () => void;
};
export type Owners = (imvuId: number) => Promise<string | undefined>;
export type RealtimeDeps = {
/** The person an IMVU account is linked to. */
owner: (imvuId: number) => Promise<string | undefined>;
/** Whether a person's IMVU account counts as adult (the age rule). */
adult?: (userId: string, imvuId: number) => Promise<boolean>;
/** A client entered an IMVU room (to learn about rooms for the rooms list). */
room?: (instanceId: string) => void;
};
const maxChats = 16;
const chatPattern = /^[0-9]{1,20}$/;
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
export class Realtime {
private sessions = new Map<string, Session>();
private listeners = new Set<() => void>();
private readonly deps: Required<RealtimeDeps>;
constructor(private readonly owner: Owners) {}
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
const d = typeof deps === "function" ? { owner: deps } : deps;
this.deps = { adult: async () => false, room: () => {}, ...d };
}
/** Calls fn on every change of presence or outfits; returns a function that stops it. */
/** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
subscribe(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private changed() {
private changed(chats: Iterable<string> = []) {
for (const chat of new Set(chats)) this.sync(chat);
for (const fn of this.listeners) fn();
}
@@ -39,7 +74,8 @@ export class Realtime {
if (!s.imvuId) continue;
const prev = byId.get(s.imvuId);
if (prev && prev.since > s.since) continue;
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit] });
const rooms = [...s.chats.values()].filter(Boolean);
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit], rooms });
}
return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime());
}
@@ -48,40 +84,57 @@ export class Realtime {
return this.sessions.size;
}
open(id: string, userId: string, close: () => void) {
this.sessions.set(id, { userId, imvuId: 0, requested: 0, since: new Date(), outfit: [], close });
open(id: string, userId: string, close: () => void, send: (message: object) => void = () => {}) {
this.sessions.set(id, {
userId,
imvuId: 0,
requested: 0,
adult: false,
since: new Date(),
outfit: [],
chats: new Map(),
lastSent: new Map(),
send,
close,
});
}
close(id: string) {
const s = this.sessions.get(id);
this.sessions.delete(id);
if (s?.imvuId) this.changed();
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
}
/** A person just linked an IMVU account: their clients already using it count now. */
linked(userId: string, imvuId: number) {
async linked(userId: string, imvuId: number) {
const chats: string[] = [];
let changed = false;
for (const s of this.sessions.values()) {
if (s.userId === userId && s.requested === imvuId && s.imvuId !== imvuId) {
s.imvuId = imvuId;
s.since = new Date();
s.adult = await this.deps.adult(userId, imvuId);
chats.push(...s.chats.keys());
changed = true;
}
}
if (changed) this.changed();
if (changed) this.changed(chats);
}
/** An IMVU account was unlinked (or moved): it no longer counts for that person's clients. */
unlinked(userId: string, imvuId: number) {
const chats: string[] = [];
let changed = false;
for (const s of this.sessions.values()) {
if (s.userId === userId && s.imvuId === imvuId) {
s.imvuId = 0;
s.adult = false;
s.outfit = [];
chats.push(...s.chats.keys());
changed = true;
}
}
if (changed) this.changed();
if (changed) this.changed(chats);
}
/** Closes a person's connections (banned, or signed out of their computers). */
@@ -89,27 +142,59 @@ export class Realtime {
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
}
/** Tells everyone in a chat about the other T5DE users there (only those it wasn't told already). */
private sync(chat: string) {
const members = [...this.sessions.values()].filter((s) => s.chats.has(chat));
for (const viewer of members) {
const avatars = new Map<number, Item[]>();
for (const other of members) {
if (!other.imvuId || other.imvuId === viewer.imvuId) continue;
avatars.set(
other.imvuId,
other.outfit.filter((it) => !it.adult || viewer.adult),
);
}
const message = {
type: "chat",
chat,
avatars: [...avatars].map(([imvuId, items]) => ({ imvu_id: imvuId, items })),
};
const text = JSON.stringify(message);
if (viewer.lastSent.get(chat) === text) continue;
viewer.lastSent.set(chat, text);
viewer.send(message);
}
}
/** Handles a message from a proxy; returns the reply to send, if any. */
async message(id: string, raw: unknown): Promise<object | undefined> {
const s = this.sessions.get(id);
if (!s) return;
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as { type?: unknown; imvu_id?: unknown; items?: unknown };
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as {
type?: unknown;
imvu_id?: unknown;
items?: unknown;
chat?: unknown;
room?: unknown;
};
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
if (m.type === "session") {
const imvuId = Number(m.imvu_id);
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
const owner = Number.isSafeInteger(imvuId) && imvuId > 0 ? await this.owner(imvuId) : undefined;
const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
if (owner !== s.userId) {
// Not this person's account: no presence, no outfit for it.
s.imvuId = 0;
s.adult = false;
s.outfit = [];
this.changed();
this.changed(s.chats.keys());
return { type: "error", message: "not_linked" };
}
s.imvuId = imvuId;
s.since = new Date();
s.outfit = [];
this.changed();
s.adult = await this.deps.adult(s.userId, imvuId);
this.changed(s.chats.keys());
return;
}
if (m.type === "outfit") {
@@ -119,8 +204,26 @@ export class Realtime {
s.outfit = items
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
.map((it) => ({ id: it.id, name: it.name }));
this.changed();
.map((it) => ({ id: it.id, name: it.name, adult: it.adult === true }));
this.changed(s.chats.keys());
return;
}
if (m.type === "join" || m.type === "leave") {
const chat = typeof m.chat === "string" ? m.chat : String(m.chat ?? "");
if (!chatPattern.test(chat)) return { type: "error", message: "bad chat" };
if (m.type === "leave") {
if (!s.chats.delete(chat)) return;
s.lastSent.delete(chat);
this.changed([chat]);
return;
}
const room = typeof m.room === "string" && roomPattern.test(m.room) ? m.room : "";
if (!s.chats.has(chat) && s.chats.size >= maxChats) return { type: "error", message: "too many chats" };
s.chats.set(chat, room);
s.lastSent.delete(chat); // tell it again, even if nothing changed
if (s.imvuId) s.adult = await this.deps.adult(s.userId, s.imvuId);
if (room) this.deps.room(room);
this.changed([chat]);
return;
}
return { type: "error", message: "unknown message type" };