/** * Club Security. * * Watches VRChat's game log for players joining your instance. When the instance passes the * configured filters (type, group, world), it gathers what VRCNext knows about the joiner and * sends one report per join to the enabled channels. * * Compare with `plugin.json`: every category used here is declared there, and the exact action * and event names are listed so they are granted when the plugin is enabled. */ import { definePlugin, type PluginContext, type PluginId } from '@vrcnext/plugin-api'; import { FactCollector, type Joiner } from './src/collector.js'; import { filterFrom, instanceMatches } from './src/filters.js'; import { notifyAll, type Report } from './src/notify.js'; import { ReportPanel } from './src/panel.js'; import { migrateTemplate, settings } from './src/settings.js'; import { toSelf, type Self } from './src/vrcnext-data.js'; type Ctx = PluginContext; /** VRCNext's parsed game-log kinds for `[Behaviour] OnPlayerJoined` and `Joining wrld_…`. */ const JOIN_EVENT = 'gl_player_join'; const WORLD_JOIN_EVENT = 'gl_world_join'; class ClubSecurity { readonly #ctx: Ctx; readonly #collector: FactCollector; readonly #panel: ReportPanel; /** * The signed-in account, from the `vrcUser` event VRCNext pushes after login and whenever the * profile is re-rendered. Empty until the first push: before that, only the settle window * keeps the local player's own `OnPlayerJoined` line from being reported. */ #self: Self = { id: '', name: '' }; /** Joiners currently being looked up, so a duplicate log line does not produce two reports. */ readonly #inFlight = new Set(); /** * Until when joins count as "already here". VRChat logs an `OnPlayerJoined` for every player * present when the local player arrives, in one burst right after the local player's own line. */ #settledAt = 0; constructor(ctx: Ctx) { this.#ctx = ctx; this.#collector = new FactCollector(ctx); this.#panel = new ReportPanel(ctx, { currentInstance: () => this.#collector.instance, sendTest: () => this.#sendTest(), }); } start(): void { this.#ctx.events.on('vrcUser', (payload) => { const self = toSelf(payload); if (self !== undefined) this.#self = self; }); this.#ctx.events.on('vrcCurrentInstance', () => { this.#panel.refresh(); }); this.#ctx.gameLog.onType(WORLD_JOIN_EVENT, () => { this.#startSettling(); }); this.#ctx.gameLog.onType(JOIN_EVENT, (entry) => { void this.#onJoin({ name: entry.message, userId: entry.detail }); }); this.#panel.install(); // Ask for the instance we are already in, so the first join after enabling is not missed. this.#ctx.bridge.send('vrcGetCurrentInstance'); this.#ctx.logger.info(`Club Security v${this.#ctx.version} watching for joins.`); } #startSettling(): void { this.#settledAt = Date.now() + this.#ctx.settings.get('settleSecs') * 1000; } async #onJoin(joiner: Joiner): Promise { const values = this.#ctx.settings.values; if (!values.enabled || joiner.name === '') return; if (this.#isSelf(joiner)) { this.#startSettling(); return; } const instance = this.#collector.instance; if (instance === undefined) { this.#ctx.logger.debug(`${joiner.name} joined but the current instance is not known yet.`); return; } const filter = filterFrom(values); if (!instanceMatches(filter, instance)) return; const key = joiner.userId === '' ? `name:${joiner.name}` : joiner.userId; if (this.#inFlight.has(key)) return; this.#inFlight.add(key); try { if (Date.now() < this.#settledAt) { // VRCNext records the meeting itself, so nothing is lost by not reporting it. this.#ctx.logger.debug(`${joiner.name} was already here when you joined; not reported.`); return; } const facts = await this.#collector.collect(joiner, filter, values.collectTimeoutSecs * 1000); const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId }; this.#panel.push(report); this.#ctx.logger.info(`Reported ${joiner.name}: 18+ ${String(facts.ageVerified ?? '?')}, PC ${facts.avatar?.pc ?? '?'}, Quest ${facts.avatar?.quest ?? '?'}.`); await notifyAll(this.#ctx, report); } catch (error) { this.#ctx.logger.error(`Report for ${joiner.name} failed: ${String(error)}`); } finally { this.#inFlight.delete(key); } } #isSelf(joiner: Joiner): boolean { const self = this.#self; if (joiner.userId !== '' && self.id !== '') return joiner.userId === self.id; return self.name !== '' && joiner.name === self.name; } /** A report built from whatever the current instance knows about you, to check the channels. */ async #sendTest(): Promise { const instance = this.#collector.instance; if (instance === undefined) { this.#ctx.notifications.toast({ message: 'Join an instance first; the test uses it.', ok: false }); return; } const self = this.#self; const me = instance.users.find((u) => u.id === self.id) ?? instance.users[0]; const name = me?.displayName ?? self.name; const joiner: Joiner = { name: name === '' ? 'Test player' : name, userId: me?.id ?? '' }; const filter = filterFrom(this.#ctx.settings.values); const facts = await this.#collector.collect(joiner, filter, 10_000); const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId }; await notifyAll(this.#ctx, report); this.#ctx.notifications.toast({ message: 'Test report sent to every enabled channel.' }); } } export default definePlugin({ id: 'club-security' as PluginId, settings, activate(ctx) { const template = ctx.settings.get('template'); const migrated = migrateTemplate(template); if (migrated !== template) void ctx.settings.set('template', migrated); new ClubSecurity(ctx).start(); }, deactivate() { // Every listener and panel went through `ctx`, so the host tears them down. }, });