Rooms made on the website (hosted_room, migration 0005), the REST routes in docs/hosted-rooms.md, and the realtime room.* protocol: joining with the capacity, adult and linked-account rules, seats, numbered messages with forged senders dropped and a rate limit, closing. The Rooms page lists them, with a form to make one and the owner's edit and close. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0c67232425
commit
2a1fc94df6
18 files changed
+3138
-52
No files matched your search
+190
-4
@@ -1,6 +1,6 @@
|
||||
// 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).
|
||||
// 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):
|
||||
@@ -13,13 +13,40 @@
|
||||
// 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": "..."}
|
||||
//
|
||||
// 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"}],
|
||||
// "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)
|
||||
|
||||
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 };
|
||||
|
||||
/** 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 };
|
||||
|
||||
// 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.
|
||||
type Session = {
|
||||
userId: string;
|
||||
imvuId: number;
|
||||
@@ -29,10 +56,17 @@ type Session = {
|
||||
outfit: Item[];
|
||||
chats: Map<string, string>;
|
||||
lastSent: Map<string, string>;
|
||||
hosted: Set<number>;
|
||||
tokens: number;
|
||||
refilled: 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.
|
||||
type Hosted = { members: Map<string, { imvuId: number; seat: number }>; sequence: number };
|
||||
|
||||
export type RealtimeDeps = {
|
||||
/** The person an IMVU account is linked to. */
|
||||
owner: (imvuId: number) => Promise<string | undefined>;
|
||||
@@ -40,20 +74,37 @@ export type RealtimeDeps = {
|
||||
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>;
|
||||
};
|
||||
|
||||
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 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.",
|
||||
} as const;
|
||||
|
||||
export class Realtime {
|
||||
private sessions = new Map<string, Session>();
|
||||
private hosted = new Map<number, Hosted>();
|
||||
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: () => {}, ...d };
|
||||
this.deps = { adult: async () => false, room: () => {}, hostedRoom: async () => undefined, ...d };
|
||||
}
|
||||
|
||||
/** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
|
||||
@@ -94,6 +145,9 @@ export class Realtime {
|
||||
outfit: [],
|
||||
chats: new Map(),
|
||||
lastSent: new Map(),
|
||||
hosted: new Set(),
|
||||
tokens: sendBurst,
|
||||
refilled: Date.now(),
|
||||
send,
|
||||
close,
|
||||
});
|
||||
@@ -101,6 +155,7 @@ export class Realtime {
|
||||
|
||||
close(id: string) {
|
||||
const s = this.sessions.get(id);
|
||||
if (s) this.leaveAllHosted(id, s, false);
|
||||
this.sessions.delete(id);
|
||||
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
|
||||
}
|
||||
@@ -125,8 +180,9 @@ export class Realtime {
|
||||
unlinked(userId: string, imvuId: number) {
|
||||
const chats: string[] = [];
|
||||
let changed = false;
|
||||
for (const s of this.sessions.values()) {
|
||||
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 = [];
|
||||
@@ -171,6 +227,115 @@ export class Realtime {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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. */
|
||||
private participants(n: number): Participant[] {
|
||||
const seats = new Map<number, number>();
|
||||
for (const m of this.hosted.get(n)?.members.values() ?? []) seats.set(m.imvuId, m.seat);
|
||||
return [...seats].map(([imvuId, seat]) => ({ imvu_id: imvuId, seat }));
|
||||
}
|
||||
|
||||
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) {
|
||||
this.toRoom(n, { type: "room.participants", room: n, participants: this.participants(n) }, except);
|
||||
}
|
||||
|
||||
/** Takes a session out of a hosted room; tell: say so (room.closed), when it didn't ask to leave. */
|
||||
private leaveHosted(id: string, s: Session, n: number, tell: boolean) {
|
||||
s.hosted.delete(n);
|
||||
if (!this.hosted.get(n)?.members.delete(id)) return;
|
||||
if (tell) s.send({ type: "room.closed", room: n });
|
||||
this.participantsChanged(n);
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
private async joinHosted(id: string, s: Session, n: number): 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);
|
||||
// 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");
|
||||
let room = this.hosted.get(n);
|
||||
if (!room?.members.has(id)) {
|
||||
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 }));
|
||||
room.members.set(id, { imvuId, seat: 0 });
|
||||
s.hosted.add(n);
|
||||
this.participantsChanged(n, id); // the newcomer's list comes in room.joined
|
||||
}
|
||||
return { type: "room.joined", room: n, chat: hostedBase + n, participants: this.participants(n), sequence: room.sequence };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -181,12 +346,17 @@ export class Realtime {
|
||||
items?: unknown;
|
||||
chat?: unknown;
|
||||
room?: unknown;
|
||||
seat?: unknown;
|
||||
mount?: unknown;
|
||||
message?: 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;
|
||||
@@ -231,6 +401,22 @@ export class Realtime {
|
||||
this.changed([chat]);
|
||||
return;
|
||||
}
|
||||
if (m.type === "room.join" || m.type === "room.leave" || m.type === "room.seat" || m.type === "room.send") {
|
||||
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);
|
||||
if (m.type === "room.leave") return void this.leaveHosted(id, s, n, false);
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user