Club Security 1.0.0

This commit is contained in:
Claude committed 2026-09-27 02:16:28 +02:00
commit 8d6850c825
13 files changed
+1256

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
/**
* Gathers everything the report needs about one joiner, within a deadline.
*
* Each fact comes from a different VRCNext path, so they are collected in parallel and each one
* degrades to "unknown" on its own timeout rather than holding up the report:
*
* - Age verification: VRCNext fetches the joiner's profile itself and re-pushes
* `vrcCurrentInstance` once it has, so this waits for that push instead of making a request.
* - Group membership: `vrcGetGroupsForNetwork` lists the groups the user shows publicly. A member
* who hides that membership looks like a non-member; the report says "visible" for that reason.
* - Avatar: `vrcGetInstanceAvatars` resolves the avatar id from the profile image through
* VRCNext's avatar databases, then `vrcGetAvatarDetail` supplies the performance ranks. That
* action also feeds VRCNext's avatar modal, so it is skipped while that modal is open.
*/
import type { EventBus, PluginContext } from '@vrcnext/plugin-api';
import type { Settings } from './settings.js';
import {
profileLoaded,
toAvatarPerformance,
toCurrentInstance,
toInstanceAvatar,
toUserGroups,
type CurrentInstance,
type InstanceUser,
} from './vrcnext-data.js';
export interface Joiner {
readonly name: string;
/** Empty for accounts VRChat still logs with a legacy id. */
readonly userId: string;
}
export interface AvatarFacts {
readonly name: string;
readonly pc: string;
readonly quest: string;
}
export interface Facts {
readonly ageVerified: boolean | undefined;
readonly ageStatus: string;
readonly platform: string;
readonly avatar: AvatarFacts | undefined;
/** `undefined` when no group filter is configured. */
readonly inGroup: boolean | undefined;
}
type Ctx = PluginContext<Settings>;
/** Resolves with the first event `pick` accepts, or `undefined` on timeout or abort. */
function waitFor<T>(
events: EventBus,
type: string,
pick: (payload: unknown) => T | undefined,
options: { readonly ms: number; readonly signal: AbortSignal },
): Promise<T | undefined> {
return new Promise((resolve) => {
let done = false;
const finish = (value: T | undefined): void => {
if (done) return;
done = true;
clearTimeout(timer);
unsubscribe();
options.signal.removeEventListener('abort', onAbort);
resolve(value);
};
const onAbort = (): void => { finish(undefined); };
const timer = setTimeout(() => { finish(undefined); }, options.ms);
const unsubscribe = events.on(type, (payload) => {
const value = pick(payload);
if (value !== undefined) finish(value);
});
options.signal.addEventListener('abort', onAbort, { once: true });
});
}
function findUser(instance: CurrentInstance, joiner: Joiner): InstanceUser | undefined {
return instance.users.find((u) =>
joiner.userId !== '' ? u.id === joiner.userId : u.displayName === joiner.name,
);
}
function avatarModalOpen(): boolean {
const modal = document.getElementById('modalAvatarDetail');
return modal !== null && modal.offsetParent !== null;
}
export class FactCollector {
readonly #ctx: Ctx;
#latest: CurrentInstance | undefined;
/** Performance ranks by avatar id, so a popular avatar is only fetched once. */
readonly #perfCache = new Map<string, AvatarFacts>();
constructor(ctx: Ctx) {
this.#ctx = ctx;
ctx.events.on('vrcCurrentInstance', (payload) => {
const instance = toCurrentInstance(payload);
if (instance !== undefined) this.#latest = instance;
});
ctx.events.on('vrcAvatarDetail', (payload) => {
const perf = toAvatarPerformance(payload);
if (perf !== undefined) {
this.#perfCache.set(perf.avatarId, { name: perf.name, pc: perf.pc, quest: perf.quest });
}
});
}
get instance(): CurrentInstance | undefined {
return this.#latest;
}
/** Runs every lookup in parallel and returns whatever arrived before the deadline. */
async collect(joiner: Joiner, groupId: string, deadlineMs: number): Promise<Facts> {
const signal = this.#ctx.signal;
const [profile, inGroup, avatar] = await Promise.all([
this.#profile(joiner, deadlineMs, signal),
groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, groupId, deadlineMs, signal),
this.#avatar(joiner, deadlineMs, signal),
]);
return {
ageVerified: profile === undefined ? undefined : profile.ageVerified,
ageStatus: profile?.ageVerificationStatus ?? '',
platform: profile?.platform ?? '',
avatar,
inGroup,
};
}
async #profile(joiner: Joiner, ms: number, signal: AbortSignal): Promise<InstanceUser | undefined> {
const current = this.#latest === undefined ? undefined : findUser(this.#latest, joiner);
if (current !== undefined && profileLoaded(current)) return current;
const loaded = await waitFor(
this.#ctx.events,
'vrcCurrentInstance',
(payload) => {
const instance = toCurrentInstance(payload);
const user = instance === undefined ? undefined : findUser(instance, joiner);
return user !== undefined && profileLoaded(user) ? user : undefined;
},
{ ms, signal },
);
// A profile that never loaded still tells us the platform VRChat logged.
return loaded ?? current;
}
async #membership(joiner: Joiner, groupId: string, ms: number, signal: AbortSignal): Promise<boolean | undefined> {
if (joiner.userId === '') return undefined;
const wanted = groupId.toLowerCase();
const pending = waitFor(
this.#ctx.events,
'vrcGroupsForNetwork',
(payload) => {
const groups = toUserGroups(payload);
return groups?.userId === joiner.userId ? groups.groupIds : undefined;
},
{ ms, signal },
);
this.#ctx.bridge.send('vrcGetGroupsForNetwork', { userId: joiner.userId });
const groupIds = await pending;
return groupIds === undefined ? undefined : groupIds.some((id) => id.toLowerCase() === wanted);
}
async #avatar(joiner: Joiner, ms: number, signal: AbortSignal): Promise<AvatarFacts | undefined> {
if (joiner.userId === '') return undefined;
const started = Date.now();
const remaining = (): number => Math.max(1_000, ms - (Date.now() - started));
let avatarId = this.#latest === undefined ? '' : (findUser(this.#latest, joiner)?.avatarId ?? '');
let avatarName = '';
if (avatarId === '') {
const pending = waitFor(
this.#ctx.events,
'vrcInstanceAvatarFound',
(payload) => {
const found = toInstanceAvatar(payload);
return found?.userId === joiner.userId ? found : undefined;
},
{ ms: remaining(), signal },
);
this.#ctx.bridge.send('vrcGetInstanceAvatars', { userIds: [joiner.userId] });
const found = await pending;
if (found === undefined) return undefined;
avatarId = found.avatarId;
avatarName = found.avatarName;
}
if (avatarId === '') return avatarName === '' ? undefined : { name: avatarName, pc: '', quest: '' };
const cached = this.#perfCache.get(avatarId);
if (cached !== undefined) return cached;
if (avatarModalOpen()) {
this.#ctx.logger.debug('Avatar modal is open; not fetching performance ranks behind it.');
return { name: avatarName, pc: '', quest: '' };
}
const pending = waitFor(
this.#ctx.events,
'vrcAvatarDetail',
(payload) => {
const perf = toAvatarPerformance(payload);
return perf?.avatarId === avatarId ? perf : undefined;
},
{ ms: remaining(), signal },
);
this.#ctx.bridge.send('vrcGetAvatarDetail', { avatarId });
const perf = await pending;
return perf === undefined
? { name: avatarName, pc: '', quest: '' }
: { name: perf.name === '' ? avatarName : perf.name, pc: perf.pc, quest: perf.quest };
}
}