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:
1 parent
c226195255
commit
9ea036bc35
14 files changed
+763
-1002
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(' · ')}`;
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user