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

+24
View File
@@ -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 <https://unlicense.org/>
+61
View File
@@ -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.
+158
View File
@@ -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<typeof settings>;
/** 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<string>();
/**
* 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<void> {
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<void> {
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.
},
});
+11
View File
@@ -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"
}
}
+33
View File
@@ -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"]
}
+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 };
}
}
+53
View File
@@ -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<Settings>): 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(' · ');
}
+91
View File
@@ -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<string, SeenEntry>;
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<string, unknown>)) {
if (typeof entry !== 'object' || entry === null) continue;
const e = entry as Record<string, unknown>;
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<Settings>;
#map: SeenMap;
constructor(store: SettingsStore<Settings>) {
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<SeenEntry | undefined> {
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<void> {
this.#map = {};
await this.#store.set('memory', '{}');
}
}
+190
View File
@@ -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<Settings>;
/** 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<boolean> {
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<Settings>, report: Report): Promise<void> {
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<Settings>, report: Report): Promise<void> {
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<void> {
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)}`);
}
}
+141
View File
@@ -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<Settings>;
const MAX_SHOWN = 25;
export interface PanelDeps {
readonly memory: JoinMemory;
readonly currentInstance: () => CurrentInstance | undefined;
readonly sendTest: () => Promise<void>;
}
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<void> {
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();
}
}
+109
View File
@@ -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;
+164
View File
@@ -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<string, unknown> | undefined {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : 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 };
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"types": ["node"]
},
"include": ["main.ts", "src/**/*.ts"],
"references": [{ "path": "../../packages/api" }]
}