From 8d6850c82532c0d17b0e2b2894d8f22bb0e140fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 02:16:28 +0200 Subject: [PATCH] Club Security 1.0.0 --- LICENSE | 24 +++++ README.md | 61 +++++++++++++ main.ts | 158 +++++++++++++++++++++++++++++++++ package.json | 11 +++ plugin.json | 33 +++++++ src/collector.ts | 211 ++++++++++++++++++++++++++++++++++++++++++++ src/filters.ts | 53 +++++++++++ src/memory.ts | 91 +++++++++++++++++++ src/notify.ts | 190 +++++++++++++++++++++++++++++++++++++++ src/panel.ts | 141 +++++++++++++++++++++++++++++ src/settings.ts | 109 +++++++++++++++++++++++ src/vrcnext-data.ts | 164 ++++++++++++++++++++++++++++++++++ tsconfig.json | 10 +++ 13 files changed, 1256 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 main.ts create mode 100644 package.json create mode 100644 plugin.json create mode 100644 src/collector.ts create mode 100644 src/filters.ts create mode 100644 src/memory.ts create mode 100644 src/notify.ts create mode 100644 src/panel.ts create mode 100644 src/settings.ts create mode 100644 src/vrcnext-data.ts create mode 100644 tsconfig.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..efb9808 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbf44ca --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Club Security + +Reports who joins your instance. For every join that passes the filters, one report: + +``` +Player "Tupper" joined +18+ Verified: Yes (18+) +Avatar PC Performance Rank: Good +Avatar Quest Performance Rank: Poor +In Group: Yes +Rejoin?: Yes (2× before, last 26/09/2026, 21:40:12) +``` + +`In Group` only appears when a group filter is set. + +## Filters + +All optional, all in the plugin's settings card. Empty means "any". + +| Setting | Meaning | +| :--- | :--- | +| Instance types | Comma-separated: `public`, `friends+`, `friends`, `hidden`, `private`, `invite_plus`, `group-public`, `group-plus`, `group-members`, or `group` for every group instance. | +| Group | A `grp_…` id. Only instances of that group count, and the report says whether the joiner is a member. | +| Worlds | Comma-separated `wrld_…` ids. | + +## Channels + +| Channel | How | +| :--- | :--- | +| In-app toast | Always available. One line. | +| Desktop | A VRCNext Bridge desktop target on any platform; VRCNext's tray toast on Windows when the bridge delivers nothing. | +| VR | A VRCNext Bridge VR target (WayVR and similar) on any platform; SteamVR wrist overlay on Windows when the bridge delivers nothing. | +| Discord | A `discord.com` webhook URL, posted as an embed through `ctx.http.fetch` (`discord.com` is the plugin's only declared host). | + +## Where each fact comes from + +| Fact | Source | Caveat | +| :--- | :--- | :--- | +| 18+ verified | VRCNext fetches the joiner's profile itself on join and re-pushes the instance; the plugin waits for that push. | Legacy accounts without a `usr_` id cannot be looked up. | +| Avatar ranks | `vrcGetInstanceAvatars` resolves the avatar from the profile image through VRCNext's avatar databases, then `vrcGetAvatarDetail` gives the ranks. | Only works when one of the databases knows the avatar. The detail action also feeds VRCNext's avatar modal, so it is skipped while that modal is open. | +| In group | `vrcGetGroupsForNetwork`, the joiner's publicly visible groups. | A member who hides the membership shows as "No". | +| Rejoin | The plugin's own memory of joins to matching instances, kept in a hidden setting, capped at 2000 players. | Cleared with the button on the plugin's tab. | + +Everything waits at most the configured number of seconds, then reports what it has, with +`Unknown` for the rest. + +## Your own joins + +The signed-in account comes from the `vrcUser` event VRCNext pushes after login; the plugin +reads no page globals. VRChat logs an `OnPlayerJoined` line for the local player too, and right after it one line for +every player already in the instance. The plugin ignores your own line and treats joins inside +the settle window after it (or after a world change) as "already here": they are remembered for +the rejoin check but not reported. + +## Layout and permissions + +Flat, like every plugin: `plugin.json`, `main.ts`, `src/`. The manifest declares `host:events` +(the five `vrc…` events above and `vrcUser`), `host:actions` (the four `vrcGet…` lookups), +`gamelog` (joins and world changes), `notifications` (toast, confirm, Windows tray toast), +`native` (bridge targets) and `network` with `discord.com` as its only host. Nothing is optional: +every channel is a plain setting, and a category that is off in the settings is simply never called. diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..ee86d74 --- /dev/null +++ b/main.ts @@ -0,0 +1,158 @@ +/** + * 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 { JoinMemory } from './src/memory.js'; +import { notifyAll, type Report } from './src/notify.js'; +import { ReportPanel } from './src/panel.js'; +import { 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 #memory: JoinMemory; + 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.#memory = new JoinMemory(ctx.settings); + this.#panel = new ReportPanel(ctx, { + memory: this.#memory, + 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 { + const previous = await this.#memory.record(key, joiner.name, instance.location); + if (Date.now() < this.#settledAt) { + this.#ctx.logger.debug(`${joiner.name} was already here when you joined; remembered, not reported.`); + this.#panel.refresh(); + return; + } + const facts = await this.#collector.collect(joiner, filter.groupId, values.collectTimeoutSecs * 1000); + const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId, previous }; + 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.groupId, 10_000); + const report: Report = { + at: Date.now(), + joiner, + instance, + facts, + groupFilter: filter.groupId, + previous: this.#memory.previous(joiner.userId === '' ? `name:${joiner.name}` : joiner.userId), + }; + 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) { + new ClubSecurity(ctx).start(); + }, + + deactivate() { + // Every listener and panel went through `ctx`, so the host tears them down. + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..f07ee36 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "@vrcnext-examples/club-security", + "version": "1.0.0", + "private": true, + "description": "Reports who joins your instance: age verification, avatar performance, group membership and rejoins.", + "license": "Unlicense", + "type": "module", + "dependencies": { + "@vrcnext/plugin-api": "0.2.0" + } +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..12aaa04 --- /dev/null +++ b/plugin.json @@ -0,0 +1,33 @@ +{ + "id": "club-security", + "name": "Club Security", + "version": "1.0.0", + "apiVersion": "^0.2.0", + "description": "When someone joins your instance, gathers age verification, avatar performance, group membership and rejoin history, then notifies you in-app, on the desktop, in VR or on Discord.", + "author": "vrcnext-plugins", + "homepage": "https://github.com/vrcnext-plugins/vrcnext-plugin-system/tree/main/examples/club-security", + "tags": ["notifications", "privacy", "activity"], + "permissions": [ + "host:events", + "host:actions", + "gamelog", + "notifications", + "native", + "network" + ], + "optionalPermissions": [], + "actions": [ + "vrcGetCurrentInstance", + "vrcGetGroupsForNetwork", + "vrcGetInstanceAvatars", + "vrcGetAvatarDetail" + ], + "events": [ + "vrcUser", + "vrcCurrentInstance", + "vrcGroupsForNetwork", + "vrcInstanceAvatarFound", + "vrcAvatarDetail" + ], + "hosts": ["discord.com"] +} diff --git a/src/collector.ts b/src/collector.ts new file mode 100644 index 0000000..6d3ea1c --- /dev/null +++ b/src/collector.ts @@ -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; + +/** Resolves with the first event `pick` accepts, or `undefined` on timeout or abort. */ +function waitFor( + events: EventBus, + type: string, + pick: (payload: unknown) => T | undefined, + options: { readonly ms: number; readonly signal: AbortSignal }, +): Promise { + 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(); + + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/filters.ts b/src/filters.ts new file mode 100644 index 0000000..2817093 --- /dev/null +++ b/src/filters.ts @@ -0,0 +1,53 @@ +/** + * Decides whether the current instance is one the user wants reports for. + */ + +import type { SettingsValues } from '@vrcnext/plugin-api'; + +import type { CurrentInstance } from './vrcnext-data.js'; +import type { Settings } from './settings.js'; + +export interface InstanceFilter { + readonly instanceTypes: readonly string[]; + readonly groupId: string; + readonly worldIds: readonly string[]; +} + +export function splitList(value: string): readonly string[] { + return value + .split(/[,\s]+/) + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length > 0); +} + +export function filterFrom(values: SettingsValues): InstanceFilter { + return { + instanceTypes: splitList(values.instanceTypes), + groupId: values.groupId.trim().toLowerCase(), + worldIds: splitList(values.worldIds), + }; +} + +function typeMatches(wanted: readonly string[], actual: string): boolean { + if (wanted.length === 0) return true; + const type = actual.toLowerCase(); + return wanted.some((w) => w === type || (w === 'group' && type.startsWith('group'))); +} + +export function instanceMatches(filter: InstanceFilter, instance: CurrentInstance): boolean { + if (!typeMatches(filter.instanceTypes, instance.instanceType)) return false; + const groupId = filter.groupId.toLowerCase(); + if (groupId !== '' && instance.groupId.toLowerCase() !== groupId) return false; + const worldIds = filter.worldIds.map((w) => w.toLowerCase()); + if (worldIds.length > 0 && !worldIds.includes(instance.worldId.toLowerCase())) return false; + return true; +} + +/** One line for the status card: what the filter currently restricts to. */ +export function describeFilter(filter: InstanceFilter): string { + const parts: string[] = []; + if (filter.instanceTypes.length > 0) parts.push(`types: ${filter.instanceTypes.join(', ')}`); + if (filter.groupId !== '') parts.push(`group: ${filter.groupId}`); + if (filter.worldIds.length > 0) parts.push(`worlds: ${String(filter.worldIds.length)}`); + return parts.length === 0 ? 'every instance' : parts.join(' · '); +} diff --git a/src/memory.ts b/src/memory.ts new file mode 100644 index 0000000..1869e36 --- /dev/null +++ b/src/memory.ts @@ -0,0 +1,91 @@ +/** + * Who has joined a matching instance before. + * + * Persisted in a hidden string setting as JSON, because the plugin API offers settings as its + * only durable store. Capped so it cannot grow without bound; the oldest entries fall off. + */ + +import type { SettingsStore } from '@vrcnext/plugin-api'; + +import type { Settings } from './settings.js'; + +export interface SeenEntry { + readonly name: string; + readonly joins: number; + readonly firstSeen: number; + readonly lastSeen: number; + readonly lastLocation: string; +} + +const MAX_ENTRIES = 2000; + +type SeenMap = Record; + +function parse(raw: string): SeenMap { + try { + const value: unknown = JSON.parse(raw); + if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; + const out: SeenMap = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (typeof entry !== 'object' || entry === null) continue; + const e = entry as Record; + out[key] = { + name: typeof e['name'] === 'string' ? e['name'] : '', + joins: typeof e['joins'] === 'number' ? e['joins'] : 1, + firstSeen: typeof e['firstSeen'] === 'number' ? e['firstSeen'] : 0, + lastSeen: typeof e['lastSeen'] === 'number' ? e['lastSeen'] : 0, + lastLocation: typeof e['lastLocation'] === 'string' ? e['lastLocation'] : '', + }; + } + return out; + } catch { + return {}; + } +} + +function trimmed(map: SeenMap): SeenMap { + const entries = Object.entries(map); + if (entries.length <= MAX_ENTRIES) return map; + entries.sort((a, b) => b[1].lastSeen - a[1].lastSeen); + return Object.fromEntries(entries.slice(0, MAX_ENTRIES)); +} + +export class JoinMemory { + readonly #store: SettingsStore; + #map: SeenMap; + + constructor(store: SettingsStore) { + this.#store = store; + this.#map = parse(store.get('memory')); + } + + get size(): number { + return Object.keys(this.#map).length; + } + + /** The entry as it was before this join, or `undefined` for a first-timer. */ + previous(key: string): SeenEntry | undefined { + return this.#map[key]; + } + + /** Records a join and persists. Returns the entry as it was before. */ + async record(key: string, name: string, location: string): Promise { + const before = this.#map[key]; + const now = Date.now(); + this.#map[key] = { + name, + joins: (before?.joins ?? 0) + 1, + firstSeen: before?.firstSeen ?? now, + lastSeen: now, + lastLocation: location, + }; + this.#map = trimmed(this.#map); + await this.#store.set('memory', JSON.stringify(this.#map)); + return before; + } + + async clear(): Promise { + this.#map = {}; + await this.#store.set('memory', '{}'); + } +} diff --git a/src/notify.ts b/src/notify.ts new file mode 100644 index 0000000..295e972 --- /dev/null +++ b/src/notify.ts @@ -0,0 +1,190 @@ +/** + * Formats a report and fans it out to every enabled channel. + * + * Channels: + * - In-app toast: always available. + * - Desktop and VR: on any platform through the VRCNext Bridge, whose targets are addressed by + * name; on Windows when the bridge delivers nothing, VRCNext's own tray toast plus SteamVR wrist overlay, + * which is a single action that reaches both. + * - Discord: a webhook POST through `ctx.http.fetch`. Only `discord.com` is in the manifest's + * `hosts`, so that is the only webhook host accepted here. + */ + +import type { PluginContext, SettingsValues } from '@vrcnext/plugin-api'; + +import type { Facts, Joiner } from './collector.js'; +import type { SeenEntry } from './memory.js'; +import type { Settings } from './settings.js'; +import type { CurrentInstance } from './vrcnext-data.js'; + +export interface Report { + readonly at: number; + readonly joiner: Joiner; + readonly instance: CurrentInstance; + readonly facts: Facts; + /** Whether a group filter was active, so the "In Group" line is only shown when it means something. */ + readonly groupFilter: string; + readonly previous: SeenEntry | undefined; +} + +type Ctx = PluginContext; + +/** Matches the host declared in plugin.json; `ptb.`/`canary.` would need their own entries. */ +const DISCORD_WEBHOOK = /^https:\/\/discord\.com\/api\/webhooks\/\d+\/[\w-]+$/; + +function yesNo(value: boolean | undefined, unknown = 'Unknown'): string { + if (value === undefined) return unknown; + return value ? 'Yes' : 'No'; +} + +function rank(value: string): string { + return value === '' ? 'Unknown' : value; +} + +function rejoinText(previous: SeenEntry | undefined): string { + if (previous === undefined) return 'No (first time)'; + const when = new Date(previous.lastSeen).toLocaleString(); + return `Yes (${String(previous.joins)}× before, last ${when})`; +} + +/** The report as the spec lays it out, one fact per line. */ +export function reportLines(report: Report): readonly string[] { + const { facts } = report; + const age = facts.ageVerified === undefined + ? 'Unknown' + : `${yesNo(facts.ageVerified)}${facts.ageStatus === '' ? '' : ` (${facts.ageStatus})`}`; + const lines = [ + `Player "${report.joiner.name}" joined`, + `18+ Verified: ${age}`, + `Avatar PC Performance Rank: ${rank(facts.avatar?.pc ?? '')}`, + `Avatar Quest Performance Rank: ${rank(facts.avatar?.quest ?? '')}`, + ]; + if (report.groupFilter !== '') lines.push(`In Group: ${yesNo(facts.inGroup, 'Unknown (only visible memberships count)')}`); + lines.push(`Rejoin?: ${rejoinText(report.previous)}`); + return lines; +} + +/** Everything a single-line surface can hold. */ +export function reportSummary(report: Report): string { + const { facts } = report; + const bits = [ + `18+: ${yesNo(facts.ageVerified, '?')}`, + `PC ${rank(facts.avatar?.pc ?? '').replace('Unknown', '?')}`, + `Quest ${rank(facts.avatar?.quest ?? '').replace('Unknown', '?')}`, + ]; + if (report.groupFilter !== '') bits.push(`group: ${yesNo(facts.inGroup, '?')}`); + bits.push(report.previous === undefined ? 'new' : 'rejoin'); + return `${report.joiner.name} joined · ${bits.join(' · ')}`; +} + +function accentFor(report: Report): 'ok' | 'warn' | 'info' { + const { facts } = report; + if (facts.ageVerified === false || facts.avatar?.pc === 'VeryPoor') return 'warn'; + if (facts.ageVerified === true) return 'ok'; + return 'info'; +} + +/** Bridge targets: the VR overlay is the one whose name says so; the rest are desktop. */ +function pickSinks(targets: readonly { readonly name: string }[], wantDesktop: boolean, wantVr: boolean): readonly string[] { + return targets + .filter((t) => { + const vr = /wayvr|openvr|steamvr|overlay|xso/i.test(t.name); + return vr ? wantVr : wantDesktop; + }) + .map((t) => t.name); +} + +/** + * Through the bridge: `true` when at least one target accepted. `ok: false` with only the + * `bridge` pseudo-sink failing means the bridge is not reachable, which is a normal state, not + * an error; a target that refused is logged by name. + */ +async function sendNative(ctx: Ctx, wants: { readonly desktop: boolean; readonly vr: boolean }, report: Report): Promise { + let targets: readonly { readonly name: string }[]; + try { + targets = await ctx.native.targets(); + } catch (error) { + ctx.logger.debug(`Bridge targets not available: ${String(error)}`); + return false; + } + const sinks = pickSinks(targets, wants.desktop, wants.vr); + if (sinks.length === 0) { + ctx.logger.debug('The bridge has no target for the enabled channels.'); + return false; + } + const lines = reportLines(report).slice(1); + const result = await ctx.native.notify({ + title: `${report.joiner.name} joined`, + content: lines.join('\n'), + timeoutSecs: 8, + icon: 'security-high', + sinks, + urgency: accentFor(report) === 'warn' ? 'critical' : 'normal', + }); + for (const failure of result.failed) { + if (failure.sink !== 'bridge') ctx.logger.warn(`Bridge target ${failure.sink} failed: ${failure.error}`); + } + if (!result.ok) ctx.logger.debug('Bridge not reachable or every target refused; falling back.'); + return result.ok; +} + +async function sendDesktopAndVr(ctx: Ctx, values: SettingsValues, report: Report): Promise { + const wants = { desktop: values.notifyDesktop, vr: values.notifyVr }; + if (!wants.desktop && !wants.vr) return; + if (await sendNative(ctx, wants, report)) return; + + // Windows without the bridge's targets: VRCNext's own tray toast and wrist overlay in one call. + if (ctx.notifications.desktopAvailable) { + ctx.notifications.desktop({ + title: `${report.joiner.name} joined`, + subtitle: reportLines(report).slice(1).join(' · '), + accent: accentFor(report), + ...(report.joiner.userId === '' ? {} : { friendId: report.joiner.userId }), + }); + return; + } + ctx.logger.info('No desktop or VR channel is reachable: the bridge delivered nothing and this platform has no tray toast.'); +} + +async function sendDiscord(ctx: Ctx, values: SettingsValues, report: Report): Promise { + if (!values.notifyDiscord) return; + const url = values.discordWebhookUrl.trim(); + if (!DISCORD_WEBHOOK.test(url)) { + ctx.logger.warn('Discord webhook is enabled but the URL is not a discord.com webhook URL.'); + return; + } + const lines = reportLines(report); + const colour = { ok: 0x3ba55d, warn: 0xed4245, info: 0x5865f2 }[accentFor(report)]; + const body = { + username: 'Club Security', + embeds: [{ + title: lines[0], + description: lines.slice(1).join('\n'), + color: colour, + footer: { text: `${report.instance.worldName} · ${report.instance.instanceType}` }, + timestamp: new Date(report.at).toISOString(), + }], + allowed_mentions: { parse: [] }, + }; + const response = await ctx.http.fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + credentials: 'omit', + referrerPolicy: 'no-referrer', + }); + if (!response.ok) ctx.logger.warn(`Discord webhook answered ${String(response.status)}.`); +} + +/** Sends on every enabled channel. Never throws: a failed channel is logged, not fatal. */ +export async function notifyAll(ctx: Ctx, report: Report): Promise { + const values = ctx.settings.values; + if (values.notifyToast) ctx.notifications.toast({ message: reportSummary(report), ok: accentFor(report) !== 'warn' }); + const results = await Promise.allSettled([ + sendDesktopAndVr(ctx, values, report), + sendDiscord(ctx, values, report), + ]); + for (const result of results) { + if (result.status === 'rejected') ctx.logger.warn(`Notification channel failed: ${String(result.reason)}`); + } +} diff --git a/src/panel.ts b/src/panel.ts new file mode 100644 index 0000000..515f193 --- /dev/null +++ b/src/panel.ts @@ -0,0 +1,141 @@ +/** + * The plugin's sidebar tab: status, the last reports, and two maintenance buttons. + * Built from `ctx.ui.kit` so it is VRCNext's own markup and follows its theme. + */ + +import type { PluginContext } from '@vrcnext/plugin-api'; + +import { describeFilter, filterFrom, instanceMatches } from './filters.js'; +import type { JoinMemory } from './memory.js'; +import { reportLines, type Report } from './notify.js'; +import type { Settings } from './settings.js'; +import type { CurrentInstance } from './vrcnext-data.js'; + +type Ctx = PluginContext; + +const MAX_SHOWN = 25; + +export interface PanelDeps { + readonly memory: JoinMemory; + readonly currentInstance: () => CurrentInstance | undefined; + readonly sendTest: () => Promise; +} + +export class ReportPanel { + readonly #ctx: Ctx; + readonly #deps: PanelDeps; + readonly #reports: Report[] = []; + #status: HTMLElement | undefined; + #list: HTMLElement | undefined; + + constructor(ctx: Ctx, deps: PanelDeps) { + this.#ctx = ctx; + this.#deps = deps; + } + + install(): void { + this.#ctx.ui.addNavTab({ + label: 'Club Security', + icon: 'security', + render: (tab) => { this.#render(tab); }, + }); + // The host renders every schema setting as a row on this card before `render` runs. + this.#ctx.ui.addSettingsCard({ + title: 'Club Security', + icon: 'security', + render: (card) => { + card.appendChild(this.#ctx.ui.kit.description( + 'Reports go out for joins in instances that pass every filter above. Test them from the Club Security tab.', + )); + }, + }); + this.#ctx.settings.onChange(() => { this.refresh(); }); + } + + push(report: Report): void { + this.#reports.unshift(report); + if (this.#reports.length > MAX_SHOWN) this.#reports.length = MAX_SHOWN; + this.refresh(); + } + + refresh(): void { + const k = this.#ctx.ui.kit; + if (this.#status !== undefined) k.setChildren(this.#status, this.#statusRows()); + if (this.#list !== undefined) k.setChildren(this.#list, this.#reportRows()); + } + + #render(tab: HTMLElement): void { + const k = this.#ctx.ui.kit; + this.#status = k.card({ title: 'Status', icon: 'shield' }); + this.#list = k.card({ title: 'Recent reports', icon: 'history' }); + const actions = k.card({ + title: 'Actions', + icon: 'build', + children: [ + k.description('A test report uses the current instance and your own name, and goes to every enabled channel.'), + k.buttonRow( + k.button({ label: 'Send test notification', icon: 'send', onClick: () => { void this.#deps.sendTest(); } }), + k.button({ + label: 'Forget who joined before', + icon: 'delete_sweep', + onClick: () => { void this.#clearMemory(); }, + }), + ), + ], + }); + tab.append(k.layout(k.pair(this.#status, actions), this.#list)); + this.refresh(); + } + + #statusRows(): readonly (HTMLElement | DocumentFragment)[] { + const k = this.#ctx.ui.kit; + const values = this.#ctx.settings.values; + const filter = filterFrom(values); + const instance = this.#deps.currentInstance(); + const matches = instance !== undefined && instanceMatches(filter, instance); + const channels = [ + values.notifyToast && 'toast', + values.notifyDesktop && 'desktop', + values.notifyVr && 'VR', + values.notifyDiscord && 'Discord', + ].filter((c): c is string => typeof c === 'string'); + return [ + k.row({ label: 'Reporting', value: values.enabled ? k.badge('ok', 'On') : k.badge('neutral', 'Off') }), + k.row({ label: 'Watching', detail: describeFilter(filter) }), + k.row({ + label: 'Current instance', + detail: instance === undefined ? 'Not in an instance' : `${instance.worldName} · ${instance.instanceType}`, + value: instance === undefined ? undefined : k.badge(matches ? 'ok' : 'neutral', matches ? 'Matches' : 'Filtered out'), + }), + k.row({ label: 'Channels', detail: channels.length === 0 ? 'none enabled' : channels.join(', ') }), + k.row({ label: 'Players remembered', value: k.valueText(String(this.#deps.memory.size)) }), + ]; + } + + #reportRows(): readonly (HTMLElement | DocumentFragment)[] { + const k = this.#ctx.ui.kit; + if (this.#reports.length === 0) return [k.emptyState('No joins reported yet.')]; + return this.#reports.map((report) => { + const lines = reportLines(report); + const time = new Date(report.at).toLocaleTimeString(); + return k.row({ + label: `${time} · ${report.joiner.name}`, + detail: lines.slice(1).join(' · '), + value: report.previous === undefined ? k.badge('accent', 'New') : k.badge('warn', 'Rejoin'), + }); + }); + } + + async #clearMemory(): Promise { + const ok = await this.#ctx.notifications.confirm({ + title: 'Forget join history', + message: 'Every player will count as a first-time joiner again. Continue?', + confirmLabel: 'Forget', + icon: 'delete_sweep', + }); + if (!ok) return; + await this.#deps.memory.clear(); + this.#ctx.notifications.toast({ message: 'Join history cleared.' }); + this.refresh(); + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..85ce4ce --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,109 @@ +/** + * Settings schema. The host renders these as rows on the plugin's settings card and persists + * them; the plugin reads them through `ctx.settings`. + */ + +import type { SettingsSchema } from '@vrcnext/plugin-api'; + +/** Instance types as VRCNext's `ParseLocation` names them, plus a shorthand for every group type. */ +export const INSTANCE_TYPES = [ + 'public', + 'friends+', + 'friends', + 'hidden', + 'private', + 'invite_plus', + 'group', + 'group-public', + 'group-plus', + 'group-members', +] as const; + +export const settings = { + enabled: { + kind: 'boolean', + label: 'Report joins', + description: 'Master switch. Filters below decide which instances count.', + default: true, + }, + instanceTypes: { + kind: 'string', + label: 'Only in these instance types', + description: + 'Comma-separated. Leave empty for every instance. Types: public, friends+, friends, hidden, ' + + 'private, invite_plus, group-public, group-plus, group-members, or just "group" for all group instances.', + default: '', + placeholder: 'group-public, group-plus', + }, + groupId: { + kind: 'string', + label: 'Only in instances of this group', + description: 'A grp_… id. Leave empty for any group. When set, the report also says whether the joiner is a member.', + default: '', + placeholder: 'grp_00000000-0000-0000-0000-000000000000', + }, + worldIds: { + kind: 'string', + label: 'Only in these worlds', + description: 'Comma-separated wrld_… ids. Leave empty for any world.', + default: '', + placeholder: 'wrld_…, wrld_…', + }, + settleSecs: { + kind: 'number', + label: 'Seconds to ignore after you join', + description: + 'When you enter an instance, VRChat logs a join for everyone already there. Joins inside this window are remembered but not reported.', + default: 15, + min: 3, + max: 120, + step: 1, + }, + collectTimeoutSecs: { + kind: 'number', + label: 'Seconds to wait for details', + description: 'How long to wait for VRCNext to load the profile and avatar before reporting what is known.', + default: 25, + min: 5, + max: 120, + step: 5, + }, + notifyToast: { + kind: 'boolean', + label: 'In-app toast', + default: true, + }, + notifyDesktop: { + kind: 'boolean', + label: 'Desktop notification', + description: 'Through the VRCNext Bridge on any platform, or VRCNext’s tray toast on Windows.', + default: true, + }, + notifyVr: { + kind: 'boolean', + label: 'VR overlay notification', + description: 'Through the VRCNext Bridge’s VR target on any platform, or the SteamVR wrist overlay on Windows.', + default: true, + }, + notifyDiscord: { + kind: 'boolean', + label: 'Discord webhook', + default: false, + }, + discordWebhookUrl: { + kind: 'string', + label: 'Discord webhook URL', + description: 'Server Settings → Integrations → Webhooks. Sent straight from this page; the URL is stored locally only.', + default: '', + placeholder: 'https://discord.com/api/webhooks/…', + }, + memory: { + kind: 'string', + label: 'Join memory', + description: 'Internal: who has joined a matching instance before.', + default: '{}', + hidden: true, + }, +} as const satisfies SettingsSchema; + +export type Settings = typeof settings; diff --git a/src/vrcnext-data.ts b/src/vrcnext-data.ts new file mode 100644 index 0000000..b2c0e10 --- /dev/null +++ b/src/vrcnext-data.ts @@ -0,0 +1,164 @@ +/** + * Narrowers for the VRCNext events this plugin consumes. + * + * None of these events are in the API's verified map, so every payload arrives as `unknown`. + * Shapes were read out of VRCNext 2026.61.2: `InstanceController.PushCurrentInstanceFromCache`, + * `GroupsController` (`vrcGetGroupsForNetwork`), `FriendsController` (`vrcGetInstanceAvatars`), + * `MessageRouter` (`vrcGetAvatarDetail`) and the `vrcUser` push after login. + */ + +export interface InstanceUser { + readonly id: string; + readonly displayName: string; + readonly ageVerified: boolean; + /** VRChat's own status string, e.g. `18+`, `verified`, `hidden`. Empty until the profile loaded. */ + readonly ageVerificationStatus: string; + readonly platform: string; + /** Present once VRCNext fetched the profile; an empty list means it has not yet. */ + readonly tags: readonly string[]; + readonly lastLogin: string; + readonly avatarId: string; + readonly avatarName: string; +} + +export interface CurrentInstance { + readonly location: string; + readonly worldId: string; + readonly worldName: string; + readonly instanceType: string; + /** Parsed from `~group(grp_…)` in the location; empty for non-group instances. */ + readonly groupId: string; + readonly users: readonly InstanceUser[]; +} + +function rec(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null ? (value as Record) : undefined; +} + +function str(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function strings(value: unknown): readonly string[] { + return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; +} + +export function groupIdOf(location: string): string { + const match = /~group\((grp_[0-9a-f-]+)\)/i.exec(location); + return match?.[1] ?? ''; +} + +function toInstanceUser(value: unknown): InstanceUser | undefined { + const r = rec(value); + if (r === undefined) return undefined; + const id = str(r['id']); + const displayName = str(r['displayName']); + if (id === '' && displayName === '') return undefined; + return { + id, + displayName, + ageVerified: r['ageVerified'] === true, + ageVerificationStatus: str(r['ageVerificationStatus']), + platform: str(r['platform']), + tags: strings(r['tags']), + lastLogin: str(r['lastLogin']), + avatarId: str(r['avatarId']), + avatarName: str(r['avatarName']), + }; +} + +/** `undefined` for the `{ empty: true }` and `{ error }` variants. */ +export function toCurrentInstance(payload: unknown): CurrentInstance | undefined { + const r = rec(payload); + if (r === undefined) return undefined; + const location = str(r['location']); + if (location === '') return undefined; + const rawUsers = Array.isArray(r['users']) ? r['users'] : []; + return { + location, + worldId: str(r['worldId']), + worldName: str(r['worldName']), + instanceType: str(r['instanceType']), + groupId: groupIdOf(location), + users: rawUsers.flatMap((u) => { + const user = toInstanceUser(u); + return user === undefined ? [] : [user]; + }), + }; +} + +/** VRCNext only fills these once it has fetched the profile from the API. */ +export function profileLoaded(user: InstanceUser): boolean { + return user.ageVerificationStatus !== '' || user.tags.length > 0 || user.lastLogin !== ''; +} + +export interface UserGroups { + readonly userId: string; + readonly groupIds: readonly string[]; +} + +export function toUserGroups(payload: unknown): UserGroups | undefined { + const r = rec(payload); + if (r === undefined) return undefined; + const userId = str(r['userId']); + if (userId === '') return undefined; + const groups = Array.isArray(r['groups']) ? r['groups'] : []; + return { + userId, + groupIds: groups.flatMap((g) => { + const id = str(rec(g)?.['id']); + return id === '' ? [] : [id]; + }), + }; +} + +export interface InstanceAvatar { + readonly userId: string; + readonly avatarId: string; + readonly avatarName: string; +} + +export function toInstanceAvatar(payload: unknown): InstanceAvatar | undefined { + const r = rec(payload); + if (r === undefined) return undefined; + const userId = str(r['userId']); + if (userId === '') return undefined; + return { userId, avatarId: str(r['avatarId']), avatarName: str(r['avatarName']) }; +} + +export interface AvatarPerformance { + readonly avatarId: string; + readonly name: string; + readonly pc: string; + readonly quest: string; +} + +export function toAvatarPerformance(payload: unknown): AvatarPerformance | undefined { + const r = rec(payload); + if (r === undefined) return undefined; + const avatarId = str(r['id']); + if (avatarId === '') return undefined; + return { + avatarId, + name: str(r['name']), + pc: str(r['pcPerf']), + quest: str(r['questPerf']), + }; +} + +export interface Self { + readonly id: string; + readonly name: string; +} + +/** + * The signed-in account, from the `vrcUser` event VRCNext pushes after login. `undefined` when + * the payload names nobody (the logged-out variant), so a stale identity is kept over an empty one. + */ +export function toSelf(payload: unknown): Self | undefined { + const r = rec(payload); + if (r === undefined) return undefined; + const id = str(r['id']); + const name = str(r['displayName']); + return id === '' && name === '' ? undefined : { id, name }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1799479 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist", + "types": ["node"] + }, + "include": ["main.ts", "src/**/*.ts"], + "references": [{ "path": "../../packages/api" }] +}