Files
t5de-hub/apps/api/src/realtime.ts
T
dhkatzandClaude Opus 5.5 76c8e0342e
CI / test (push) Successful in 1m10s
Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
Moderators (up to 20 IMVU accounts, set by ID or name) and the owner account boot
and ban over realtime (room.boot); bans are listed and lifted over REST. Private
rooms are unlisted and joined by the owner, moderators and holders of an accepted
invite (room.invite / room.invited / room.invite_reply / room.invite_answer).
Room images (PNG/JPEG by magic bytes, up to 512 KB) are stored in the database
and served with cache headers. Per-person favorites, ratings and recent rooms.
room.watch pushes room.occupancy; participants carry their IMVU name, and
GET .../participants lists them. Contract in docs/hosted-rooms.md, "Phase 2".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:07:41 -07:00

716 lines
33 KiB
TypeScript

// The live connection with signed-in proxies: which IMVU account each client is using (presence), the community
// products it wears, the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
// community products), and T5DE-hosted rooms (the hub is their chat server; docs/hosted-rooms.md).
//
// 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", "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": "decor", "room": "1-2"}
// that IMVU room's decorations changed (GET /api/v1/decorations/1-2 again); sent to every session
// with a chat in that room
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
//
// Hosted rooms (n: the room's number; its chat ID is 2100000000 + n):
// proxy -> hub {"type": "room.join", "room": n} joining again (a reconnect) answers the same
// proxy -> hub {"type": "room.leave", "room": n}
// proxy -> hub {"type": "room.seat", "room": n, "seat": 3} 0-99
// proxy -> hub {"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."}
// to everyone else in the room (up to 64 KB); a "messages" JSON whose userId isn't the sender's IMVU
// account is dropped, and so is anything over the rate (bursts of 20, then 10 a second)
// hub -> proxy {"type": "room.joined", "room": n, "chat": chatId, "participants": [{"imvu_id", "seat", "name"}],
// "sequence": s} (the latest message number)
// hub -> proxy {"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" |
// "not_linked" | "too_many", "message": "..."}
// hub -> proxy {"type": "room.message", "room": n, "from": imvuId, "mount": "...", "message": "...", "sequence": s}
// hub -> proxy {"type": "room.participants", "room": n, "participants": [...]} on every join, leave or seat
// hub -> proxy {"type": "room.closed", "room": n} the room closed, or this session is out of it (its IMVU account
// was unlinked or changed, or the room became adult)
//
// Phase 2 (docs/hosted-rooms.md, "Phase 2", has every detail):
// proxy -> hub {"type": "room.join", "room": n, "invite"?: id} invite: an accepted invite, for a private room
// proxy -> hub {"type": "room.boot", "room": n, "imvu_id": X, "ban"?: bool, "minutes"?: m} owner or moderator
// hub -> proxy {"type": "room.boot_result", "room": n, "imvu_id": X, "ok": bool, "ban"?: bool, "reason"?, "message"?}
// hub -> proxy {"type": "room.closed", "room": n, "reason": "booted"} to the booted account's sessions
// proxy -> hub {"type": "room.invite", "room": n, "imvu_id": X}
// hub -> proxy {"type": "room.invite_result", "room": n, "imvu_id": X, "ok": bool, "invite"?: id, "reason"?, "message"?}
// hub -> proxy {"type": "room.invited", "room": n, "invite": id, "from": imvuId, "room_name", "owner_name"} (to X)
// proxy -> hub {"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."} (from X)
// hub -> proxy {"type": "room.invite_answer", "invite": id, "to": X, "accept": bool, "note"?, "offline"?: true}
// proxy -> hub {"type": "room.watch", "rooms": [n, ...]} replaces the watched set (up to 100)
// hub -> proxy {"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}
export type Item = { id: string; name: string; adult: boolean };
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
/** What realtime needs to know of a hosted room to let someone in. */
export type HostedRoomInfo = {
closed: boolean;
adult: boolean;
maxUsers: number;
privacy: "public" | "private";
ownerImvuId: number;
moderators: number[];
name: string;
ownerName: string;
};
/** A boot (or ban) that was allowed: app.ts stores the ban and writes the audit log. */
export type Boot = { room: number; imvuId: number; by: number; byUserId: string; ban: boolean; minutes: number };
/** Hosted rooms' chat IDs and instance IDs start here (docs/hosted-rooms.md). */
export const hostedBase = 2_100_000_000;
/** The highest room number: chat IDs must stay XML-RPC integers. */
export const maxHostedN = 2_147_483_647 - hostedBase;
type Participant = { imvu_id: number; seat: number; name: string };
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is).
// chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told.
// hosted: the hosted rooms it's in. tokens, refilled: its allowance of room messages. watching: the hosted rooms it
// watches the occupancy of (watchGen: which room.watch that was). invites, invitesRefilled: its allowance of invites.
type Session = {
userId: string;
imvuId: number;
requested: number;
adult: boolean;
since: Date;
outfit: Item[];
chats: Map<string, string>;
lastSent: Map<string, string>;
hosted: Set<number>;
tokens: number;
refilled: number;
watching: Set<number>;
watchGen: number;
invites: number;
invitesRefilled: number;
send: (message: object) => void;
close: () => void;
};
// A hosted room in use: who's in it (session ID -> IMVU account and seat) and its latest message number. Kept while
// the hub runs, so the numbers keep counting up when a room empties and fills again; dropped when it closes.
// admitted: IMVU accounts let into it (a private room) with an invite, until they leave or are booted.
type Hosted = { members: Map<string, { imvuId: number; seat: number; name: string }>; sequence: number; admitted: Set<number> };
// An invitation to a hosted room: single use, for 10 minutes from when it was made. Kept in memory only.
type Invite = { id: string; room: number; from: number; to: number; expires: number; answered: boolean; accepted: boolean };
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;
/** A hosted room (undefined: there's none by that number). */
hostedRoom?: (n: number) => Promise<HostedRoomInfo | undefined>;
/** Whether an IMVU account is banned from a hosted room now. */
hostedBanned?: (n: number, imvuId: number) => Promise<boolean>;
/** Someone was booted (and maybe banned): store the ban, log it. */
hostedBoot?: (boot: Boot) => Promise<void>;
/** A person joined a hosted room as that IMVU account (for their recent rooms). */
hostedVisit?: (userId: string, n: number, imvuId: number) => void;
/** The IMVU username of a linked account, from its verified link ("" if unknown). */
accountName?: (imvuId: number) => Promise<string>;
};
const maxChats = 16;
const chatPattern = /^[0-9]{1,20}$/;
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
const maxHostedPerSession = 5;
const maxRoomMessage = 64 * 1024; // bytes, as UTF-8
const sendBurst = 20;
const sendPerSecond = 10;
const maxWatched = 100;
const inviteLifetime = 10 * 60 * 1000;
const inviteBurst = 10;
const invitesPerMinute = 10;
const maxNote = 200;
const maxBanMinutes = 10 * 366 * 24 * 60;
const roomErrors = {
not_linked: "Sign in to T5DE with a linked IMVU account first.",
not_found: "No such room.",
closed: "This room is closed.",
adult: "This room is for adults.",
full: "This room is full.",
too_many: "You're in too many rooms.",
banned: "You're banned from this room.",
private: "This room is private.",
} as const;
// Reasons in room.boot_result and room.invite_result.
const actionErrors = {
not_linked: roomErrors.not_linked,
not_found: roomErrors.not_found,
closed: roomErrors.closed,
forbidden: "You can't do that in this room.",
not_in_room: "Join the room first.",
banned: "They're banned from this room.",
adult: "This room is for adults.",
rate_limited: "Too many invitations. Try again in a minute.",
} as const;
export class Realtime {
private sessions = new Map<string, Session>();
private hosted = new Map<number, Hosted>();
private watchers = new Map<number, Set<string>>();
private invites = new Map<string, Invite>();
private listeners = new Set<() => void>();
private readonly deps: Required<RealtimeDeps>;
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
const d = typeof deps === "function" ? { owner: deps } : deps;
this.deps = {
adult: async () => false,
room: () => {},
hostedRoom: async () => undefined,
hostedBanned: async () => false,
hostedBoot: async () => {},
hostedVisit: () => {},
accountName: async () => "",
...d,
};
}
/** 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(chats: Iterable<string> = []) {
for (const chat of new Set(chats)) this.sync(chat);
for (const fn of this.listeners) fn();
}
/** The IMVU accounts in use now, one entry each (the most recent session), oldest first. */
online(): Online[] {
const byId = new Map<number, Online>();
for (const s of this.sessions.values()) {
if (!s.imvuId) continue;
const prev = byId.get(s.imvuId);
if (prev && prev.since > s.since) continue;
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());
}
connections(): number {
return this.sessions.size;
}
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(),
hosted: new Set(),
tokens: sendBurst,
refilled: Date.now(),
watching: new Set(),
watchGen: 0,
invites: inviteBurst,
invitesRefilled: Date.now(),
send,
close,
});
}
close(id: string) {
const s = this.sessions.get(id);
if (s) {
this.leaveAllHosted(id, s, false);
this.unwatch(id, s);
}
this.sessions.delete(id);
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. */
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(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 [id, s] of this.sessions) {
if (s.userId === userId && s.imvuId === imvuId) {
this.leaveAllHosted(id, s, true);
s.imvuId = 0;
s.adult = false;
s.outfit = [];
chats.push(...s.chats.keys());
changed = true;
}
}
if (changed) this.changed(chats);
}
/** Sends a message to all of a person's connections. */
notify(userId: string, message: object) {
for (const s of this.sessions.values()) if (s.userId === userId) s.send(message);
}
/** An IMVU room's decorations changed: every session in one of its chats is told, so its proxy fetches them. */
decorated(instance: string) {
const message = { type: "decor", room: instance };
for (const s of this.sessions.values()) if ([...s.chats.values()].includes(instance)) s.send(message);
}
/** Closes a person's connections (banned, or signed out of their computers). */
disconnect(userId: string) {
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);
}
}
// ---- Hosted rooms ----
/** How many people are in a hosted room now (IMVU accounts, each once). */
occupancy(n: number): number {
return new Set([...(this.hosted.get(n)?.members.values() ?? [])].map((m) => m.imvuId)).size;
}
/** A hosted room's participants: each IMVU account once, with the seat it last took and its IMVU name. */
participants(n: number): Participant[] {
const seats = new Map<number, { seat: number; name: string }>();
for (const m of this.hosted.get(n)?.members.values() ?? []) seats.set(m.imvuId, { seat: m.seat, name: m.name });
return [...seats].map(([imvuId, m]) => ({ imvu_id: imvuId, seat: m.seat, name: m.name }));
}
private toRoom(n: number, message: object, except?: string) {
for (const id of this.hosted.get(n)?.members.keys() ?? []) if (id !== except) this.sessions.get(id)?.send(message);
}
private participantsChanged(n: number, except?: string) {
const participants = this.participants(n);
this.toRoom(n, { type: "room.participants", room: n, participants }, except);
const occupancy = { type: "room.occupancy", room: n, participants };
for (const w of this.watchers.get(n) ?? []) this.sessions.get(w)?.send(occupancy);
}
/** Takes a session out of a hosted room; tell: say so (room.closed, with the reason if any), when it didn't ask to
* leave. */
private leaveHosted(id: string, s: Session, n: number, tell: boolean, reason?: string) {
s.hosted.delete(n);
if (!this.hosted.get(n)?.members.delete(id)) return;
if (tell) s.send({ type: "room.closed", room: n, ...(reason ? { reason } : {}) });
this.participantsChanged(n);
}
private toAccount(imvuId: number, message: object) {
for (const s of this.sessions.values()) if (s.imvuId === imvuId) s.send(message);
}
private unwatch(id: string, s: Session) {
for (const n of s.watching) {
const w = this.watchers.get(n);
w?.delete(id);
if (w?.size === 0) this.watchers.delete(n);
}
s.watching.clear();
}
private leaveAllHosted(id: string, s: Session, tell: boolean) {
for (const n of [...s.hosted]) this.leaveHosted(id, s, n, tell);
}
/** A hosted room closed: everyone in it is told, and its state is dropped. */
hostedClosed(n: number) {
const watching = this.watchers.get(n);
if (watching) {
this.watchers.delete(n);
for (const w of watching) {
const s = this.sessions.get(w);
s?.watching.delete(n);
s?.send({ type: "room.occupancy", room: n, participants: [] });
}
}
for (const [id, inv] of this.invites) if (inv.room === n) this.invites.delete(id);
const room = this.hosted.get(n);
if (!room) return;
this.hosted.delete(n);
for (const id of room.members.keys()) {
const s = this.sessions.get(id);
s?.hosted.delete(n);
s?.send({ type: "room.closed", room: n });
}
}
/** A hosted room's settings changed: if it became adult, those who aren't leave. (A lower maxUsers only stops
* more from coming in.) */
hostedChanged(n: number, info: HostedRoomInfo) {
if (info.closed) return this.hostedClosed(n);
if (!info.adult) return;
for (const id of [...(this.hosted.get(n)?.members.keys() ?? [])]) {
const s = this.sessions.get(id);
if (s && !s.adult) this.leaveHosted(id, s, n, true);
}
}
/** Whether an account is the room's owner account, or one of its moderators. */
private static staff(info: HostedRoomInfo, imvuId: number) {
return { owner: imvuId > 0 && imvuId === info.ownerImvuId, moderator: imvuId > 0 && info.moderators.includes(imvuId) };
}
private async joinHosted(id: string, s: Session, n: number, invite: unknown): Promise<object> {
const refuse = (reason: keyof typeof roomErrors) => ({ type: "room.error", room: n, reason, message: roomErrors[reason] });
if (!s.imvuId) return refuse("not_linked");
const imvuId = s.imvuId;
const info = await this.deps.hostedRoom(n);
if (!info) return refuse("not_found");
if (info.closed) return refuse("closed");
const adult = await this.deps.adult(s.userId, imvuId);
const name = await this.deps.accountName(imvuId);
const banned = await this.deps.hostedBanned(n, imvuId);
// The session may have closed or changed accounts while waiting.
if (!this.sessions.has(id) || s.imvuId !== imvuId) return refuse("not_linked");
s.adult = adult;
if (info.adult && !s.adult) return refuse("adult");
if (banned) return refuse("banned");
let room = this.hosted.get(n);
if (!room?.members.has(id)) {
// A private room: its owner account, its moderators, accounts let in already, and an accepted invite's holder.
let admit: Invite | undefined;
const staff = Realtime.staff(info, imvuId);
if (info.privacy === "private" && !staff.owner && !staff.moderator && !room?.admitted.has(imvuId)) {
const inv = typeof invite === "string" ? this.invites.get(invite) : undefined;
if (!inv || inv.room !== n || inv.to !== imvuId || !inv.accepted || inv.expires <= Date.now()) return refuse("private");
admit = inv;
}
if (s.hosted.size >= maxHostedPerSession) return refuse("too_many");
const here = new Set([...(room?.members.values() ?? [])].map((m) => m.imvuId));
if (!here.has(imvuId) && here.size >= info.maxUsers) return refuse("full");
if (!room) this.hosted.set(n, (room = { members: new Map(), sequence: 0, admitted: new Set() }));
if (admit) {
this.invites.delete(admit.id); // single use
room.admitted.add(imvuId);
}
room.members.set(id, { imvuId, seat: 0, name });
s.hosted.add(n);
this.participantsChanged(n, id); // the newcomer's list comes in room.joined
if (!here.has(imvuId)) this.deps.hostedVisit(s.userId, n, imvuId);
}
return { type: "room.joined", room: n, chat: hostedBase + n, participants: this.participants(n), sequence: room.sequence };
}
/** room.boot: the owner account or a moderator sends an account out of the room, and maybe bans it. */
private async bootHosted(id: string, s: Session, n: number, m: { imvu_id?: unknown; ban?: unknown; minutes?: unknown }) {
const target = m.imvu_id;
const ban = m.ban === undefined ? false : m.ban;
const minutes = m.minutes === undefined ? 0 : m.minutes;
if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" };
if (typeof ban !== "boolean") return { type: "error", message: "bad ban" };
if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < 0 || minutes > maxBanMinutes)
return { type: "error", message: "bad minutes" };
const result = (reason?: keyof typeof actionErrors) => ({
type: "room.boot_result",
room: n,
imvu_id: target,
...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, ban }),
});
if (!s.imvuId) return result("not_linked");
const actor = s.imvuId;
const info = await this.deps.hostedRoom(n);
if (!info) return result("not_found");
if (info.closed) return result("closed");
if (!this.sessions.has(id) || s.imvuId !== actor) return result("not_linked");
const me = Realtime.staff(info, actor);
const them = Realtime.staff(info, target);
// Nobody boots the owner; moderators don't boot moderators; nobody boots themselves.
if ((!me.owner && !me.moderator) || them.owner || (them.moderator && !me.owner) || target === actor)
return result("forbidden");
await this.deps.hostedBoot({ room: n, imvuId: target, by: actor, byUserId: s.userId, ban, minutes });
const room = this.hosted.get(n);
room?.admitted.delete(target);
for (const [sid, member] of [...(room?.members ?? [])]) {
const other = this.sessions.get(sid);
if (member.imvuId === target && other) this.leaveHosted(sid, other, n, true, "booted");
}
return result();
}
/** Whether a session may send another invitation now (10, then 10 a minute). */
private allowInvite(s: Session): boolean {
const now = Date.now();
s.invites = Math.min(inviteBurst, s.invites + ((now - s.invitesRefilled) / 60_000) * invitesPerMinute);
s.invitesRefilled = now;
if (s.invites < 1) return false;
s.invites -= 1;
return true;
}
/** room.invite: someone in a public room, or the owner or a moderator of a private one, invites an account. */
private async inviteHosted(id: string, s: Session, n: number, target: unknown): Promise<object | undefined> {
if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" };
const result = (reason?: keyof typeof actionErrors, invite?: string) => ({
type: "room.invite_result",
room: n,
imvu_id: target,
...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, invite }),
});
if (!s.imvuId) return result("not_linked");
const from = s.imvuId;
if (target === from) return { type: "error", message: "bad imvu_id" };
const info = await this.deps.hostedRoom(n);
if (!info) return result("not_found");
if (info.closed) return result("closed");
const banned = await this.deps.hostedBanned(n, target);
if (!this.sessions.has(id) || s.imvuId !== from) return result("not_linked");
const me = Realtime.staff(info, from);
if (info.privacy === "private" ? !me.owner && !me.moderator : !this.hosted.get(n)?.members.has(id))
return result(info.privacy === "private" ? "forbidden" : "not_in_room");
if (banned) return result("banned");
const targets = [...this.sessions.values()].filter((t) => t.imvuId === target);
if (info.adult && targets.length > 0 && !targets.some((t) => t.adult)) return result("adult");
if (!this.allowInvite(s)) return result("rate_limited");
const now = Date.now();
for (const [k, inv] of this.invites) if (inv.expires <= now) this.invites.delete(k);
const inviteId = Buffer.from(crypto.getRandomValues(new Uint8Array(12))).toString("base64url");
s.send(result(undefined, inviteId));
if (targets.length === 0) {
s.send({ type: "room.invite_answer", invite: inviteId, to: target, accept: false, offline: true });
return;
}
this.invites.set(inviteId, { id: inviteId, room: n, from, to: target, expires: now + inviteLifetime, answered: false, accepted: false });
const message = { type: "room.invited", room: n, invite: inviteId, from, room_name: info.name, owner_name: info.ownerName };
for (const t of targets) if (!info.adult || t.adult) t.send(message);
}
/** room.invite_reply: the invited account answers (the first answer counts). */
private inviteReply(s: Session, m: { invite?: unknown; accept?: unknown; note?: unknown }): object | undefined {
const inv = typeof m.invite === "string" ? this.invites.get(m.invite) : undefined;
if (!inv || inv.expires <= Date.now() || !s.imvuId || s.imvuId !== inv.to) return { type: "error", message: "no such invite" };
if (typeof m.accept !== "boolean") return { type: "error", message: "bad accept" };
if (m.note !== undefined && typeof m.note !== "string") return { type: "error", message: "bad note" };
if (inv.answered) return { type: "error", message: "already answered" };
inv.answered = true;
inv.accepted = m.accept;
if (!m.accept) this.invites.delete(inv.id);
// eslint-disable-next-line no-control-regex
const note = (m.note ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxNote);
this.toAccount(inv.from, { type: "room.invite_answer", invite: inv.id, to: inv.to, accept: m.accept, ...(note ? { note } : {}) });
}
/** room.watch: replaces the rooms whose occupancy this session is sent. Rooms it may not see are left out. */
private async watch(id: string, s: Session, rooms: unknown): Promise<object | undefined> {
if (
!Array.isArray(rooms) ||
rooms.length > maxWatched ||
!rooms.every((n) => typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= maxHostedN)
)
return { type: "error", message: "bad rooms" };
const gen = ++s.watchGen;
this.unwatch(id, s);
const ns = [...new Set(rooms as number[])];
const infos = await Promise.all(ns.map((n) => this.deps.hostedRoom(n)));
if (!this.sessions.has(id) || s.watchGen !== gen) return; // closed, or a newer room.watch came
for (const [i, n] of ns.entries()) {
const info = infos[i];
if (!info || info.closed || (info.adult && !s.adult)) continue;
const me = Realtime.staff(info, s.imvuId);
if (info.privacy === "private" && !me.owner && !me.moderator) continue;
s.watching.add(n);
let w = this.watchers.get(n);
if (!w) this.watchers.set(n, (w = new Set()));
w.add(id);
s.send({ type: "room.occupancy", room: n, participants: this.participants(n) });
}
}
/** Whether a session may send another room message now (a bucket of tokens, refilled over time). */
private allowSend(s: Session): boolean {
const now = Date.now();
s.tokens = Math.min(sendBurst, s.tokens + ((now - s.refilled) / 1000) * sendPerSecond);
s.refilled = now;
if (s.tokens < 1) return false;
s.tokens -= 1;
return true;
}
private sendHosted(id: string, s: Session, n: number, mount: unknown, message: unknown): object | undefined {
const room = this.hosted.get(n);
const me = room?.members.get(id);
if (!room || !me) return { type: "error", message: "not in that room" };
if (mount !== "messages" && mount !== "control") return { type: "error", message: "bad mount" };
if (typeof message !== "string" || Buffer.byteLength(message) > maxRoomMessage)
return { type: "error", message: "bad message" };
if (mount === "messages") {
// The client flags a message whose userId isn't its sender as forged; the hub doesn't pass those on at all.
const parsed = safeJSON(message) as { userId?: unknown } | null | undefined;
if (!parsed || typeof parsed !== "object" || String(parsed.userId) !== String(me.imvuId)) return;
}
if (!this.allowSend(s)) return;
room.sequence++;
this.toRoom(n, { type: "room.message", room: n, from: me.imvuId, mount, message, sequence: room.sequence }, id);
}
/** 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;
chat?: unknown;
room?: unknown;
seat?: unknown;
mount?: unknown;
message?: unknown;
invite?: unknown;
ban?: unknown;
minutes?: unknown;
accept?: unknown;
note?: unknown;
rooms?: 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 = s.requested ? await this.deps.owner(imvuId) : undefined;
// Hosted rooms were joined as the session's account: another account (or none) leaves them.
if (owner !== s.userId || imvuId !== s.imvuId) this.leaveAllHosted(id, s, true);
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(s.chats.keys());
return { type: "error", message: "not_linked" };
}
s.imvuId = imvuId;
s.since = new Date();
s.outfit = [];
s.adult = await this.deps.adult(s.userId, imvuId);
this.changed(s.chats.keys());
return;
}
if (m.type === "outfit") {
const items = m.items ?? [];
if (!Array.isArray(items)) return { type: "error", message: "items must be a list" };
if (items.length > 64) return { type: "error", message: "too many items" };
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, 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;
}
if (m.type === "room.watch") return this.watch(id, s, m.rooms);
if (m.type === "room.invite_reply") return this.inviteReply(s, m);
if (
m.type === "room.join" ||
m.type === "room.leave" ||
m.type === "room.seat" ||
m.type === "room.send" ||
m.type === "room.boot" ||
m.type === "room.invite"
) {
const n = m.room;
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > maxHostedN) return { type: "error", message: "bad room" };
if (m.type === "room.join") return this.joinHosted(id, s, n, m.invite);
if (m.type === "room.boot") return this.bootHosted(id, s, n, m);
if (m.type === "room.invite") return this.inviteHosted(id, s, n, m.imvu_id);
if (m.type === "room.leave") {
this.leaveHosted(id, s, n, false);
// Leaving on purpose ends an invitee's admission, once none of the account's sessions is in the room.
const room = this.hosted.get(n);
if (room && ![...room.members.values()].some((x) => x.imvuId === s.imvuId)) room.admitted.delete(s.imvuId);
return;
}
if (m.type === "room.send") return this.sendHosted(id, s, n, m.mount, m.message);
const seat = m.seat;
if (typeof seat !== "number" || !Number.isInteger(seat) || seat < 0 || seat > 99) return { type: "error", message: "bad seat" };
const member = this.hosted.get(n)?.members.get(id);
if (!member) return { type: "error", message: "not in that room" };
if (member.seat !== seat) {
member.seat = seat;
this.participantsChanged(n);
}
return;
}
return { type: "error", message: "unknown message type" };
}
}
function safeJSON(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return undefined;
}
}