Club Security 1.0.0
This commit is contained in:
commit
8d6850c825
13 files changed
+1256
No files matched your search
@@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org/>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Club Security
|
||||||
|
|
||||||
|
Reports who joins your instance. For every join that passes the filters, one report:
|
||||||
|
|
||||||
|
```
|
||||||
|
Player "Tupper" joined
|
||||||
|
18+ Verified: Yes (18+)
|
||||||
|
Avatar PC Performance Rank: Good
|
||||||
|
Avatar Quest Performance Rank: Poor
|
||||||
|
In Group: Yes
|
||||||
|
Rejoin?: Yes (2× before, last 26/09/2026, 21:40:12)
|
||||||
|
```
|
||||||
|
|
||||||
|
`In Group` only appears when a group filter is set.
|
||||||
|
|
||||||
|
## Filters
|
||||||
|
|
||||||
|
All optional, all in the plugin's settings card. Empty means "any".
|
||||||
|
|
||||||
|
| Setting | Meaning |
|
||||||
|
| :--- | :--- |
|
||||||
|
| Instance types | Comma-separated: `public`, `friends+`, `friends`, `hidden`, `private`, `invite_plus`, `group-public`, `group-plus`, `group-members`, or `group` for every group instance. |
|
||||||
|
| Group | A `grp_…` id. Only instances of that group count, and the report says whether the joiner is a member. |
|
||||||
|
| Worlds | Comma-separated `wrld_…` ids. |
|
||||||
|
|
||||||
|
## Channels
|
||||||
|
|
||||||
|
| Channel | How |
|
||||||
|
| :--- | :--- |
|
||||||
|
| In-app toast | Always available. One line. |
|
||||||
|
| Desktop | A VRCNext Bridge desktop target on any platform; VRCNext's tray toast on Windows when the bridge delivers nothing. |
|
||||||
|
| VR | A VRCNext Bridge VR target (WayVR and similar) on any platform; SteamVR wrist overlay on Windows when the bridge delivers nothing. |
|
||||||
|
| Discord | A `discord.com` webhook URL, posted as an embed through `ctx.http.fetch` (`discord.com` is the plugin's only declared host). |
|
||||||
|
|
||||||
|
## Where each fact comes from
|
||||||
|
|
||||||
|
| Fact | Source | Caveat |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| 18+ verified | VRCNext fetches the joiner's profile itself on join and re-pushes the instance; the plugin waits for that push. | Legacy accounts without a `usr_` id cannot be looked up. |
|
||||||
|
| Avatar ranks | `vrcGetInstanceAvatars` resolves the avatar from the profile image through VRCNext's avatar databases, then `vrcGetAvatarDetail` gives the ranks. | Only works when one of the databases knows the avatar. The detail action also feeds VRCNext's avatar modal, so it is skipped while that modal is open. |
|
||||||
|
| In group | `vrcGetGroupsForNetwork`, the joiner's publicly visible groups. | A member who hides the membership shows as "No". |
|
||||||
|
| Rejoin | The plugin's own memory of joins to matching instances, kept in a hidden setting, capped at 2000 players. | Cleared with the button on the plugin's tab. |
|
||||||
|
|
||||||
|
Everything waits at most the configured number of seconds, then reports what it has, with
|
||||||
|
`Unknown` for the rest.
|
||||||
|
|
||||||
|
## Your own joins
|
||||||
|
|
||||||
|
The signed-in account comes from the `vrcUser` event VRCNext pushes after login; the plugin
|
||||||
|
reads no page globals. VRChat logs an `OnPlayerJoined` line for the local player too, and right after it one line for
|
||||||
|
every player already in the instance. The plugin ignores your own line and treats joins inside
|
||||||
|
the settle window after it (or after a world change) as "already here": they are remembered for
|
||||||
|
the rejoin check but not reported.
|
||||||
|
|
||||||
|
## Layout and permissions
|
||||||
|
|
||||||
|
Flat, like every plugin: `plugin.json`, `main.ts`, `src/`. The manifest declares `host:events`
|
||||||
|
(the five `vrc…` events above and `vrcUser`), `host:actions` (the four `vrcGet…` lookups),
|
||||||
|
`gamelog` (joins and world changes), `notifications` (toast, confirm, Windows tray toast),
|
||||||
|
`native` (bridge targets) and `network` with `discord.com` as its only host. Nothing is optional:
|
||||||
|
every channel is a plain setting, and a category that is off in the settings is simply never called.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Club Security.
|
||||||
|
*
|
||||||
|
* Watches VRChat's game log for players joining your instance. When the instance passes the
|
||||||
|
* configured filters (type, group, world), it gathers what VRCNext knows about the joiner and
|
||||||
|
* sends one report per join to the enabled channels.
|
||||||
|
*
|
||||||
|
* Compare with `plugin.json`: every category used here is declared there, and the exact action
|
||||||
|
* and event names are listed so they are granted when the plugin is enabled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { definePlugin, type PluginContext, type PluginId } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
|
import { FactCollector, type Joiner } from './src/collector.js';
|
||||||
|
import { filterFrom, instanceMatches } from './src/filters.js';
|
||||||
|
import { JoinMemory } from './src/memory.js';
|
||||||
|
import { notifyAll, type Report } from './src/notify.js';
|
||||||
|
import { ReportPanel } from './src/panel.js';
|
||||||
|
import { settings } from './src/settings.js';
|
||||||
|
import { toSelf, type Self } from './src/vrcnext-data.js';
|
||||||
|
|
||||||
|
type Ctx = PluginContext<typeof settings>;
|
||||||
|
|
||||||
|
/** VRCNext's parsed game-log kinds for `[Behaviour] OnPlayerJoined` and `Joining wrld_…`. */
|
||||||
|
const JOIN_EVENT = 'gl_player_join';
|
||||||
|
const WORLD_JOIN_EVENT = 'gl_world_join';
|
||||||
|
|
||||||
|
class ClubSecurity {
|
||||||
|
readonly #ctx: Ctx;
|
||||||
|
readonly #collector: FactCollector;
|
||||||
|
readonly #memory: JoinMemory;
|
||||||
|
readonly #panel: ReportPanel;
|
||||||
|
/**
|
||||||
|
* The signed-in account, from the `vrcUser` event VRCNext pushes after login and whenever the
|
||||||
|
* profile is re-rendered. Empty until the first push: before that, only the settle window
|
||||||
|
* keeps the local player's own `OnPlayerJoined` line from being reported.
|
||||||
|
*/
|
||||||
|
#self: Self = { id: '', name: '' };
|
||||||
|
/** Joiners currently being looked up, so a duplicate log line does not produce two reports. */
|
||||||
|
readonly #inFlight = new Set<string>();
|
||||||
|
/**
|
||||||
|
* Until when joins count as "already here". VRChat logs an `OnPlayerJoined` for every player
|
||||||
|
* present when the local player arrives, in one burst right after the local player's own line.
|
||||||
|
*/
|
||||||
|
#settledAt = 0;
|
||||||
|
|
||||||
|
constructor(ctx: Ctx) {
|
||||||
|
this.#ctx = ctx;
|
||||||
|
this.#collector = new FactCollector(ctx);
|
||||||
|
this.#memory = new JoinMemory(ctx.settings);
|
||||||
|
this.#panel = new ReportPanel(ctx, {
|
||||||
|
memory: this.#memory,
|
||||||
|
currentInstance: () => this.#collector.instance,
|
||||||
|
sendTest: () => this.#sendTest(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.#ctx.events.on('vrcUser', (payload) => {
|
||||||
|
const self = toSelf(payload);
|
||||||
|
if (self !== undefined) this.#self = self;
|
||||||
|
});
|
||||||
|
this.#ctx.events.on('vrcCurrentInstance', () => { this.#panel.refresh(); });
|
||||||
|
this.#ctx.gameLog.onType(WORLD_JOIN_EVENT, () => { this.#startSettling(); });
|
||||||
|
this.#ctx.gameLog.onType(JOIN_EVENT, (entry) => {
|
||||||
|
void this.#onJoin({ name: entry.message, userId: entry.detail });
|
||||||
|
});
|
||||||
|
this.#panel.install();
|
||||||
|
// Ask for the instance we are already in, so the first join after enabling is not missed.
|
||||||
|
this.#ctx.bridge.send('vrcGetCurrentInstance');
|
||||||
|
this.#ctx.logger.info(`Club Security v${this.#ctx.version} watching for joins.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
#startSettling(): void {
|
||||||
|
this.#settledAt = Date.now() + this.#ctx.settings.get('settleSecs') * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #onJoin(joiner: Joiner): Promise<void> {
|
||||||
|
const values = this.#ctx.settings.values;
|
||||||
|
if (!values.enabled || joiner.name === '') return;
|
||||||
|
if (this.#isSelf(joiner)) {
|
||||||
|
this.#startSettling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const instance = this.#collector.instance;
|
||||||
|
if (instance === undefined) {
|
||||||
|
this.#ctx.logger.debug(`${joiner.name} joined but the current instance is not known yet.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filter = filterFrom(values);
|
||||||
|
if (!instanceMatches(filter, instance)) return;
|
||||||
|
|
||||||
|
const key = joiner.userId === '' ? `name:${joiner.name}` : joiner.userId;
|
||||||
|
if (this.#inFlight.has(key)) return;
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
const previous = await this.#memory.record(key, joiner.name, instance.location);
|
||||||
|
if (Date.now() < this.#settledAt) {
|
||||||
|
this.#ctx.logger.debug(`${joiner.name} was already here when you joined; remembered, not reported.`);
|
||||||
|
this.#panel.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const facts = await this.#collector.collect(joiner, filter.groupId, values.collectTimeoutSecs * 1000);
|
||||||
|
const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId, previous };
|
||||||
|
this.#panel.push(report);
|
||||||
|
this.#ctx.logger.info(`Reported ${joiner.name}: 18+ ${String(facts.ageVerified ?? '?')}, PC ${facts.avatar?.pc ?? '?'}, Quest ${facts.avatar?.quest ?? '?'}.`);
|
||||||
|
await notifyAll(this.#ctx, report);
|
||||||
|
} catch (error) {
|
||||||
|
this.#ctx.logger.error(`Report for ${joiner.name} failed: ${String(error)}`);
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#isSelf(joiner: Joiner): boolean {
|
||||||
|
const self = this.#self;
|
||||||
|
if (joiner.userId !== '' && self.id !== '') return joiner.userId === self.id;
|
||||||
|
return self.name !== '' && joiner.name === self.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A report built from whatever the current instance knows about you, to check the channels. */
|
||||||
|
async #sendTest(): Promise<void> {
|
||||||
|
const instance = this.#collector.instance;
|
||||||
|
if (instance === undefined) {
|
||||||
|
this.#ctx.notifications.toast({ message: 'Join an instance first; the test uses it.', ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const self = this.#self;
|
||||||
|
const me = instance.users.find((u) => u.id === self.id) ?? instance.users[0];
|
||||||
|
const name = me?.displayName ?? self.name;
|
||||||
|
const joiner: Joiner = { name: name === '' ? 'Test player' : name, userId: me?.id ?? '' };
|
||||||
|
const filter = filterFrom(this.#ctx.settings.values);
|
||||||
|
const facts = await this.#collector.collect(joiner, filter.groupId, 10_000);
|
||||||
|
const report: Report = {
|
||||||
|
at: Date.now(),
|
||||||
|
joiner,
|
||||||
|
instance,
|
||||||
|
facts,
|
||||||
|
groupFilter: filter.groupId,
|
||||||
|
previous: this.#memory.previous(joiner.userId === '' ? `name:${joiner.name}` : joiner.userId),
|
||||||
|
};
|
||||||
|
await notifyAll(this.#ctx, report);
|
||||||
|
this.#ctx.notifications.toast({ message: 'Test report sent to every enabled channel.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: 'club-security' as PluginId,
|
||||||
|
settings,
|
||||||
|
|
||||||
|
activate(ctx) {
|
||||||
|
new ClubSecurity(ctx).start();
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
// Every listener and panel went through `ctx`, so the host tears them down.
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@vrcnext-examples/club-security",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Reports who joins your instance: age verification, avatar performance, group membership and rejoins.",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@vrcnext/plugin-api": "0.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"id": "club-security",
|
||||||
|
"name": "Club Security",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": "^0.2.0",
|
||||||
|
"description": "When someone joins your instance, gathers age verification, avatar performance, group membership and rejoin history, then notifies you in-app, on the desktop, in VR or on Discord.",
|
||||||
|
"author": "vrcnext-plugins",
|
||||||
|
"homepage": "https://github.com/vrcnext-plugins/vrcnext-plugin-system/tree/main/examples/club-security",
|
||||||
|
"tags": ["notifications", "privacy", "activity"],
|
||||||
|
"permissions": [
|
||||||
|
"host:events",
|
||||||
|
"host:actions",
|
||||||
|
"gamelog",
|
||||||
|
"notifications",
|
||||||
|
"native",
|
||||||
|
"network"
|
||||||
|
],
|
||||||
|
"optionalPermissions": [],
|
||||||
|
"actions": [
|
||||||
|
"vrcGetCurrentInstance",
|
||||||
|
"vrcGetGroupsForNetwork",
|
||||||
|
"vrcGetInstanceAvatars",
|
||||||
|
"vrcGetAvatarDetail"
|
||||||
|
],
|
||||||
|
"events": [
|
||||||
|
"vrcUser",
|
||||||
|
"vrcCurrentInstance",
|
||||||
|
"vrcGroupsForNetwork",
|
||||||
|
"vrcInstanceAvatarFound",
|
||||||
|
"vrcAvatarDetail"
|
||||||
|
],
|
||||||
|
"hosts": ["discord.com"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Gathers everything the report needs about one joiner, within a deadline.
|
||||||
|
*
|
||||||
|
* Each fact comes from a different VRCNext path, so they are collected in parallel and each one
|
||||||
|
* degrades to "unknown" on its own timeout rather than holding up the report:
|
||||||
|
*
|
||||||
|
* - Age verification: VRCNext fetches the joiner's profile itself and re-pushes
|
||||||
|
* `vrcCurrentInstance` once it has, so this waits for that push instead of making a request.
|
||||||
|
* - Group membership: `vrcGetGroupsForNetwork` lists the groups the user shows publicly. A member
|
||||||
|
* who hides that membership looks like a non-member; the report says "visible" for that reason.
|
||||||
|
* - Avatar: `vrcGetInstanceAvatars` resolves the avatar id from the profile image through
|
||||||
|
* VRCNext's avatar databases, then `vrcGetAvatarDetail` supplies the performance ranks. That
|
||||||
|
* action also feeds VRCNext's avatar modal, so it is skipped while that modal is open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EventBus, PluginContext } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
|
import type { Settings } from './settings.js';
|
||||||
|
import {
|
||||||
|
profileLoaded,
|
||||||
|
toAvatarPerformance,
|
||||||
|
toCurrentInstance,
|
||||||
|
toInstanceAvatar,
|
||||||
|
toUserGroups,
|
||||||
|
type CurrentInstance,
|
||||||
|
type InstanceUser,
|
||||||
|
} from './vrcnext-data.js';
|
||||||
|
|
||||||
|
export interface Joiner {
|
||||||
|
readonly name: string;
|
||||||
|
/** Empty for accounts VRChat still logs with a legacy id. */
|
||||||
|
readonly userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarFacts {
|
||||||
|
readonly name: string;
|
||||||
|
readonly pc: string;
|
||||||
|
readonly quest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Facts {
|
||||||
|
readonly ageVerified: boolean | undefined;
|
||||||
|
readonly ageStatus: string;
|
||||||
|
readonly platform: string;
|
||||||
|
readonly avatar: AvatarFacts | undefined;
|
||||||
|
/** `undefined` when no group filter is configured. */
|
||||||
|
readonly inGroup: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ctx = PluginContext<Settings>;
|
||||||
|
|
||||||
|
/** Resolves with the first event `pick` accepts, or `undefined` on timeout or abort. */
|
||||||
|
function waitFor<T>(
|
||||||
|
events: EventBus,
|
||||||
|
type: string,
|
||||||
|
pick: (payload: unknown) => T | undefined,
|
||||||
|
options: { readonly ms: number; readonly signal: AbortSignal },
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const finish = (value: T | undefined): void => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
unsubscribe();
|
||||||
|
options.signal.removeEventListener('abort', onAbort);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
const onAbort = (): void => { finish(undefined); };
|
||||||
|
const timer = setTimeout(() => { finish(undefined); }, options.ms);
|
||||||
|
const unsubscribe = events.on(type, (payload) => {
|
||||||
|
const value = pick(payload);
|
||||||
|
if (value !== undefined) finish(value);
|
||||||
|
});
|
||||||
|
options.signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUser(instance: CurrentInstance, joiner: Joiner): InstanceUser | undefined {
|
||||||
|
return instance.users.find((u) =>
|
||||||
|
joiner.userId !== '' ? u.id === joiner.userId : u.displayName === joiner.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarModalOpen(): boolean {
|
||||||
|
const modal = document.getElementById('modalAvatarDetail');
|
||||||
|
return modal !== null && modal.offsetParent !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FactCollector {
|
||||||
|
readonly #ctx: Ctx;
|
||||||
|
#latest: CurrentInstance | undefined;
|
||||||
|
/** Performance ranks by avatar id, so a popular avatar is only fetched once. */
|
||||||
|
readonly #perfCache = new Map<string, AvatarFacts>();
|
||||||
|
|
||||||
|
constructor(ctx: Ctx) {
|
||||||
|
this.#ctx = ctx;
|
||||||
|
ctx.events.on('vrcCurrentInstance', (payload) => {
|
||||||
|
const instance = toCurrentInstance(payload);
|
||||||
|
if (instance !== undefined) this.#latest = instance;
|
||||||
|
});
|
||||||
|
ctx.events.on('vrcAvatarDetail', (payload) => {
|
||||||
|
const perf = toAvatarPerformance(payload);
|
||||||
|
if (perf !== undefined) {
|
||||||
|
this.#perfCache.set(perf.avatarId, { name: perf.name, pc: perf.pc, quest: perf.quest });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get instance(): CurrentInstance | undefined {
|
||||||
|
return this.#latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs every lookup in parallel and returns whatever arrived before the deadline. */
|
||||||
|
async collect(joiner: Joiner, groupId: string, deadlineMs: number): Promise<Facts> {
|
||||||
|
const signal = this.#ctx.signal;
|
||||||
|
const [profile, inGroup, avatar] = await Promise.all([
|
||||||
|
this.#profile(joiner, deadlineMs, signal),
|
||||||
|
groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, groupId, deadlineMs, signal),
|
||||||
|
this.#avatar(joiner, deadlineMs, signal),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
ageVerified: profile === undefined ? undefined : profile.ageVerified,
|
||||||
|
ageStatus: profile?.ageVerificationStatus ?? '',
|
||||||
|
platform: profile?.platform ?? '',
|
||||||
|
avatar,
|
||||||
|
inGroup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async #profile(joiner: Joiner, ms: number, signal: AbortSignal): Promise<InstanceUser | undefined> {
|
||||||
|
const current = this.#latest === undefined ? undefined : findUser(this.#latest, joiner);
|
||||||
|
if (current !== undefined && profileLoaded(current)) return current;
|
||||||
|
const loaded = await waitFor(
|
||||||
|
this.#ctx.events,
|
||||||
|
'vrcCurrentInstance',
|
||||||
|
(payload) => {
|
||||||
|
const instance = toCurrentInstance(payload);
|
||||||
|
const user = instance === undefined ? undefined : findUser(instance, joiner);
|
||||||
|
return user !== undefined && profileLoaded(user) ? user : undefined;
|
||||||
|
},
|
||||||
|
{ ms, signal },
|
||||||
|
);
|
||||||
|
// A profile that never loaded still tells us the platform VRChat logged.
|
||||||
|
return loaded ?? current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #membership(joiner: Joiner, groupId: string, ms: number, signal: AbortSignal): Promise<boolean | undefined> {
|
||||||
|
if (joiner.userId === '') return undefined;
|
||||||
|
const wanted = groupId.toLowerCase();
|
||||||
|
const pending = waitFor(
|
||||||
|
this.#ctx.events,
|
||||||
|
'vrcGroupsForNetwork',
|
||||||
|
(payload) => {
|
||||||
|
const groups = toUserGroups(payload);
|
||||||
|
return groups?.userId === joiner.userId ? groups.groupIds : undefined;
|
||||||
|
},
|
||||||
|
{ ms, signal },
|
||||||
|
);
|
||||||
|
this.#ctx.bridge.send('vrcGetGroupsForNetwork', { userId: joiner.userId });
|
||||||
|
const groupIds = await pending;
|
||||||
|
return groupIds === undefined ? undefined : groupIds.some((id) => id.toLowerCase() === wanted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #avatar(joiner: Joiner, ms: number, signal: AbortSignal): Promise<AvatarFacts | undefined> {
|
||||||
|
if (joiner.userId === '') return undefined;
|
||||||
|
const started = Date.now();
|
||||||
|
const remaining = (): number => Math.max(1_000, ms - (Date.now() - started));
|
||||||
|
|
||||||
|
let avatarId = this.#latest === undefined ? '' : (findUser(this.#latest, joiner)?.avatarId ?? '');
|
||||||
|
let avatarName = '';
|
||||||
|
if (avatarId === '') {
|
||||||
|
const pending = waitFor(
|
||||||
|
this.#ctx.events,
|
||||||
|
'vrcInstanceAvatarFound',
|
||||||
|
(payload) => {
|
||||||
|
const found = toInstanceAvatar(payload);
|
||||||
|
return found?.userId === joiner.userId ? found : undefined;
|
||||||
|
},
|
||||||
|
{ ms: remaining(), signal },
|
||||||
|
);
|
||||||
|
this.#ctx.bridge.send('vrcGetInstanceAvatars', { userIds: [joiner.userId] });
|
||||||
|
const found = await pending;
|
||||||
|
if (found === undefined) return undefined;
|
||||||
|
avatarId = found.avatarId;
|
||||||
|
avatarName = found.avatarName;
|
||||||
|
}
|
||||||
|
if (avatarId === '') return avatarName === '' ? undefined : { name: avatarName, pc: '', quest: '' };
|
||||||
|
|
||||||
|
const cached = this.#perfCache.get(avatarId);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
if (avatarModalOpen()) {
|
||||||
|
this.#ctx.logger.debug('Avatar modal is open; not fetching performance ranks behind it.');
|
||||||
|
return { name: avatarName, pc: '', quest: '' };
|
||||||
|
}
|
||||||
|
const pending = waitFor(
|
||||||
|
this.#ctx.events,
|
||||||
|
'vrcAvatarDetail',
|
||||||
|
(payload) => {
|
||||||
|
const perf = toAvatarPerformance(payload);
|
||||||
|
return perf?.avatarId === avatarId ? perf : undefined;
|
||||||
|
},
|
||||||
|
{ ms: remaining(), signal },
|
||||||
|
);
|
||||||
|
this.#ctx.bridge.send('vrcGetAvatarDetail', { avatarId });
|
||||||
|
const perf = await pending;
|
||||||
|
return perf === undefined
|
||||||
|
? { name: avatarName, pc: '', quest: '' }
|
||||||
|
: { name: perf.name === '' ? avatarName : perf.name, pc: perf.pc, quest: perf.quest };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Decides whether the current instance is one the user wants reports for.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SettingsValues } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
|
import type { CurrentInstance } from './vrcnext-data.js';
|
||||||
|
import type { Settings } from './settings.js';
|
||||||
|
|
||||||
|
export interface InstanceFilter {
|
||||||
|
readonly instanceTypes: readonly string[];
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly worldIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitList(value: string): readonly string[] {
|
||||||
|
return value
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterFrom(values: SettingsValues<Settings>): InstanceFilter {
|
||||||
|
return {
|
||||||
|
instanceTypes: splitList(values.instanceTypes),
|
||||||
|
groupId: values.groupId.trim().toLowerCase(),
|
||||||
|
worldIds: splitList(values.worldIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeMatches(wanted: readonly string[], actual: string): boolean {
|
||||||
|
if (wanted.length === 0) return true;
|
||||||
|
const type = actual.toLowerCase();
|
||||||
|
return wanted.some((w) => w === type || (w === 'group' && type.startsWith('group')));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function instanceMatches(filter: InstanceFilter, instance: CurrentInstance): boolean {
|
||||||
|
if (!typeMatches(filter.instanceTypes, instance.instanceType)) return false;
|
||||||
|
const groupId = filter.groupId.toLowerCase();
|
||||||
|
if (groupId !== '' && instance.groupId.toLowerCase() !== groupId) return false;
|
||||||
|
const worldIds = filter.worldIds.map((w) => w.toLowerCase());
|
||||||
|
if (worldIds.length > 0 && !worldIds.includes(instance.worldId.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line for the status card: what the filter currently restricts to. */
|
||||||
|
export function describeFilter(filter: InstanceFilter): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (filter.instanceTypes.length > 0) parts.push(`types: ${filter.instanceTypes.join(', ')}`);
|
||||||
|
if (filter.groupId !== '') parts.push(`group: ${filter.groupId}`);
|
||||||
|
if (filter.worldIds.length > 0) parts.push(`worlds: ${String(filter.worldIds.length)}`);
|
||||||
|
return parts.length === 0 ? 'every instance' : parts.join(' · ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Who has joined a matching instance before.
|
||||||
|
*
|
||||||
|
* Persisted in a hidden string setting as JSON, because the plugin API offers settings as its
|
||||||
|
* only durable store. Capped so it cannot grow without bound; the oldest entries fall off.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SettingsStore } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
|
import type { Settings } from './settings.js';
|
||||||
|
|
||||||
|
export interface SeenEntry {
|
||||||
|
readonly name: string;
|
||||||
|
readonly joins: number;
|
||||||
|
readonly firstSeen: number;
|
||||||
|
readonly lastSeen: number;
|
||||||
|
readonly lastLocation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ENTRIES = 2000;
|
||||||
|
|
||||||
|
type SeenMap = Record<string, SeenEntry>;
|
||||||
|
|
||||||
|
function parse(raw: string): SeenMap {
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(raw);
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {};
|
||||||
|
const out: SeenMap = {};
|
||||||
|
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (typeof entry !== 'object' || entry === null) continue;
|
||||||
|
const e = entry as Record<string, unknown>;
|
||||||
|
out[key] = {
|
||||||
|
name: typeof e['name'] === 'string' ? e['name'] : '',
|
||||||
|
joins: typeof e['joins'] === 'number' ? e['joins'] : 1,
|
||||||
|
firstSeen: typeof e['firstSeen'] === 'number' ? e['firstSeen'] : 0,
|
||||||
|
lastSeen: typeof e['lastSeen'] === 'number' ? e['lastSeen'] : 0,
|
||||||
|
lastLocation: typeof e['lastLocation'] === 'string' ? e['lastLocation'] : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimmed(map: SeenMap): SeenMap {
|
||||||
|
const entries = Object.entries(map);
|
||||||
|
if (entries.length <= MAX_ENTRIES) return map;
|
||||||
|
entries.sort((a, b) => b[1].lastSeen - a[1].lastSeen);
|
||||||
|
return Object.fromEntries(entries.slice(0, MAX_ENTRIES));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JoinMemory {
|
||||||
|
readonly #store: SettingsStore<Settings>;
|
||||||
|
#map: SeenMap;
|
||||||
|
|
||||||
|
constructor(store: SettingsStore<Settings>) {
|
||||||
|
this.#store = store;
|
||||||
|
this.#map = parse(store.get('memory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return Object.keys(this.#map).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entry as it was before this join, or `undefined` for a first-timer. */
|
||||||
|
previous(key: string): SeenEntry | undefined {
|
||||||
|
return this.#map[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a join and persists. Returns the entry as it was before. */
|
||||||
|
async record(key: string, name: string, location: string): Promise<SeenEntry | undefined> {
|
||||||
|
const before = this.#map[key];
|
||||||
|
const now = Date.now();
|
||||||
|
this.#map[key] = {
|
||||||
|
name,
|
||||||
|
joins: (before?.joins ?? 0) + 1,
|
||||||
|
firstSeen: before?.firstSeen ?? now,
|
||||||
|
lastSeen: now,
|
||||||
|
lastLocation: location,
|
||||||
|
};
|
||||||
|
this.#map = trimmed(this.#map);
|
||||||
|
await this.#store.set('memory', JSON.stringify(this.#map));
|
||||||
|
return before;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
this.#map = {};
|
||||||
|
await this.#store.set('memory', '{}');
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Formats a report and fans it out to every enabled channel.
|
||||||
|
*
|
||||||
|
* Channels:
|
||||||
|
* - In-app toast: always available.
|
||||||
|
* - Desktop and VR: on any platform through the VRCNext Bridge, whose targets are addressed by
|
||||||
|
* name; on Windows when the bridge delivers nothing, VRCNext's own tray toast plus SteamVR wrist overlay,
|
||||||
|
* which is a single action that reaches both.
|
||||||
|
* - Discord: a webhook POST through `ctx.http.fetch`. Only `discord.com` is in the manifest's
|
||||||
|
* `hosts`, so that is the only webhook host accepted here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginContext, SettingsValues } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
|
import type { Facts, Joiner } from './collector.js';
|
||||||
|
import type { SeenEntry } from './memory.js';
|
||||||
|
import type { Settings } from './settings.js';
|
||||||
|
import type { CurrentInstance } from './vrcnext-data.js';
|
||||||
|
|
||||||
|
export interface Report {
|
||||||
|
readonly at: number;
|
||||||
|
readonly joiner: Joiner;
|
||||||
|
readonly instance: CurrentInstance;
|
||||||
|
readonly facts: Facts;
|
||||||
|
/** Whether a group filter was active, so the "In Group" line is only shown when it means something. */
|
||||||
|
readonly groupFilter: string;
|
||||||
|
readonly previous: SeenEntry | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ctx = PluginContext<Settings>;
|
||||||
|
|
||||||
|
/** Matches the host declared in plugin.json; `ptb.`/`canary.` would need their own entries. */
|
||||||
|
const DISCORD_WEBHOOK = /^https:\/\/discord\.com\/api\/webhooks\/\d+\/[\w-]+$/;
|
||||||
|
|
||||||
|
function yesNo(value: boolean | undefined, unknown = 'Unknown'): string {
|
||||||
|
if (value === undefined) return unknown;
|
||||||
|
return value ? 'Yes' : 'No';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rank(value: string): string {
|
||||||
|
return value === '' ? 'Unknown' : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejoinText(previous: SeenEntry | undefined): string {
|
||||||
|
if (previous === undefined) return 'No (first time)';
|
||||||
|
const when = new Date(previous.lastSeen).toLocaleString();
|
||||||
|
return `Yes (${String(previous.joins)}× before, last ${when})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report as the spec lays it out, one fact per line. */
|
||||||
|
export function reportLines(report: Report): readonly string[] {
|
||||||
|
const { facts } = report;
|
||||||
|
const age = facts.ageVerified === undefined
|
||||||
|
? 'Unknown'
|
||||||
|
: `${yesNo(facts.ageVerified)}${facts.ageStatus === '' ? '' : ` (${facts.ageStatus})`}`;
|
||||||
|
const lines = [
|
||||||
|
`Player "${report.joiner.name}" joined`,
|
||||||
|
`18+ Verified: ${age}`,
|
||||||
|
`Avatar PC Performance Rank: ${rank(facts.avatar?.pc ?? '')}`,
|
||||||
|
`Avatar Quest Performance Rank: ${rank(facts.avatar?.quest ?? '')}`,
|
||||||
|
];
|
||||||
|
if (report.groupFilter !== '') lines.push(`In Group: ${yesNo(facts.inGroup, 'Unknown (only visible memberships count)')}`);
|
||||||
|
lines.push(`Rejoin?: ${rejoinText(report.previous)}`);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything a single-line surface can hold. */
|
||||||
|
export function reportSummary(report: Report): string {
|
||||||
|
const { facts } = report;
|
||||||
|
const bits = [
|
||||||
|
`18+: ${yesNo(facts.ageVerified, '?')}`,
|
||||||
|
`PC ${rank(facts.avatar?.pc ?? '').replace('Unknown', '?')}`,
|
||||||
|
`Quest ${rank(facts.avatar?.quest ?? '').replace('Unknown', '?')}`,
|
||||||
|
];
|
||||||
|
if (report.groupFilter !== '') bits.push(`group: ${yesNo(facts.inGroup, '?')}`);
|
||||||
|
bits.push(report.previous === undefined ? 'new' : 'rejoin');
|
||||||
|
return `${report.joiner.name} joined · ${bits.join(' · ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function accentFor(report: Report): 'ok' | 'warn' | 'info' {
|
||||||
|
const { facts } = report;
|
||||||
|
if (facts.ageVerified === false || facts.avatar?.pc === 'VeryPoor') return 'warn';
|
||||||
|
if (facts.ageVerified === true) return 'ok';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bridge targets: the VR overlay is the one whose name says so; the rest are desktop. */
|
||||||
|
function pickSinks(targets: readonly { readonly name: string }[], wantDesktop: boolean, wantVr: boolean): readonly string[] {
|
||||||
|
return targets
|
||||||
|
.filter((t) => {
|
||||||
|
const vr = /wayvr|openvr|steamvr|overlay|xso/i.test(t.name);
|
||||||
|
return vr ? wantVr : wantDesktop;
|
||||||
|
})
|
||||||
|
.map((t) => t.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Through the bridge: `true` when at least one target accepted. `ok: false` with only the
|
||||||
|
* `bridge` pseudo-sink failing means the bridge is not reachable, which is a normal state, not
|
||||||
|
* an error; a target that refused is logged by name.
|
||||||
|
*/
|
||||||
|
async function sendNative(ctx: Ctx, wants: { readonly desktop: boolean; readonly vr: boolean }, report: Report): Promise<boolean> {
|
||||||
|
let targets: readonly { readonly name: string }[];
|
||||||
|
try {
|
||||||
|
targets = await ctx.native.targets();
|
||||||
|
} catch (error) {
|
||||||
|
ctx.logger.debug(`Bridge targets not available: ${String(error)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sinks = pickSinks(targets, wants.desktop, wants.vr);
|
||||||
|
if (sinks.length === 0) {
|
||||||
|
ctx.logger.debug('The bridge has no target for the enabled channels.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lines = reportLines(report).slice(1);
|
||||||
|
const result = await ctx.native.notify({
|
||||||
|
title: `${report.joiner.name} joined`,
|
||||||
|
content: lines.join('\n'),
|
||||||
|
timeoutSecs: 8,
|
||||||
|
icon: 'security-high',
|
||||||
|
sinks,
|
||||||
|
urgency: accentFor(report) === 'warn' ? 'critical' : 'normal',
|
||||||
|
});
|
||||||
|
for (const failure of result.failed) {
|
||||||
|
if (failure.sink !== 'bridge') ctx.logger.warn(`Bridge target ${failure.sink} failed: ${failure.error}`);
|
||||||
|
}
|
||||||
|
if (!result.ok) ctx.logger.debug('Bridge not reachable or every target refused; falling back.');
|
||||||
|
return result.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDesktopAndVr(ctx: Ctx, values: SettingsValues<Settings>, report: Report): Promise<void> {
|
||||||
|
const wants = { desktop: values.notifyDesktop, vr: values.notifyVr };
|
||||||
|
if (!wants.desktop && !wants.vr) return;
|
||||||
|
if (await sendNative(ctx, wants, report)) return;
|
||||||
|
|
||||||
|
// Windows without the bridge's targets: VRCNext's own tray toast and wrist overlay in one call.
|
||||||
|
if (ctx.notifications.desktopAvailable) {
|
||||||
|
ctx.notifications.desktop({
|
||||||
|
title: `${report.joiner.name} joined`,
|
||||||
|
subtitle: reportLines(report).slice(1).join(' · '),
|
||||||
|
accent: accentFor(report),
|
||||||
|
...(report.joiner.userId === '' ? {} : { friendId: report.joiner.userId }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.logger.info('No desktop or VR channel is reachable: the bridge delivered nothing and this platform has no tray toast.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDiscord(ctx: Ctx, values: SettingsValues<Settings>, report: Report): Promise<void> {
|
||||||
|
if (!values.notifyDiscord) return;
|
||||||
|
const url = values.discordWebhookUrl.trim();
|
||||||
|
if (!DISCORD_WEBHOOK.test(url)) {
|
||||||
|
ctx.logger.warn('Discord webhook is enabled but the URL is not a discord.com webhook URL.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = reportLines(report);
|
||||||
|
const colour = { ok: 0x3ba55d, warn: 0xed4245, info: 0x5865f2 }[accentFor(report)];
|
||||||
|
const body = {
|
||||||
|
username: 'Club Security',
|
||||||
|
embeds: [{
|
||||||
|
title: lines[0],
|
||||||
|
description: lines.slice(1).join('\n'),
|
||||||
|
color: colour,
|
||||||
|
footer: { text: `${report.instance.worldName} · ${report.instance.instanceType}` },
|
||||||
|
timestamp: new Date(report.at).toISOString(),
|
||||||
|
}],
|
||||||
|
allowed_mentions: { parse: [] },
|
||||||
|
};
|
||||||
|
const response = await ctx.http.fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
credentials: 'omit',
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
});
|
||||||
|
if (!response.ok) ctx.logger.warn(`Discord webhook answered ${String(response.status)}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends on every enabled channel. Never throws: a failed channel is logged, not fatal. */
|
||||||
|
export async function notifyAll(ctx: Ctx, report: Report): Promise<void> {
|
||||||
|
const values = ctx.settings.values;
|
||||||
|
if (values.notifyToast) ctx.notifications.toast({ message: reportSummary(report), ok: accentFor(report) !== 'warn' });
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
sendDesktopAndVr(ctx, values, report),
|
||||||
|
sendDiscord(ctx, values, report),
|
||||||
|
]);
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'rejected') ctx.logger.warn(`Notification channel failed: ${String(result.reason)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* The plugin's sidebar tab: status, the last reports, and two maintenance buttons.
|
||||||
|
* Built from `ctx.ui.kit` so it is VRCNext's own markup and follows its theme.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginContext } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
|
import { describeFilter, filterFrom, instanceMatches } from './filters.js';
|
||||||
|
import type { JoinMemory } from './memory.js';
|
||||||
|
import { reportLines, type Report } from './notify.js';
|
||||||
|
import type { Settings } from './settings.js';
|
||||||
|
import type { CurrentInstance } from './vrcnext-data.js';
|
||||||
|
|
||||||
|
type Ctx = PluginContext<Settings>;
|
||||||
|
|
||||||
|
const MAX_SHOWN = 25;
|
||||||
|
|
||||||
|
export interface PanelDeps {
|
||||||
|
readonly memory: JoinMemory;
|
||||||
|
readonly currentInstance: () => CurrentInstance | undefined;
|
||||||
|
readonly sendTest: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportPanel {
|
||||||
|
readonly #ctx: Ctx;
|
||||||
|
readonly #deps: PanelDeps;
|
||||||
|
readonly #reports: Report[] = [];
|
||||||
|
#status: HTMLElement | undefined;
|
||||||
|
#list: HTMLElement | undefined;
|
||||||
|
|
||||||
|
constructor(ctx: Ctx, deps: PanelDeps) {
|
||||||
|
this.#ctx = ctx;
|
||||||
|
this.#deps = deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
install(): void {
|
||||||
|
this.#ctx.ui.addNavTab({
|
||||||
|
label: 'Club Security',
|
||||||
|
icon: 'security',
|
||||||
|
render: (tab) => { this.#render(tab); },
|
||||||
|
});
|
||||||
|
// The host renders every schema setting as a row on this card before `render` runs.
|
||||||
|
this.#ctx.ui.addSettingsCard({
|
||||||
|
title: 'Club Security',
|
||||||
|
icon: 'security',
|
||||||
|
render: (card) => {
|
||||||
|
card.appendChild(this.#ctx.ui.kit.description(
|
||||||
|
'Reports go out for joins in instances that pass every filter above. Test them from the Club Security tab.',
|
||||||
|
));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#ctx.settings.onChange(() => { this.refresh(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
push(report: Report): void {
|
||||||
|
this.#reports.unshift(report);
|
||||||
|
if (this.#reports.length > MAX_SHOWN) this.#reports.length = MAX_SHOWN;
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
const k = this.#ctx.ui.kit;
|
||||||
|
if (this.#status !== undefined) k.setChildren(this.#status, this.#statusRows());
|
||||||
|
if (this.#list !== undefined) k.setChildren(this.#list, this.#reportRows());
|
||||||
|
}
|
||||||
|
|
||||||
|
#render(tab: HTMLElement): void {
|
||||||
|
const k = this.#ctx.ui.kit;
|
||||||
|
this.#status = k.card({ title: 'Status', icon: 'shield' });
|
||||||
|
this.#list = k.card({ title: 'Recent reports', icon: 'history' });
|
||||||
|
const actions = k.card({
|
||||||
|
title: 'Actions',
|
||||||
|
icon: 'build',
|
||||||
|
children: [
|
||||||
|
k.description('A test report uses the current instance and your own name, and goes to every enabled channel.'),
|
||||||
|
k.buttonRow(
|
||||||
|
k.button({ label: 'Send test notification', icon: 'send', onClick: () => { void this.#deps.sendTest(); } }),
|
||||||
|
k.button({
|
||||||
|
label: 'Forget who joined before',
|
||||||
|
icon: 'delete_sweep',
|
||||||
|
onClick: () => { void this.#clearMemory(); },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
tab.append(k.layout(k.pair(this.#status, actions), this.#list));
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusRows(): readonly (HTMLElement | DocumentFragment)[] {
|
||||||
|
const k = this.#ctx.ui.kit;
|
||||||
|
const values = this.#ctx.settings.values;
|
||||||
|
const filter = filterFrom(values);
|
||||||
|
const instance = this.#deps.currentInstance();
|
||||||
|
const matches = instance !== undefined && instanceMatches(filter, instance);
|
||||||
|
const channels = [
|
||||||
|
values.notifyToast && 'toast',
|
||||||
|
values.notifyDesktop && 'desktop',
|
||||||
|
values.notifyVr && 'VR',
|
||||||
|
values.notifyDiscord && 'Discord',
|
||||||
|
].filter((c): c is string => typeof c === 'string');
|
||||||
|
return [
|
||||||
|
k.row({ label: 'Reporting', value: values.enabled ? k.badge('ok', 'On') : k.badge('neutral', 'Off') }),
|
||||||
|
k.row({ label: 'Watching', detail: describeFilter(filter) }),
|
||||||
|
k.row({
|
||||||
|
label: 'Current instance',
|
||||||
|
detail: instance === undefined ? 'Not in an instance' : `${instance.worldName} · ${instance.instanceType}`,
|
||||||
|
value: instance === undefined ? undefined : k.badge(matches ? 'ok' : 'neutral', matches ? 'Matches' : 'Filtered out'),
|
||||||
|
}),
|
||||||
|
k.row({ label: 'Channels', detail: channels.length === 0 ? 'none enabled' : channels.join(', ') }),
|
||||||
|
k.row({ label: 'Players remembered', value: k.valueText(String(this.#deps.memory.size)) }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#reportRows(): readonly (HTMLElement | DocumentFragment)[] {
|
||||||
|
const k = this.#ctx.ui.kit;
|
||||||
|
if (this.#reports.length === 0) return [k.emptyState('No joins reported yet.')];
|
||||||
|
return this.#reports.map((report) => {
|
||||||
|
const lines = reportLines(report);
|
||||||
|
const time = new Date(report.at).toLocaleTimeString();
|
||||||
|
return k.row({
|
||||||
|
label: `${time} · ${report.joiner.name}`,
|
||||||
|
detail: lines.slice(1).join(' · '),
|
||||||
|
value: report.previous === undefined ? k.badge('accent', 'New') : k.badge('warn', 'Rejoin'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #clearMemory(): Promise<void> {
|
||||||
|
const ok = await this.#ctx.notifications.confirm({
|
||||||
|
title: 'Forget join history',
|
||||||
|
message: 'Every player will count as a first-time joiner again. Continue?',
|
||||||
|
confirmLabel: 'Forget',
|
||||||
|
icon: 'delete_sweep',
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
await this.#deps.memory.clear();
|
||||||
|
this.#ctx.notifications.toast({ message: 'Join history cleared.' });
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Settings schema. The host renders these as rows on the plugin's settings card and persists
|
||||||
|
* them; the plugin reads them through `ctx.settings`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SettingsSchema } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
|
/** Instance types as VRCNext's `ParseLocation` names them, plus a shorthand for every group type. */
|
||||||
|
export const INSTANCE_TYPES = [
|
||||||
|
'public',
|
||||||
|
'friends+',
|
||||||
|
'friends',
|
||||||
|
'hidden',
|
||||||
|
'private',
|
||||||
|
'invite_plus',
|
||||||
|
'group',
|
||||||
|
'group-public',
|
||||||
|
'group-plus',
|
||||||
|
'group-members',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const settings = {
|
||||||
|
enabled: {
|
||||||
|
kind: 'boolean',
|
||||||
|
label: 'Report joins',
|
||||||
|
description: 'Master switch. Filters below decide which instances count.',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
instanceTypes: {
|
||||||
|
kind: 'string',
|
||||||
|
label: 'Only in these instance types',
|
||||||
|
description:
|
||||||
|
'Comma-separated. Leave empty for every instance. Types: public, friends+, friends, hidden, ' +
|
||||||
|
'private, invite_plus, group-public, group-plus, group-members, or just "group" for all group instances.',
|
||||||
|
default: '',
|
||||||
|
placeholder: 'group-public, group-plus',
|
||||||
|
},
|
||||||
|
groupId: {
|
||||||
|
kind: 'string',
|
||||||
|
label: 'Only in instances of this group',
|
||||||
|
description: 'A grp_… id. Leave empty for any group. When set, the report also says whether the joiner is a member.',
|
||||||
|
default: '',
|
||||||
|
placeholder: 'grp_00000000-0000-0000-0000-000000000000',
|
||||||
|
},
|
||||||
|
worldIds: {
|
||||||
|
kind: 'string',
|
||||||
|
label: 'Only in these worlds',
|
||||||
|
description: 'Comma-separated wrld_… ids. Leave empty for any world.',
|
||||||
|
default: '',
|
||||||
|
placeholder: 'wrld_…, wrld_…',
|
||||||
|
},
|
||||||
|
settleSecs: {
|
||||||
|
kind: 'number',
|
||||||
|
label: 'Seconds to ignore after you join',
|
||||||
|
description:
|
||||||
|
'When you enter an instance, VRChat logs a join for everyone already there. Joins inside this window are remembered but not reported.',
|
||||||
|
default: 15,
|
||||||
|
min: 3,
|
||||||
|
max: 120,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
collectTimeoutSecs: {
|
||||||
|
kind: 'number',
|
||||||
|
label: 'Seconds to wait for details',
|
||||||
|
description: 'How long to wait for VRCNext to load the profile and avatar before reporting what is known.',
|
||||||
|
default: 25,
|
||||||
|
min: 5,
|
||||||
|
max: 120,
|
||||||
|
step: 5,
|
||||||
|
},
|
||||||
|
notifyToast: {
|
||||||
|
kind: 'boolean',
|
||||||
|
label: 'In-app toast',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
notifyDesktop: {
|
||||||
|
kind: 'boolean',
|
||||||
|
label: 'Desktop notification',
|
||||||
|
description: 'Through the VRCNext Bridge on any platform, or VRCNext’s tray toast on Windows.',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
notifyVr: {
|
||||||
|
kind: 'boolean',
|
||||||
|
label: 'VR overlay notification',
|
||||||
|
description: 'Through the VRCNext Bridge’s VR target on any platform, or the SteamVR wrist overlay on Windows.',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
notifyDiscord: {
|
||||||
|
kind: 'boolean',
|
||||||
|
label: 'Discord webhook',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
discordWebhookUrl: {
|
||||||
|
kind: 'string',
|
||||||
|
label: 'Discord webhook URL',
|
||||||
|
description: 'Server Settings → Integrations → Webhooks. Sent straight from this page; the URL is stored locally only.',
|
||||||
|
default: '',
|
||||||
|
placeholder: 'https://discord.com/api/webhooks/…',
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
kind: 'string',
|
||||||
|
label: 'Join memory',
|
||||||
|
description: 'Internal: who has joined a matching instance before.',
|
||||||
|
default: '{}',
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
} as const satisfies SettingsSchema;
|
||||||
|
|
||||||
|
export type Settings = typeof settings;
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Narrowers for the VRCNext events this plugin consumes.
|
||||||
|
*
|
||||||
|
* None of these events are in the API's verified map, so every payload arrives as `unknown`.
|
||||||
|
* Shapes were read out of VRCNext 2026.61.2: `InstanceController.PushCurrentInstanceFromCache`,
|
||||||
|
* `GroupsController` (`vrcGetGroupsForNetwork`), `FriendsController` (`vrcGetInstanceAvatars`),
|
||||||
|
* `MessageRouter` (`vrcGetAvatarDetail`) and the `vrcUser` push after login.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface InstanceUser {
|
||||||
|
readonly id: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly ageVerified: boolean;
|
||||||
|
/** VRChat's own status string, e.g. `18+`, `verified`, `hidden`. Empty until the profile loaded. */
|
||||||
|
readonly ageVerificationStatus: string;
|
||||||
|
readonly platform: string;
|
||||||
|
/** Present once VRCNext fetched the profile; an empty list means it has not yet. */
|
||||||
|
readonly tags: readonly string[];
|
||||||
|
readonly lastLogin: string;
|
||||||
|
readonly avatarId: string;
|
||||||
|
readonly avatarName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentInstance {
|
||||||
|
readonly location: string;
|
||||||
|
readonly worldId: string;
|
||||||
|
readonly worldName: string;
|
||||||
|
readonly instanceType: string;
|
||||||
|
/** Parsed from `~group(grp_…)` in the location; empty for non-group instances. */
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly users: readonly InstanceUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rec(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function strings(value: unknown): readonly string[] {
|
||||||
|
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupIdOf(location: string): string {
|
||||||
|
const match = /~group\((grp_[0-9a-f-]+)\)/i.exec(location);
|
||||||
|
return match?.[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInstanceUser(value: unknown): InstanceUser | undefined {
|
||||||
|
const r = rec(value);
|
||||||
|
if (r === undefined) return undefined;
|
||||||
|
const id = str(r['id']);
|
||||||
|
const displayName = str(r['displayName']);
|
||||||
|
if (id === '' && displayName === '') return undefined;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
displayName,
|
||||||
|
ageVerified: r['ageVerified'] === true,
|
||||||
|
ageVerificationStatus: str(r['ageVerificationStatus']),
|
||||||
|
platform: str(r['platform']),
|
||||||
|
tags: strings(r['tags']),
|
||||||
|
lastLogin: str(r['lastLogin']),
|
||||||
|
avatarId: str(r['avatarId']),
|
||||||
|
avatarName: str(r['avatarName']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `undefined` for the `{ empty: true }` and `{ error }` variants. */
|
||||||
|
export function toCurrentInstance(payload: unknown): CurrentInstance | undefined {
|
||||||
|
const r = rec(payload);
|
||||||
|
if (r === undefined) return undefined;
|
||||||
|
const location = str(r['location']);
|
||||||
|
if (location === '') return undefined;
|
||||||
|
const rawUsers = Array.isArray(r['users']) ? r['users'] : [];
|
||||||
|
return {
|
||||||
|
location,
|
||||||
|
worldId: str(r['worldId']),
|
||||||
|
worldName: str(r['worldName']),
|
||||||
|
instanceType: str(r['instanceType']),
|
||||||
|
groupId: groupIdOf(location),
|
||||||
|
users: rawUsers.flatMap((u) => {
|
||||||
|
const user = toInstanceUser(u);
|
||||||
|
return user === undefined ? [] : [user];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VRCNext only fills these once it has fetched the profile from the API. */
|
||||||
|
export function profileLoaded(user: InstanceUser): boolean {
|
||||||
|
return user.ageVerificationStatus !== '' || user.tags.length > 0 || user.lastLogin !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserGroups {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly groupIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toUserGroups(payload: unknown): UserGroups | undefined {
|
||||||
|
const r = rec(payload);
|
||||||
|
if (r === undefined) return undefined;
|
||||||
|
const userId = str(r['userId']);
|
||||||
|
if (userId === '') return undefined;
|
||||||
|
const groups = Array.isArray(r['groups']) ? r['groups'] : [];
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
groupIds: groups.flatMap((g) => {
|
||||||
|
const id = str(rec(g)?.['id']);
|
||||||
|
return id === '' ? [] : [id];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceAvatar {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly avatarId: string;
|
||||||
|
readonly avatarName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toInstanceAvatar(payload: unknown): InstanceAvatar | undefined {
|
||||||
|
const r = rec(payload);
|
||||||
|
if (r === undefined) return undefined;
|
||||||
|
const userId = str(r['userId']);
|
||||||
|
if (userId === '') return undefined;
|
||||||
|
return { userId, avatarId: str(r['avatarId']), avatarName: str(r['avatarName']) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarPerformance {
|
||||||
|
readonly avatarId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly pc: string;
|
||||||
|
readonly quest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAvatarPerformance(payload: unknown): AvatarPerformance | undefined {
|
||||||
|
const r = rec(payload);
|
||||||
|
if (r === undefined) return undefined;
|
||||||
|
const avatarId = str(r['id']);
|
||||||
|
if (avatarId === '') return undefined;
|
||||||
|
return {
|
||||||
|
avatarId,
|
||||||
|
name: str(r['name']),
|
||||||
|
pc: str(r['pcPerf']),
|
||||||
|
quest: str(r['questPerf']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Self {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in account, from the `vrcUser` event VRCNext pushes after login. `undefined` when
|
||||||
|
* the payload names nobody (the logged-out variant), so a stale identity is kept over an empty one.
|
||||||
|
*/
|
||||||
|
export function toSelf(payload: unknown): Self | undefined {
|
||||||
|
const r = rec(payload);
|
||||||
|
if (r === undefined) return undefined;
|
||||||
|
const id = str(r['id']);
|
||||||
|
const name = str(r['displayName']);
|
||||||
|
return id === '' && name === '' ? undefined : { id, name };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["main.ts", "src/**/*.ts"],
|
||||||
|
"references": [{ "path": "../../packages/api" }]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user