Presets, requirements and a verdict per report

Everything that varies per club is a preset now: its filters (one of each
kind, pickable rather than pasted ids), the requirements it places on a
joiner, its own channels and its own formats, including a Discord embed the
user edits field by field. A join is reported once per matching preset.

Each report carries the worst verdict of the preset's checks: green when
everything was verified and holds, orange when something could not be checked,
red when a check failed. Templates read it as {result}, {resultEmoji} and
{resultColor}.

Facts come from ctx.vrchat, so the hand-written event narrowers are gone and
the manifest no longer needs host:actions or any event name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bluandClaude Opus 5 committed 2026-09-27 13:10:15 +02:00
1 parent c226195255
commit 9ea036bc35
14 files changed
+763 -1002

No files matched your search

-234
View File
@@ -1,234 +0,0 @@
/**
* 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 { InstanceFilter } from './filters.js';
import { rejoinIn, toTimelineEntries, UNKNOWN_REJOIN, type Rejoin } from './history.js';
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;
readonly rejoin: Rejoin;
}
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, filter: InstanceFilter, deadlineMs: number): Promise<Facts> {
const signal = this.#ctx.signal;
const joinedAt = Date.now();
const [profile, inGroup, avatar, rejoin] = await Promise.all([
this.#profile(joiner, deadlineMs, signal),
filter.groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, filter.groupId, deadlineMs, signal),
this.#avatar(joiner, deadlineMs, signal),
this.#rejoin(joiner, joinedAt, { ms: deadlineMs, signal }),
]);
return {
ageVerified: profile === undefined ? undefined : profile.ageVerified,
ageStatus: profile?.ageVerificationStatus ?? '',
platform: profile?.platform ?? '',
avatar,
inGroup,
rejoin,
};
}
/**
* Whether VRCNext's timeline already has this player in this exact instance. Legacy accounts
* without a `usr_` id cannot be looked up.
*/
async #rejoin(joiner: Joiner, joinedAt: number, window: { readonly ms: number; readonly signal: AbortSignal }): Promise<Rejoin> {
const location = this.#latest?.location ?? '';
if (joiner.userId === '' || location === '') return UNKNOWN_REJOIN;
const pending = waitFor(
this.#ctx.events,
'timelineForUser',
(payload) => toTimelineEntries(payload, joiner.userId),
window,
);
this.#ctx.bridge.send('getTimelineForUser', { userId: joiner.userId });
return rejoinIn(await pending, location, joinedAt);
}
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 };
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Gathers everything a report needs about one joiner, within a deadline.
*
* All of it comes through `ctx.vrchat`, which reads VRCNext's data without touching its dialogs:
* the profile (age verification, friend state), the avatar the player wears and its ranks, the
* groups they show, and VRCNext's own timeline for the rejoin check. The lookups run in
* parallel and each one degrades to "unknown" on its own rather than holding up the report.
*/
import type { PerformanceRank, VrcInstance, VrchatApi } from '@vrcnext/plugin-api';
import { rejoinIn, UNKNOWN_REJOIN, type Rejoin } from './history.js';
export interface Joiner {
readonly name: string;
/** Empty for accounts VRChat still logs with a legacy id. */
readonly userId: string;
}
export interface Facts {
readonly ageVerified: boolean | undefined;
/** `18+`, `verified`, `hidden` or `''`. */
readonly ageVerificationStatus: string;
readonly isFriend: boolean | undefined;
readonly platform: string;
readonly avatarId: string;
readonly avatarName: string;
readonly avatarImageUrl: string;
readonly pcRank: PerformanceRank;
readonly questRank: PerformanceRank;
/** Groups the player shows publicly; `undefined` when VRCNext did not answer. */
readonly groupIds: readonly string[] | undefined;
readonly rejoin: Rejoin;
}
export const UNKNOWN_FACTS: Facts = {
ageVerified: undefined,
ageVerificationStatus: '',
isFriend: undefined,
platform: '',
avatarId: '',
avatarName: '',
avatarImageUrl: '',
pcRank: '',
questRank: '',
groupIds: undefined,
rejoin: UNKNOWN_REJOIN,
};
export interface CollectOptions {
readonly deadlineMs: number;
readonly signal: AbortSignal;
/** Which groups matter; membership is only looked up when one is set. */
readonly wantsGroups: boolean;
}
async function avatarFacts(vrchat: VrchatApi, joiner: Joiner, instance: VrcInstance | undefined, signal: AbortSignal): Promise<Pick<Facts, 'avatarId' | 'avatarName' | 'avatarImageUrl' | 'pcRank' | 'questRank'>> {
const known = instance?.users.find((u) => u.id === joiner.userId);
let avatarId = known?.avatarId ?? '';
let avatarName = known?.avatarName ?? '';
if (avatarId === '') {
const found = await vrchat.instanceAvatar(joiner.userId, { signal });
avatarId = found?.avatarId ?? '';
avatarName = found?.avatarName ?? avatarName;
}
if (avatarId === '') return { avatarId: '', avatarName, avatarImageUrl: '', pcRank: '', questRank: '' };
const avatar = await vrchat.avatar(avatarId, { signal });
return {
avatarId,
avatarName: avatar?.name ?? avatarName,
avatarImageUrl: avatar?.thumbnailImageUrl ?? '',
pcRank: avatar?.pcRank ?? '',
questRank: avatar?.questRank ?? '',
};
}
/** Runs every lookup in parallel and returns whatever arrived before the deadline. */
export async function collectFacts(
vrchat: VrchatApi,
joiner: Joiner,
instance: VrcInstance | undefined,
options: CollectOptions,
): Promise<Facts> {
if (joiner.userId === '') return UNKNOWN_FACTS;
const signal = AbortSignal.any([options.signal, AbortSignal.timeout(options.deadlineMs)]);
const joinedAt = Date.now();
const location = instance?.location ?? '';
const [user, avatar, groups, timeline] = await Promise.all([
vrchat.user(joiner.userId, { signal }),
avatarFacts(vrchat, joiner, instance, signal).catch(() => undefined),
options.wantsGroups ? vrchat.userGroups(joiner.userId, { signal }).then((g) => g.map((x) => x.id), () => undefined) : Promise.resolve(undefined),
location === '' ? Promise.resolve(undefined) : vrchat.userTimeline(joiner.userId, { signal }).catch(() => undefined),
]);
const inInstance = instance?.users.find((u) => u.id === joiner.userId);
return {
ageVerified: user?.ageVerified ?? inInstance?.ageVerified,
ageVerificationStatus: user?.ageVerificationStatus ?? inInstance?.ageVerificationStatus ?? '',
isFriend: user?.isFriend,
platform: user?.platform ?? inInstance?.platform ?? '',
...(avatar ?? { avatarId: '', avatarName: '', avatarImageUrl: '', pcRank: '', questRank: '' }),
groupIds: groups,
rejoin: location === '' ? UNKNOWN_REJOIN : rejoinIn(timeline, location, joinedAt),
};
}
+19 -73
View File
@@ -1,85 +1,31 @@
/**
* Decides whether the current instance is one the user wants reports for.
* Decides which presets want reports for an instance.
*/
import type { SettingsValues } from '@vrcnext/plugin-api';
import type { VrcInstance } from '@vrcnext/plugin-api';
import { groupIdOf } from './vrcnext-data.js';
import type { Settings } from './settings.js';
import type { Preset } from './settings.js';
/** The three things a filter looks at; both a live instance and a past location provide them. */
export interface InstanceShape {
readonly worldId: string;
readonly instanceType: string;
readonly groupId: string;
}
/** What a filter looks at. */
export type InstanceShape = Pick<VrcInstance, 'worldId' | 'instanceType' | 'groupId'>;
/**
* VRCNext's `ParseLocation`, ported: the instance type a location string encodes. Needed for
* past locations from the timeline, which carry no parsed type.
*/
export function instanceTypeOf(location: string): string {
const instance = location.split(':')[1] ?? '';
if (instance.includes('~private(')) return instance.includes('~canRequestInvite') ? 'invite_plus' : 'private';
if (instance.includes('~friends+(')) return 'friends+';
if (instance.includes('~friends(')) return 'friends';
if (instance.includes('~hidden(')) return 'hidden';
if (instance.includes('~group(')) {
const access = /groupAccessType\(([^)]+)\)/.exec(instance)?.[1] ?? '';
if (access === 'public') return 'group-public';
if (access === 'plus') return 'group-plus';
if (access === 'members') return 'group-members';
return 'group';
}
return 'public';
}
export function shapeOfLocation(location: string): InstanceShape {
const worldId = location.split(':')[0] ?? '';
return { worldId: worldId.startsWith('wrld_') ? worldId : '', instanceType: instanceTypeOf(location), groupId: groupIdOf(location) };
}
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: InstanceShape): 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;
export function presetMatches(preset: Preset, instance: InstanceShape): boolean {
if (preset.instanceTypes.length > 0 && !preset.instanceTypes.includes(instance.instanceType as (typeof preset.instanceTypes)[number])) return false;
if (preset.group !== '' && instance.groupId.toLowerCase() !== preset.group.toLowerCase()) return false;
if (preset.worlds.length > 0 && !preset.worlds.some((w) => w.toLowerCase() === 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 {
/** The enabled presets that match, in order. */
export function matchingPresets(presets: readonly Preset[], instance: InstanceShape): readonly Preset[] {
return presets.filter((p) => p.enabled && presetMatches(p, instance));
}
/** One line for the status card: what a preset restricts to. */
export function describePreset(preset: Preset): 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)}`);
if (preset.instanceTypes.length > 0) parts.push(`types: ${preset.instanceTypes.join(', ')}`);
if (preset.group !== '') parts.push('one group');
if (preset.worlds.length > 0) parts.push(`${String(preset.worlds.length)} world${preset.worlds.length === 1 ? '' : 's'}`);
return parts.length === 0 ? 'every instance' : parts.join(' · ');
}
+12 -38
View File
@@ -1,16 +1,13 @@
/**
* Whether a joiner has been in this exact instance with you before, from VRCNext's own timeline.
*
* `getTimelineForUser` returns the ten most recent timeline events involving the player, each with
* a timestamp and the location it happened in. VRCNext keeps those in SQLite, so they survive
* VRCNext and VRChat restarts and reach back to when VRCNext was installed. The check is strict on
* purpose: same world and same instance id, not "met somewhere" and not "an instance like this".
* `ctx.vrchat.userTimeline` returns the ten most recent timeline events involving the player,
* each with a timestamp and the location it happened in. VRCNext keeps those in SQLite, so they
* survive restarts and reach back to when VRCNext was installed. The check is strict on purpose:
* same world and same instance id, not "met somewhere".
*/
export interface TimelineEntry {
readonly timestamp: string;
readonly location: string;
}
import { parseLocation, type VrcTimelineEvent } from '@vrcnext/plugin-api';
export interface Rejoin {
/** `undefined` when VRCNext did not answer in time. */
@@ -21,44 +18,21 @@ export interface Rejoin {
export const UNKNOWN_REJOIN: Rejoin = { seenHere: undefined, lastAt: undefined };
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 : '';
}
/** Narrows a `timelineForUser` reply for one user. */
export function toTimelineEntries(payload: unknown, userId: string): readonly TimelineEntry[] | undefined {
const r = rec(payload);
if (r === undefined || str(r['userId']) !== userId) return undefined;
const events = Array.isArray(r['events']) ? r['events'] : [];
return events.flatMap((e) => {
const entry = rec(e);
return entry === undefined ? [] : [{ timestamp: str(entry['timestamp']), location: str(entry['location']) }];
});
}
/** `wrld_…:12345` — the part of a location that identifies the instance, without its modifiers. */
export function instanceKey(location: string): string {
return location.split('~')[0] ?? '';
}
/**
* Looks for an earlier event in the same instance. Events from this join are excluded by
* timestamp: anything at or after `joinedAt` describes this visit, not an earlier one.
* timestamp: anything at or after `joinedAt` (with a few seconds of slack) describes this
* visit, not an earlier one.
*/
export function rejoinIn(
entries: readonly TimelineEntry[] | undefined,
events: readonly Pick<VrcTimelineEvent, 'timestamp' | 'location'>[] | undefined,
location: string,
joinedAt: number,
): Rejoin {
if (entries === undefined) return UNKNOWN_REJOIN;
const key = instanceKey(location);
if (events === undefined) return UNKNOWN_REJOIN;
const key = parseLocation(location).key;
if (key === '') return { seenHere: false, lastAt: undefined };
const earlier = entries
.filter((e) => instanceKey(e.location) === key)
const earlier = events
.filter((e) => parseLocation(e.location).key === key)
.filter((e) => {
const at = Date.parse(e.timestamp);
return Number.isFinite(at) && at < joinedAt - 5_000;
+53 -202
View File
@@ -1,184 +1,50 @@
/**
* Formats a report and fans it out to every enabled channel.
* Fans a report out to the preset's channels.
*
* 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.
* - In-app toast: always available, one line.
* - Desktop and VR: through the VRCNext Bridge's targets, addressed by name; VR overlays get the
* preset's VR template (WayVR draws with one font and shows nothing for emoji). On Windows,
* when the bridge delivers nothing, VRCNext's own tray toast and wrist overlay.
* - Discord: the preset's embed, rendered with the report's values and posted to its webhook
* through `ctx.http.fetch`. Only `discord.com` is in the manifest's `hosts`.
*/
import {
TemplateError,
renderTemplate,
timeAgo,
renderEmbed,
webhookPayload,
type PluginContext,
type SettingsValues,
type TemplateValues,
type TemplateError,
} from '@vrcnext/plugin-api';
import type { Facts, Joiner } from './collector.js';
import type { Rejoin } from './history.js';
import { DEFAULT_TEMPLATE, 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;
}
import { reportLines, reportSummary, reportValues, type Report } from './report.js';
import type { Settings } from './settings.js';
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;
}
/** `Yes (2 hours ago)`: this player was in this exact instance with you before, per VRCNext's timeline. */
function rejoinText(rejoin: Rejoin): string {
if (rejoin.seenHere === undefined) return 'Unknown';
if (!rejoin.seenHere) return 'No';
return rejoin.lastAt === undefined ? 'Yes' : `Yes (${timeAgo(rejoin.lastAt)})`;
}
/** VRChat's performance rank colours, as emoji. */
const RANK_EMOJI: Readonly<Record<string, string>> = {
Excellent: '🟢', Good: '🔵', Medium: '🟡', Poor: '🟠', VeryPoor: '🔴',
};
const PLATFORM_EMOJI: readonly (readonly [RegExp, string])[] = [
[/windows/i, '🖥️'], [/android|quest/i, '📱'], [/ios/i, '🍎'],
];
function triState(value: boolean | undefined, yes: string, no: string, unknown = '❔'): string {
return value === undefined ? unknown : (value ? yes : no);
}
/**
* Everything a template may name. `undefined` means "not applicable", so a line that only
* shows that value is dropped. Three flavours per fact: the boolean for conditions, `…Text` for
* the spec's wording, `…Emoji` for compact formats.
*/
export function reportValues(report: Report): TemplateValues {
const { facts, joiner, instance } = report;
const groupFiltered = report.groupFilter !== '';
const pc = facts.avatar?.pc ?? '';
const quest = facts.avatar?.quest ?? '';
const seenHere = facts.rejoin.seenHere;
const at = new Date(report.at);
return {
name: joiner.name,
playerId: joiner.userId,
userId: joiner.userId,
ageVerified: facts.ageVerified,
ageVerifiedText: facts.ageVerified === undefined
? 'Unknown'
: `${yesNo(facts.ageVerified)}${facts.ageStatus === '' ? '' : ` (${facts.ageStatus})`}`,
ageVerifiedEmoji: triState(facts.ageVerified, '✅', '❌'),
ageStatus: facts.ageStatus,
pcRank: pc === '' ? undefined : pc,
pcRankText: rank(pc),
pcRankEmoji: RANK_EMOJI[pc] ?? '⚪',
questRank: quest === '' ? undefined : quest,
questRankText: rank(quest),
questRankEmoji: RANK_EMOJI[quest] ?? '⚪',
avatar: facts.avatar?.name ?? '',
platform: facts.platform,
platformEmoji: PLATFORM_EMOJI.find(([re]) => re.test(facts.platform))?.[1] ?? '❔',
groupId: report.groupFilter,
inGroup: groupFiltered ? facts.inGroup : undefined,
inGroupText: groupFiltered ? yesNo(facts.inGroup, 'Unknown (only visible memberships count)') : undefined,
inGroupEmoji: groupFiltered ? triState(facts.inGroup, '✅', '❌') : undefined,
rejoin: seenHere,
rejoinText: rejoinText(facts.rejoin),
rejoinEmoji: triState(seenHere, '🔁', '🆕'),
rejoinAgo: facts.rejoin.lastAt === undefined ? '' : timeAgo(facts.rejoin.lastAt),
rejoinSince: facts.rejoin.lastAt === undefined ? '' : new Date(facts.rejoin.lastAt).toLocaleString(),
rejoinAt: facts.rejoin.lastAt ?? '',
world: instance.worldName,
worldId: instance.worldId,
instanceType: instance.instanceType,
instanceId: instance.location.split('~')[0]?.split(':')[1] ?? '',
location: instance.location,
time: at.toLocaleTimeString(),
date: at.toLocaleDateString(),
timestamp: at.toISOString(),
};
}
/**
* The report through the user's template, one line per entry. An empty template means the
* default; a template that does not parse falls back to it, with `onError` told why.
*/
export function reportLines(
report: Report,
template: string = DEFAULT_TEMPLATE,
onError?: (error: TemplateError) => void,
): readonly string[] {
const chosen = template.trim() === '' ? DEFAULT_TEMPLATE : template;
const values = reportValues(report);
try {
return renderTemplate(chosen, values).split('\n');
} catch (error) {
if (!(error instanceof TemplateError)) throw error;
onError?.(error);
return renderTemplate(DEFAULT_TEMPLATE, values).split('\n');
}
}
/** 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.facts.rejoin.seenHere === undefined ? 'rejoin ?' : (report.facts.rejoin.seenHere ? 'rejoin' : 'new'));
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';
if (report.evaluation.verdict === 'failed') return 'warn';
if (report.evaluation.verdict === 'met') return 'ok';
return 'info';
}
function warnTemplate(ctx: Ctx, error: TemplateError): void {
ctx.logger.warn(`Report template is invalid, using the default: ${error.message}`);
function warnTemplate(ctx: Ctx, report: Report, error: TemplateError): void {
ctx.logger.warn(`Preset "${report.preset.name}": template is invalid, using the default: ${error.message}`);
}
/** 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);
function isVrTarget(name: string): boolean {
return /wayvr|openvr|steamvr|overlay|xso/i.test(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.
* `bridge` pseudo-sink failing means the bridge is not reachable, which is a normal state.
*/
async function sendNative(ctx: Ctx, wants: { readonly desktop: boolean; readonly vr: boolean }, report: Report): Promise<boolean> {
async function sendNative(ctx: Ctx, report: Report): Promise<boolean> {
const { preset } = report;
let targets: readonly { readonly name: string }[];
try {
targets = await ctx.native.targets();
@@ -186,28 +52,22 @@ async function sendNative(ctx: Ctx, wants: { readonly desktop: boolean; readonly
ctx.logger.debug(`Bridge targets not available: ${String(error)}`);
return false;
}
const values = ctx.settings.values;
const desktopSinks = wants.desktop ? pickSinks(targets, true, false) : [];
const vrSinks = wants.vr ? pickSinks(targets, false, true) : [];
if (desktopSinks.length === 0 && vrSinks.length === 0) {
const names = targets.map((t) => t.name);
const batches = [
{ sinks: preset.desktop ? names.filter((n) => !isVrTarget(n)) : [], template: preset.template },
{ sinks: preset.vr ? names.filter(isVrTarget) : [], template: preset.templateVr.trim() === '' ? preset.template : preset.templateVr },
].filter((b) => b.sinks.length > 0);
if (batches.length === 0) {
ctx.logger.debug('The bridge has no target for the enabled channels.');
return false;
}
// VR overlays get their own template when one is set: WayVR draws with a single font and
// shows nothing for emoji, so the rich format is often wrong there.
const vrTemplate = values.templateVr.trim() === '' ? values.template : values.templateVr;
const batches: { readonly sinks: readonly string[]; readonly template: string }[] =
vrTemplate === values.template
? [{ sinks: [...desktopSinks, ...vrSinks], template: values.template }]
: [{ sinks: desktopSinks, template: values.template }, { sinks: vrSinks, template: vrTemplate }];
let delivered = false;
for (const batch of batches.filter((b) => b.sinks.length > 0)) {
const lines = reportLines(report, batch.template, (e) => { warnTemplate(ctx, e); });
for (const batch of batches) {
const lines = reportLines(report, batch.template, (e) => { warnTemplate(ctx, report, e); });
const result = await ctx.native.notify({
title: lines[0] ?? `${report.joiner.name} joined`,
content: lines.slice(1).join('\n'),
timeoutSecs: values.notifyTimeoutSecs,
timeoutSecs: ctx.settings.get('notifyTimeoutSecs'),
icon: 'security-high',
sinks: batch.sinks,
// Never critical: KDE ignores the expiry for critical notifications and keeps them on screen.
@@ -218,18 +78,17 @@ async function sendNative(ctx: Ctx, wants: { readonly desktop: boolean; readonly
}
delivered ||= result.ok;
}
if (!delivered) ctx.logger.debug('Bridge not reachable or every target refused; falling back.');
return delivered;
}
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;
async function sendDesktopAndVr(ctx: Ctx, report: Report): Promise<void> {
const { preset } = report;
if (!preset.desktop && !preset.vr) return;
if (await sendNative(ctx, report)) return;
// Windows without the bridge's targets: VRCNext's own tray toast and wrist overlay in one call.
if (ctx.notifications.desktopAvailable) {
const lines = reportLines(report, values.template, (e) => { warnTemplate(ctx, e); });
const lines = reportLines(report, preset.template, (e) => { warnTemplate(ctx, report, e); });
ctx.notifications.desktop({
title: lines[0] ?? `${report.joiner.name} joined`,
subtitle: lines.slice(1).join(' · '),
@@ -241,44 +100,36 @@ async function sendDesktopAndVr(ctx: Ctx, values: SettingsValues<Settings>, repo
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();
async function sendDiscord(ctx: Ctx, report: Report): Promise<void> {
const { discord } = report.preset;
if (!discord.enabled) return;
const url = discord.webhookUrl.trim();
if (!DISCORD_WEBHOOK.test(url)) {
ctx.logger.warn('Discord webhook is enabled but the URL is not a discord.com webhook URL.');
ctx.logger.warn(`Preset "${report.preset.name}": Discord is on but the URL is not a discord.com webhook URL.`);
return;
}
const embed = renderEmbed(discord.embed, reportValues(report), {
at: new Date(report.at),
onError: (error) => { ctx.logger.warn(`Preset "${report.preset.name}": embed template: ${error.message}`); },
});
if (embed === undefined) {
ctx.logger.warn(`Preset "${report.preset.name}": the embed rendered empty; nothing posted.`);
return;
}
const lines = reportLines(report, values.template, (e) => { warnTemplate(ctx, e); });
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),
body: JSON.stringify(webhookPayload(embed, { username: 'Club Security' })),
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),
]);
/** Sends on every channel the preset enables. Never throws: a failed channel is logged. */
export async function notify(ctx: Ctx, report: Report): Promise<void> {
if (report.preset.toast) ctx.notifications.toast({ message: reportSummary(report), ok: accentFor(report) !== 'warn' });
const results = await Promise.allSettled([sendDesktopAndVr(ctx, report), sendDiscord(ctx, report)]);
for (const result of results) {
if (result.status === 'rejected') ctx.logger.warn(`Notification channel failed: ${String(result.reason)}`);
}
+35 -35
View File
@@ -1,21 +1,23 @@
/**
* The plugin's sidebar tab: status, the last reports, and two maintenance buttons.
* The plugin's sidebar tab: status per preset, the last reports, and a test button.
* Built from `ctx.ui.kit` so it is VRCNext's own markup and follows its theme.
*/
import type { PluginContext } from '@vrcnext/plugin-api';
import type { PluginContext, UiBadgeTone, VrcInstance } from '@vrcnext/plugin-api';
import { describeFilter, filterFrom, instanceMatches } from './filters.js';
import { reportLines, type Report } from './notify.js';
import { describePreset, presetMatches } from './filters.js';
import { reportLines, type Report } from './report.js';
import { VERDICT_TEXT, type Verdict } from './requirements.js';
import type { Settings } from './settings.js';
import type { CurrentInstance } from './vrcnext-data.js';
type Ctx = PluginContext<Settings>;
const MAX_SHOWN = 25;
const VERDICT_TONE: Readonly<Record<Verdict, UiBadgeTone>> = { met: 'ok', unverified: 'warning', failed: 'err' };
export interface PanelDeps {
readonly currentInstance: () => CurrentInstance | undefined;
readonly currentInstance: () => VrcInstance | undefined;
readonly sendTest: () => Promise<void>;
}
@@ -37,13 +39,13 @@ export class ReportPanel {
icon: 'security',
render: (tab) => { this.#render(tab); },
});
// The host renders every schema setting as a row on this card before `render` runs.
// The host renders every schema setting 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.',
'A join is reported once per enabled preset whose filters match the instance. Test the channels from the Club Security tab.',
));
},
});
@@ -64,16 +66,16 @@ export class ReportPanel {
#render(tab: HTMLElement): void {
const k = this.#ctx.ui.kit;
this.#status = k.card({ title: 'Status', icon: 'shield' });
this.#status = k.card({ title: 'Presets', 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.description('Rejoin is answered from VRCNext’s own timeline: was this player in this exact instance with you before.'),
k.description('A test report uses the current instance and your own account, and goes through every preset that matches it.'),
k.description('Green: every requirement verified. Orange: something could not be checked. Red: a requirement was checked and not met.'),
k.buttonRow(
k.button({ label: 'Send test notification', icon: 'send', onClick: () => { void this.#deps.sendTest(); } }),
k.button({ label: 'Send test report', icon: 'send', onClick: () => { void this.#deps.sendTest(); } }),
),
],
});
@@ -83,40 +85,38 @@ export class ReportPanel {
#statusRows(): readonly (HTMLElement | DocumentFragment)[] {
const k = this.#ctx.ui.kit;
const values = this.#ctx.settings.values;
const filter = filterFrom(values);
const presets = this.#ctx.settings.get('presets');
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(', ') }),
];
if (presets.length === 0) return [k.emptyState('No presets yet. Add one in Settings → Plugins → Club Security.')];
const rows = presets.map((preset) => {
const matches = instance !== undefined && preset.enabled && presetMatches(preset, instance);
const channels = [preset.toast && 'toast', preset.desktop && 'desktop', preset.vr && 'VR', preset.discord.enabled && 'Discord']
.filter((c): c is string => typeof c === 'string');
return k.row({
label: preset.name,
detail: `${describePreset(preset)} · ${channels.length === 0 ? 'no channels' : channels.join(', ')}`,
value: !preset.enabled ? k.badge('neutral', 'Off') : instance === undefined ? k.badge('neutral', 'Waiting') : k.badge(matches ? 'ok' : 'neutral', matches ? 'Watching' : 'Not here'),
});
});
rows.push(k.row({
label: 'Current instance',
detail: instance === undefined ? 'Not in an instance' : `${instance.worldName} · ${instance.instanceType}`,
}));
return rows;
}
#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, this.#ctx.settings.values.template);
const lines = reportLines(report, report.preset.template);
const time = new Date(report.at).toLocaleTimeString();
const verdict = report.evaluation.verdict;
return k.row({
label: `${time} · ${report.joiner.name}`,
label: `${time} · ${report.joiner.name} · ${report.preset.name}`,
detail: lines.slice(1).join(' · '),
value: report.facts.rejoin.seenHere === true ? k.badge('warn', 'Rejoin') : k.badge('accent', report.facts.rejoin.seenHere === false ? 'New' : 'Rejoin ?'),
value: k.badge(VERDICT_TONE[verdict], VERDICT_TEXT[verdict]),
});
});
}
}
+148
View File
@@ -0,0 +1,148 @@
/**
* A report, and everything a template may say about it.
*
* Three flavours per fact: the raw value for conditions (`rejoin`, `pcRank`), `…Text` for
* wording, `…Emoji` for compact formats. The verdict comes as `result`, `resultText`,
* `resultEmoji` and `resultColor`, and the individual checks as `checksText` (one line each).
*/
import {
TemplateError,
renderTemplate,
timeAgo,
type TemplateValues,
type VrcInstance,
} from '@vrcnext/plugin-api';
import type { Facts, Joiner } from './facts.js';
import { VERDICT_COLOR, VERDICT_EMOJI, VERDICT_TEXT, type Evaluation } from './requirements.js';
import { DEFAULT_TEMPLATE, type Preset } from './settings.js';
export interface Report {
readonly at: number;
readonly preset: Preset;
readonly joiner: Joiner;
readonly instance: VrcInstance;
readonly facts: Facts;
readonly evaluation: Evaluation;
}
/** VRChat's performance rank colours, as emoji. */
const RANK_EMOJI: Readonly<Record<string, string>> = {
Excellent: '🟢', Good: '🔵', Medium: '🟡', Poor: '🟠', VeryPoor: '🔴',
};
const PLATFORM_EMOJI: readonly (readonly [RegExp, string])[] = [
[/windows/i, '🖥️'], [/android|quest/i, '📱'], [/ios/i, '🍎'],
];
function yesNo(value: boolean | undefined, unknown = 'Unknown'): string {
if (value === undefined) return unknown;
return value ? 'Yes' : 'No';
}
function triState(value: boolean | undefined, yes: string, no: string, unknown = '❔'): string {
return value === undefined ? unknown : (value ? yes : no);
}
function rankText(rank: string): string {
return rank === '' ? 'Unknown' : rank;
}
function rejoinText(facts: Facts): string {
const { seenHere, lastAt } = facts.rejoin;
if (seenHere === undefined) return 'Unknown';
if (!seenHere) return 'No';
return lastAt === undefined ? 'Yes' : `Yes (${timeAgo(lastAt)})`;
}
/** Everything a template may name. `undefined` means "not applicable", which drops the line. */
export function reportValues(report: Report): TemplateValues {
const { facts, joiner, instance, evaluation, preset } = report;
const at = new Date(report.at);
const groupChecked = preset.requiredGroup !== '';
// A membership the player hides cannot be called a "no": the check is unverified, and so is
// the value a template sees.
const groupVerdict = evaluation.checks.find((c) => c.key === 'group')?.verdict;
const inGroup = groupVerdict === 'met' ? true : groupVerdict === 'failed' ? false : undefined;
const failed = evaluation.checks.filter((c) => c.verdict === 'failed');
const unverified = evaluation.checks.filter((c) => c.verdict === 'unverified');
return {
name: joiner.name,
playerId: joiner.userId,
userId: joiner.userId,
preset: preset.name,
result: evaluation.verdict,
resultText: VERDICT_TEXT[evaluation.verdict],
resultEmoji: VERDICT_EMOJI[evaluation.verdict],
resultColor: VERDICT_COLOR[evaluation.verdict],
checksText: evaluation.checks.map((c) => `${VERDICT_EMOJI[c.verdict]} ${c.label}: ${c.detail}`).join('\n'),
checksPlainText: evaluation.checks.map((c) => `${c.label}: ${c.detail}`).join('\n'),
failedText: failed.map((c) => `${c.label} (${c.detail})`).join(', '),
unverifiedText: unverified.map((c) => `${c.label} (${c.detail})`).join(', '),
ageVerified: facts.ageVerified,
ageVerifiedText: facts.ageVerified === undefined
? 'Unknown'
: `${yesNo(facts.ageVerified)}${facts.ageVerificationStatus === '' ? '' : ` (${facts.ageVerificationStatus})`}`,
ageVerifiedEmoji: triState(facts.ageVerified, '✅', '❌'),
ageStatus: facts.ageVerificationStatus,
pcRank: facts.pcRank === '' ? undefined : facts.pcRank,
pcRankText: rankText(facts.pcRank),
pcRankEmoji: RANK_EMOJI[facts.pcRank] ?? '⚪',
questRank: facts.questRank === '' ? undefined : facts.questRank,
questRankText: rankText(facts.questRank),
questRankEmoji: RANK_EMOJI[facts.questRank] ?? '⚪',
avatar: facts.avatarName,
avatarId: facts.avatarId,
avatarImageUrl: facts.avatarImageUrl,
platform: facts.platform,
platformEmoji: PLATFORM_EMOJI.find(([re]) => re.test(facts.platform))?.[1] ?? '❔',
isFriend: facts.isFriend,
friendText: yesNo(facts.isFriend),
inGroup,
inGroupText: groupChecked ? yesNo(inGroup, 'Not visible') : undefined,
inGroupEmoji: groupChecked ? triState(inGroup, '✅', '❔') : undefined,
rejoin: facts.rejoin.seenHere,
rejoinText: rejoinText(facts),
rejoinEmoji: triState(facts.rejoin.seenHere, '🔁', '🆕'),
rejoinAgo: facts.rejoin.lastAt === undefined ? '' : timeAgo(facts.rejoin.lastAt),
rejoinSince: facts.rejoin.lastAt === undefined ? '' : new Date(facts.rejoin.lastAt).toLocaleString(),
rejoinAt: facts.rejoin.lastAt ?? '',
world: instance.worldName,
worldId: instance.worldId,
instanceType: instance.instanceType,
instanceId: instance.instanceId,
location: instance.location,
time: at.toLocaleTimeString(),
date: at.toLocaleDateString(),
timestamp: at.toISOString(),
};
}
/**
* The report through a template, one line per entry. An empty template means the default; one
* that does not parse falls back to it, with `onError` told why.
*/
export function reportLines(report: Report, template: string, onError?: (error: TemplateError) => void): readonly string[] {
const chosen = template.trim() === '' ? DEFAULT_TEMPLATE : template;
const values = reportValues(report);
try {
return renderTemplate(chosen, values).split('\n');
} catch (error) {
if (!(error instanceof TemplateError)) throw error;
onError?.(error);
return renderTemplate(DEFAULT_TEMPLATE, values).split('\n');
}
}
/** Everything a single-line surface can hold. */
export function reportSummary(report: Report): string {
const { facts, evaluation } = report;
const bits = [
VERDICT_TEXT[evaluation.verdict],
`PC ${rankText(facts.pcRank).replace('Unknown', '?')}`,
`Quest ${rankText(facts.questRank).replace('Unknown', '?')}`,
facts.rejoin.seenHere === undefined ? 'rejoin ?' : (facts.rejoin.seenHere ? 'rejoin' : 'new'),
];
return `${VERDICT_EMOJI[evaluation.verdict]} ${report.joiner.name} joined (${report.preset.name}) · ${bits.join(' · ')}`;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Checks a joiner against a preset's requirements.
*
* Every check ends in one of three verdicts, and the report's colour is the worst of them:
*
* - `met` (green, ✅): the requirement holds and could be verified.
* - `unverified` (orange, ⚠️): it could not be checked — an unknown avatar rank, a hidden age
* status, a membership the player does not show.
* - `failed` (red, ⛔): it was checked and does not hold.
*/
import { rankIndex } from '@vrcnext/plugin-api';
import type { Facts } from './facts.js';
import type { Preset } from './settings.js';
export type Verdict = 'met' | 'unverified' | 'failed';
export interface Check {
readonly key: 'age' | 'pcRank' | 'questRank' | 'group' | 'friend';
readonly label: string;
readonly verdict: Verdict;
/** Why, in a few words. */
readonly detail: string;
}
export interface Evaluation {
readonly verdict: Verdict;
readonly checks: readonly Check[];
}
export const VERDICT_EMOJI: Readonly<Record<Verdict, string>> = { met: '✅', unverified: '⚠️', failed: '⛔' };
export const VERDICT_COLOR: Readonly<Record<Verdict, string>> = { met: 'green', unverified: 'orange', failed: 'red' };
export const VERDICT_TEXT: Readonly<Record<Verdict, string>> = {
met: 'All requirements met',
unverified: 'Some requirements unverified',
failed: 'Requirements not met',
};
function ageCheck(facts: Facts): Check {
const status = facts.ageVerificationStatus;
if (status === '18+') return { key: 'age', label: '18+ verified', verdict: 'met', detail: '18+' };
if (status === 'verified') return { key: 'age', label: '18+ verified', verdict: 'failed', detail: 'verified, not 18+' };
if (facts.ageVerified === true) return { key: 'age', label: '18+ verified', verdict: 'met', detail: 'verified' };
const detail = status === 'hidden' ? 'hidden' : facts.ageVerified === false ? 'not verified' : 'unknown';
return { key: 'age', label: '18+ verified', verdict: 'unverified', detail };
}
function rankCheck(key: 'pcRank' | 'questRank', label: string, rank: string, minimum: string): Check {
const have = rankIndex(rank);
const want = rankIndex(minimum);
if (have === undefined || want === undefined) return { key, label, verdict: 'unverified', detail: 'rank unknown' };
return have <= want
? { key, label, verdict: 'met', detail: rank }
: { key, label, verdict: 'failed', detail: `${rank}, needs ${minimum} or better` };
}
function groupCheck(facts: Facts, groupId: string): Check {
const label = 'Group member';
if (facts.groupIds === undefined) return { key: 'group', label, verdict: 'unverified', detail: 'groups unknown' };
const member = facts.groupIds.some((id) => id.toLowerCase() === groupId.toLowerCase());
return member
? { key: 'group', label, verdict: 'met', detail: 'member' }
: { key: 'group', label, verdict: 'unverified', detail: 'not among visible memberships' };
}
function friendCheck(facts: Facts): Check {
const label = 'On friend list';
if (facts.isFriend === undefined) return { key: 'friend', label, verdict: 'unverified', detail: 'unknown' };
return facts.isFriend
? { key: 'friend', label, verdict: 'met', detail: 'friend' }
: { key: 'friend', label, verdict: 'failed', detail: 'not a friend' };
}
/** The worst verdict wins: one failure makes the report red, otherwise one unknown makes it orange. */
export function worst(verdicts: readonly Verdict[]): Verdict {
if (verdicts.includes('failed')) return 'failed';
if (verdicts.includes('unverified')) return 'unverified';
return 'met';
}
export function evaluate(preset: Preset, facts: Facts): Evaluation {
const checks: Check[] = [];
if (preset.requireAge) checks.push(ageCheck(facts));
if (preset.minPcRank !== 'any') checks.push(rankCheck('pcRank', 'PC avatar rank', facts.pcRank, preset.minPcRank));
if (preset.minQuestRank !== 'any') checks.push(rankCheck('questRank', 'Quest avatar rank', facts.questRank, preset.minQuestRank));
if (preset.requiredGroup !== '') checks.push(groupCheck(facts, preset.requiredGroup));
if (preset.requireFriend) checks.push(friendCheck(facts));
return { verdict: worst(checks.map((c) => c.verdict)), checks };
}
+156 -115
View File
@@ -1,151 +1,192 @@
/**
* Settings schema. The host renders these as rows on the plugin's settings card and persists
* them; the plugin reads them through `ctx.settings`.
* Settings schema.
*
* Everything that varies per club is a **preset**: which instances it watches (filters, one of
* each kind), what it expects of a joiner (requirements), and where its reports go (channels,
* each with its own template or embed). The three numbers at the bottom are global.
*/
import type { SettingsSchema } from '@vrcnext/plugin-api';
import { INSTANCE_TYPES, PERFORMANCE_RANKS, type SettingsSchema, type SettingsValues } 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;
/** The report as first specified, one fact per line. */
/** The report as text: first line = title, rest = body. Used for toasts, desktop and VR. */
export const DEFAULT_TEMPLATE = [
'Player "{name}" joined',
'18+ Verified: {ageVerifiedText}',
'Avatar PC Performance Rank: {pcRankText}',
'Avatar Quest Performance Rank: {questRankText}',
'In Group: {inGroupText}',
'Rejoin?: {rejoinText}',
'{resultEmoji} {name} {{ "is back" if rejoin else "joined" }} · {preset}',
'{checksText}',
'Avatar: {avatar} (PC {pcRankText} · Quest {questRankText})',
'{{ "Seen here before, " + rejoinAgo if rejoin else "" }}',
].join('\n');
/**
* The first version's variables were text; they are booleans now, with the wording under
* `…Text`. A stored template written against the old names is rewritten once on activation, so
* an edited format keeps rendering words rather than `true`.
*/
export function migrateTemplate(template: string): string {
return template.replace(/\{(ageVerified|pcRank|questRank|inGroup|rejoin)\}/g, '{$1Text}');
}
/** The same, without emoji: WayVR draws with one font and shows nothing for symbols. */
export const DEFAULT_TEMPLATE_VR = [
'{name} {{ "is back" if rejoin else "joined" }}: {resultText}',
'{checksPlainText}',
].join('\n');
export const settings = {
enabled: {
/** The report as a Discord embed. Every text is a template; `{resultColor}` colours the bar. */
export const DEFAULT_EMBED = {
title: '{resultEmoji} {name} {{ "is back" if rejoin else "joined" }}',
description: '{checksText}',
color: '{resultColor}',
thumbnailUrl: '{avatarImageUrl}',
footerText: '{preset} · {world} · {instanceType}',
timestamp: true,
fields: [
{ name: 'Avatar', value: '{avatar}', inline: true },
{ name: 'PC / Quest', value: '{pcRankEmoji} {pcRankText} / {questRankEmoji} {questRankText}', inline: true },
{ name: 'Rejoin', value: '{{ rejoinText }}', inline: true },
],
} as const;
/** Names a template may use. Listed under the editors so the user can see them. */
export const TEMPLATE_VARIABLES = [
'name', 'playerId', 'preset', 'result', 'resultText', 'resultEmoji', 'resultColor', 'checksText', 'checksPlainText',
'failedText', 'unverifiedText', 'ageVerified', 'ageVerifiedText', 'ageVerifiedEmoji', 'ageStatus',
'pcRank', 'pcRankText', 'pcRankEmoji', 'questRank', 'questRankText', 'questRankEmoji',
'avatar', 'avatarId', 'avatarImageUrl', 'platform', 'platformEmoji', 'isFriend', 'friendText',
'inGroup', 'inGroupText', 'inGroupEmoji', 'rejoin', 'rejoinText', 'rejoinEmoji', 'rejoinAgo', 'rejoinSince',
'world', 'worldId', 'instanceType', 'instanceId', 'location', 'time', 'date', 'timestamp',
] as const;
const RANK_OPTIONS = [
{ value: 'any', label: 'Any' },
...[...PERFORMANCE_RANKS].reverse().map((rank) => ({ value: rank, label: `${rank} or better` })),
] as const;
const INSTANCE_TYPE_OPTIONS = INSTANCE_TYPES.map((type) => ({ value: type, label: type })) as unknown as readonly { readonly value: (typeof INSTANCE_TYPES)[number]; readonly label: string }[];
/** One club. */
export const preset = {
name: { kind: 'string', label: 'Preset name', default: 'My club', maxLength: 40 },
enabled: { kind: 'boolean', label: 'Enabled', default: true },
instanceTypes: {
kind: 'multiselect',
label: 'Instance types',
description: 'Only these count. None chosen means every type.',
default: [],
options: INSTANCE_TYPE_OPTIONS,
},
group: {
kind: 'group',
label: 'Group',
description: 'Only instances of this group. Empty means any.',
default: '',
},
worlds: {
kind: 'world',
label: 'Worlds',
description: 'Only these worlds. None chosen means any.',
default: [],
multiple: true,
},
requireAge: {
kind: 'boolean',
label: 'Report joins',
description: 'Master switch. Filters below decide which instances count.',
label: 'Require 18+ verification',
description: 'Met when VRChat shows 18+. Hidden or unknown counts as unverified; a verified account under 18 fails.',
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.',
minPcRank: {
kind: 'select',
label: 'PC avatar rank at least',
description: 'An unknown rank counts as unverified.',
default: 'any',
options: RANK_OPTIONS,
},
minQuestRank: {
kind: 'select',
label: 'Quest avatar rank at least',
default: 'any',
options: RANK_OPTIONS,
},
requiredGroup: {
kind: 'group',
label: 'Must be a member of',
description: 'Only memberships the player shows publicly can be seen; a hidden one counts as unverified.',
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,
},
requireFriend: { kind: 'boolean', label: 'Must be on my friend list', default: false },
toast: { kind: 'boolean', label: 'In-app toast', default: true },
desktop: { kind: 'boolean', label: 'Desktop notification', default: true },
vr: { kind: 'boolean', label: 'VR overlay notification', default: true },
template: {
kind: 'string',
multiline: true,
label: 'Report template',
description:
'First line = title, rest = body. A line whose placeholders are all empty is left out. {name} is short for ' +
'{{ name }}; expressions like {{ "yes" if rejoin else "no" }}, {{ pcRank | upper }} and {% if inGroup == false %}…{% endif %} work. ' +
'Text: name playerId ageVerifiedText ageStatus pcRankText questRankText avatar platform inGroupText rejoinText rejoinAgo ' +
'rejoinSince world worldId instanceType instanceId location time date. Booleans: ageVerified inGroup rejoin. Emoji: ' +
'ageVerifiedEmoji pcRankEmoji questRankEmoji platformEmoji inGroupEmoji rejoinEmoji.',
description: 'First line = title, rest = body. A line whose placeholders are all empty is left out. Variables are listed under the Discord embed.',
default: DEFAULT_TEMPLATE,
},
templateVr: {
kind: 'string',
multiline: true,
label: 'VR overlay template (optional)',
description:
'Used for VR overlay targets instead of the report template. Leave empty to use the same. WayVR draws with one ' +
'font and shows nothing for emoji and most symbols, so keep this to plain text.',
default: '',
label: 'VR overlay template',
description: 'Plain text: WayVR shows nothing for emoji. Empty means the report template.',
default: DEFAULT_TEMPLATE_VR,
},
notifyToast: {
kind: 'boolean',
label: 'In-app toast',
default: true,
discord: {
kind: 'object',
label: 'Discord',
fields: {
enabled: { kind: 'boolean', label: 'Post to a webhook', default: false },
webhookUrl: {
kind: 'string',
label: 'Webhook URL',
description: 'Server Settings → Integrations → Webhooks. Only discord.com is allowed.',
default: '',
placeholder: 'https://discord.com/api/webhooks/…',
format: 'url',
},
embed: {
kind: 'embed',
label: 'Embed',
default: DEFAULT_EMBED,
variables: TEMPLATE_VARIABLES,
},
},
},
notifyDesktop: {
kind: 'boolean',
label: 'Desktop notification',
description: 'Through the VRCNext Bridge on any platform, or VRCNext’s tray toast on Windows.',
default: true,
} as const satisfies SettingsSchema;
export const settings = {
presets: {
kind: 'list',
label: 'Presets',
description: 'One per club: which instances it watches, what it requires, where it reports. A join is reported once per preset that matches.',
titleKey: 'name',
addLabel: 'Add preset',
default: [],
item: preset,
},
settleSecs: {
kind: 'number',
label: 'Seconds to ignore after you join',
description: 'VRChat logs a join for everyone already there when you enter. Joins inside this window are not reported.',
default: 15,
min: 3,
max: 120,
step: 1,
unit: 's',
slider: true,
},
collectTimeoutSecs: {
kind: 'number',
label: 'Seconds to wait for details',
description: 'How long to give VRCNext for the profile, avatar and groups before reporting what is known.',
default: 25,
markers: [5, 10, 15, 25, 40, 60],
unit: 's',
},
notifyTimeoutSecs: {
kind: 'number',
label: 'Seconds a desktop or VR notification stays',
description: 'Sent as the notification’s expiry. KDE keeps "critical" notifications until dismissed, so reports are never sent as critical.',
default: 30,
min: 1,
max: 60,
step: 1,
},
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/…',
unit: 's',
slider: true,
},
} as const satisfies SettingsSchema;
export type Settings = typeof settings;
export type Preset = SettingsValues<typeof preset>;
-164
View File
@@ -1,164 +0,0 @@
/**
* 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 };
}