Club Security 1.0.0
This commit is contained in:
commit
8d6850c825
13 files changed
+1256
No files matched your search
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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(' · ');
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user