// 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; lastSent: Map; hosted: Set; tokens: number; refilled: number; watching: Set; 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; sequence: number; admitted: Set }; // 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; /** Whether a person's IMVU account counts as adult (the age rule). */ adult?: (userId: string, imvuId: number) => Promise; /** 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; /** Whether an IMVU account is banned from a hosted room now. */ hostedBanned?: (n: number, imvuId: number) => Promise; /** Someone was booted (and maybe banned): store the ban, log it. */ hostedBoot?: (boot: Boot) => Promise; /** 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; }; 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(); private hosted = new Map(); private watchers = new Map>(); private invites = new Map(); private listeners = new Set<() => void>(); private readonly deps: Required; 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 = []) { 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(); 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(); 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(); 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 { 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 { 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 { 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 { 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; } }