Club Security 1.0.0

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

No files matched your search

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