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,79 +1,87 @@
|
||||
# Club Security
|
||||
|
||||
Reports who joins your instance. For every join that passes the filters, one report:
|
||||
Reports who joins your instance, and whether they meet your club's rules.
|
||||
|
||||
Everything that varies per club is a **preset**: which instances it watches, what it expects of a
|
||||
joiner, and where its reports go. A join is reported once per enabled preset whose filters match
|
||||
the instance, so one account can run the door for several clubs at once.
|
||||
|
||||
Every report carries a verdict, and it is the worst of the preset's checks:
|
||||
|
||||
| Verdict | Means |
|
||||
| :--- | :--- |
|
||||
| ✅ green | Every requirement was checked and holds. |
|
||||
| ⚠️ orange | Something could not be checked: an unknown avatar rank, a hidden age status, a membership the player does not show publicly. |
|
||||
| ⛔ red | A requirement was checked and does not hold: a VeryPoor avatar under a Medium floor, a verified account that is not 18+, a non-friend where friendship is required. |
|
||||
|
||||
The verdict reaches your templates as `{result}`, `{resultText}`, `{resultEmoji}` and
|
||||
`{resultColor}` — the last one colours the Discord embed's bar.
|
||||
|
||||
## A preset
|
||||
|
||||
| Section | Setting | Meaning |
|
||||
| :--- | :--- | :--- |
|
||||
| Identity | Preset name, Enabled | The name appears in every report as `{preset}`. |
|
||||
| Filters | Instance types, Group, Worlds | One of each kind. Empty means "any". A pickable group and worlds, not pasted ids. |
|
||||
| Requirements | Require 18+, PC / Quest avatar rank at least, Must be a member of, Must be on my friend list | Each one becomes a check with its own verdict. |
|
||||
| Channels | In-app toast, Desktop, VR overlay, Discord | Per preset, so a strict club can post to Discord while a relaxed one only toasts. |
|
||||
| Formats | Report template, VR overlay template, Discord embed | The text report, the plain-text one for VR, and the embed. |
|
||||
|
||||
## The default report
|
||||
|
||||
```
|
||||
Player "Tupper" joined
|
||||
18+ Verified: Yes (18+)
|
||||
Avatar PC Performance Rank: Good
|
||||
Avatar Quest Performance Rank: Poor
|
||||
In Group: Yes
|
||||
Rejoin?: Yes (2 hours ago)
|
||||
✅ Tupper joined · Saturday Night
|
||||
✅ 18+ verified: 18+
|
||||
✅ PC avatar rank: Good
|
||||
✅ Group member: member
|
||||
Avatar: Ava (PC Good · Quest Poor)
|
||||
```
|
||||
|
||||
That is the default **Report template**, editable in the plugin's settings. The first line is
|
||||
the title on desktop, VR and Discord; the rest is the body. A line whose placeholders are all
|
||||
empty is left out, which is why `In Group` only appears when a group filter is set.
|
||||
First line = title, rest = body. A line whose placeholders all came out empty is left out, which
|
||||
is why the rejoin line only appears for someone who has been in that instance before.
|
||||
|
||||
`{name}` is short for `{{ name }}`; the full template language (conditions, filters, `{% if %}`
|
||||
blocks) is documented in the plugin system's API reference. For example:
|
||||
|
||||
```
|
||||
{rejoinEmoji} {name} {{ "is back" if rejoin else "is new" }} {ageVerifiedEmoji}
|
||||
{pcRankEmoji} PC {pcRankText} · {questRankEmoji} Quest {questRankText}
|
||||
{% if inGroup == false %}⚠️ Not a group member{% endif %}
|
||||
```
|
||||
blocks) is in the plugin system's API reference. The VR template is separate because WayVR draws
|
||||
with a single font and shows nothing for emoji.
|
||||
|
||||
| Kind | Variables |
|
||||
| :--- | :--- |
|
||||
| Text | `name` `playerId` `ageVerifiedText` `ageStatus` `pcRankText` `questRankText` `avatar` `platform` `inGroupText` `rejoinText` `rejoinAgo` `rejoinSince` `rejoinAt` `world` `worldId` `instanceType` `instanceId` `location` `groupId` `time` `date` `timestamp` |
|
||||
| Booleans (for conditions; empty when unknown) | `ageVerified` `inGroup` `rejoin`, plus `pcRank` and `questRank` (the raw rank, empty when unknown) |
|
||||
| Emoji | `ageVerifiedEmoji` ✅❌❔ · `pcRankEmoji` / `questRankEmoji` 🟢🔵🟡🟠🔴⚪ · `platformEmoji` 🖥️📱🍎 · `inGroupEmoji` · `rejoinEmoji` 🔁🆕❔ |
|
||||
| Verdict | `result` `resultText` `resultEmoji` `resultColor` `checksText` `checksPlainText` `failedText` `unverifiedText` |
|
||||
| Player | `name` `playerId` `platform` `platformEmoji` `isFriend` `friendText` `ageVerified` `ageVerifiedText` `ageVerifiedEmoji` `ageStatus` |
|
||||
| Avatar | `avatar` `avatarId` `avatarImageUrl` `pcRank` `pcRankText` `pcRankEmoji` `questRank` `questRankText` `questRankEmoji` |
|
||||
| Club | `preset` `inGroup` `inGroupText` `inGroupEmoji` |
|
||||
| History | `rejoin` `rejoinText` `rejoinEmoji` `rejoinAgo` `rejoinSince` `rejoinAt` |
|
||||
| Place and time | `world` `worldId` `instanceType` `instanceId` `location` `time` `date` `timestamp` |
|
||||
|
||||
A template that does not parse is reported in the log and the default is used instead.
|
||||
|
||||
## 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). |
|
||||
Booleans (`ageVerified`, `inGroup`, `rejoin`, `isFriend`) are empty when unknown, so
|
||||
`{{ "yes" if rejoin else "no" }}` and `{% if inGroup == false %}…{% endif %}` both behave. A
|
||||
template that does not parse is reported in the log and the default is used instead.
|
||||
|
||||
## Where each fact comes from
|
||||
|
||||
Everything goes through `ctx.vrchat`, which reads VRCNext's data **without opening its dialogs**.
|
||||
|
||||
| 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 | `getTimelineForUser`: the player's ten most recent timeline events, each with its location. Yes when one of them is this exact instance (same world and instance id) from before this join, with how long ago. | Survives VRCNext and VRChat restarts and reaches back to when VRCNext was installed, but only ten events deep per player. |
|
||||
| 18+ verification | The joiner's profile. `18+` passes, `verified` without `18+` fails, `hidden` or missing is unverified. | Legacy accounts without a `usr_` id cannot be looked up at all. |
|
||||
| Avatar and ranks | The avatar the player wears, resolved through VRCNext's avatar databases, then its performance ranks. | Only works when one of the databases knows the avatar; an unknown rank is unverified, never a failure. |
|
||||
| Group membership | The groups the user shows publicly. | A member who hides the membership is unverified, not a failure. |
|
||||
| Friendship | Your friend list. | — |
|
||||
| Rejoin | VRCNext's timeline: the player's ten most recent events, each with its location. Yes when one of them is this exact instance (same world **and** instance id) from before this join. | Survives restarts and reaches back to when VRCNext was installed, but only ten events deep per player. |
|
||||
|
||||
Everything waits at most the configured number of seconds, then reports what it has, with
|
||||
`Unknown` for the rest.
|
||||
Each lookup runs in parallel and degrades to "unknown" on its own timeout rather than holding up
|
||||
the report.
|
||||
|
||||
## 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" and does not report them.
|
||||
The signed-in account comes from `ctx.vrchat.self()`. 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".
|
||||
|
||||
## 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.
|
||||
Flat, like every plugin: `plugin.json`, `main.ts`, `src/`. The manifest declares `gamelog` (joins
|
||||
and world changes), `vrchat` (the read-only data above), `notifications` (toast, Windows tray
|
||||
toast), `native` (bridge targets) and `network` with `discord.com` as its only host. No VRCNext
|
||||
actions and no raw events: the `vrchat` capability covers everything this plugin reads.
|
||||
@@ -1,40 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
* Watches VRChat's game log for players joining your instance. For every enabled preset whose
|
||||
* filters match the instance, it gathers what VRCNext knows about the joiner through
|
||||
* `ctx.vrchat` (no dialogs open), checks the preset's requirements, and sends one report to the
|
||||
* preset's channels with a green, orange or red verdict.
|
||||
*
|
||||
* 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.
|
||||
* Compare with `plugin.json`: every category used here is declared there.
|
||||
*/
|
||||
|
||||
import { definePlugin, type PluginContext, type PluginId } from '@vrcnext/plugin-api';
|
||||
import { definePlugin, type PluginContext, type PluginId, type VrcInstance } from '@vrcnext/plugin-api';
|
||||
|
||||
import { FactCollector, type Joiner } from './src/collector.js';
|
||||
import { filterFrom, instanceMatches } from './src/filters.js';
|
||||
import { notifyAll, type Report } from './src/notify.js';
|
||||
import { collectFacts, type Joiner } from './src/facts.js';
|
||||
import { matchingPresets } from './src/filters.js';
|
||||
import { notify } from './src/notify.js';
|
||||
import { ReportPanel } from './src/panel.js';
|
||||
import { migrateTemplate, settings } from './src/settings.js';
|
||||
import { toSelf, type Self } from './src/vrcnext-data.js';
|
||||
import { evaluate } from './src/requirements.js';
|
||||
import { type Report } from './src/report.js';
|
||||
import { settings, type Preset } from './src/settings.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';
|
||||
|
||||
/** How often the current instance is re-read while the tab is open, so its status stays honest. */
|
||||
const INSTANCE_REFRESH_MS = 30_000;
|
||||
|
||||
class ClubSecurity {
|
||||
readonly #ctx: Ctx;
|
||||
readonly #collector: FactCollector;
|
||||
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: '' };
|
||||
#instance: VrcInstance | undefined;
|
||||
/** Joiners currently being looked up, so a duplicate log line does not produce two reports. */
|
||||
readonly #inFlight = new Set<string>();
|
||||
/**
|
||||
@@ -45,62 +41,70 @@ class ClubSecurity {
|
||||
|
||||
constructor(ctx: Ctx) {
|
||||
this.#ctx = ctx;
|
||||
this.#collector = new FactCollector(ctx);
|
||||
this.#panel = new ReportPanel(ctx, {
|
||||
currentInstance: () => this.#collector.instance,
|
||||
currentInstance: () => this.#instance,
|
||||
sendTest: () => this.#sendTest(),
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.#ctx.events.on('vrcUser', (payload) => {
|
||||
const self = toSelf(payload);
|
||||
if (self !== undefined) this.#self = self;
|
||||
this.#ctx.gameLog.onType(WORLD_JOIN_EVENT, () => {
|
||||
this.#startSettling();
|
||||
void this.#refreshInstance();
|
||||
});
|
||||
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');
|
||||
void this.#refreshInstance();
|
||||
const timer = setInterval(() => { void this.#refreshInstance(); }, INSTANCE_REFRESH_MS);
|
||||
this.#ctx.disposables.add(() => { clearInterval(timer); });
|
||||
this.#ctx.logger.info(`Club Security v${this.#ctx.version} watching for joins.`);
|
||||
}
|
||||
|
||||
async #refreshInstance(): Promise<void> {
|
||||
try {
|
||||
this.#instance = await this.#ctx.vrchat.currentInstance({ cached: false, signal: this.#ctx.signal });
|
||||
} catch (error) {
|
||||
this.#ctx.logger.debug(`Current instance not available: ${String(error)}`);
|
||||
}
|
||||
this.#panel.refresh();
|
||||
}
|
||||
|
||||
#startSettling(): void {
|
||||
this.#settledAt = Date.now() + this.#ctx.settings.get('settleSecs') * 1000;
|
||||
}
|
||||
|
||||
#isSelf(joiner: Joiner): boolean {
|
||||
const self = this.#ctx.vrchat.self();
|
||||
if (self === undefined) return false;
|
||||
return joiner.userId !== '' ? joiner.userId === self.id : joiner.name === self.displayName;
|
||||
}
|
||||
|
||||
async #onJoin(joiner: Joiner): Promise<void> {
|
||||
const values = this.#ctx.settings.values;
|
||||
if (!values.enabled || joiner.name === '') return;
|
||||
if (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.`);
|
||||
if (Date.now() < this.#settledAt) {
|
||||
// VRCNext records the meeting itself, so nothing is lost by not reporting it.
|
||||
this.#ctx.logger.debug(`${joiner.name} was already here when you joined; not reported.`);
|
||||
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 {
|
||||
if (Date.now() < this.#settledAt) {
|
||||
// VRCNext records the meeting itself, so nothing is lost by not reporting it.
|
||||
this.#ctx.logger.debug(`${joiner.name} was already here when you joined; not reported.`);
|
||||
const instance = this.#instance ?? (await this.#ctx.vrchat.currentInstance({ cached: false, signal: this.#ctx.signal }));
|
||||
if (instance === undefined) {
|
||||
this.#ctx.logger.debug(`${joiner.name} joined but the current instance is not known.`);
|
||||
return;
|
||||
}
|
||||
const facts = await this.#collector.collect(joiner, filter, values.collectTimeoutSecs * 1000);
|
||||
const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId };
|
||||
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);
|
||||
this.#instance = instance;
|
||||
const presets = matchingPresets(this.#ctx.settings.get('presets'), instance);
|
||||
if (presets.length === 0) return;
|
||||
await this.#report(joiner, instance, presets, this.#ctx.settings.get('collectTimeoutSecs') * 1000);
|
||||
} catch (error) {
|
||||
this.#ctx.logger.error(`Report for ${joiner.name} failed: ${String(error)}`);
|
||||
} finally {
|
||||
@@ -108,28 +112,38 @@ class ClubSecurity {
|
||||
}
|
||||
}
|
||||
|
||||
#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;
|
||||
/** One fact collection, then one report per preset. */
|
||||
async #report(joiner: Joiner, instance: VrcInstance, presets: readonly Preset[], deadlineMs: number): Promise<void> {
|
||||
const facts = await collectFacts(this.#ctx.vrchat, joiner, instance, {
|
||||
deadlineMs,
|
||||
signal: this.#ctx.signal,
|
||||
wantsGroups: presets.some((p) => p.requiredGroup !== ''),
|
||||
});
|
||||
for (const preset of presets) {
|
||||
const report: Report = { at: Date.now(), preset, joiner, instance, facts, evaluation: evaluate(preset, facts) };
|
||||
this.#panel.push(report);
|
||||
this.#ctx.logger.info(`${preset.name}: ${joiner.name} — ${report.evaluation.verdict}` +
|
||||
(report.evaluation.checks.length === 0 ? '' : ` (${report.evaluation.checks.map((c) => `${c.label}: ${c.detail}`).join(', ')})`));
|
||||
await notify(this.#ctx, report);
|
||||
}
|
||||
}
|
||||
|
||||
/** A report built from whatever the current instance knows about you, to check the channels. */
|
||||
/** A report built from your own account in the current instance, to check the channels. */
|
||||
async #sendTest(): Promise<void> {
|
||||
const instance = this.#collector.instance;
|
||||
if (instance === undefined) {
|
||||
await this.#refreshInstance();
|
||||
const instance = this.#instance;
|
||||
const self = this.#ctx.vrchat.self();
|
||||
if (instance === undefined || self === 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, 10_000);
|
||||
const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId };
|
||||
await notifyAll(this.#ctx, report);
|
||||
this.#ctx.notifications.toast({ message: 'Test report sent to every enabled channel.' });
|
||||
const presets = matchingPresets(this.#ctx.settings.get('presets'), instance);
|
||||
if (presets.length === 0) {
|
||||
this.#ctx.notifications.toast({ message: 'No enabled preset matches this instance.', ok: false });
|
||||
return;
|
||||
}
|
||||
await this.#report({ name: self.displayName, userId: self.id }, instance, presets, 10_000);
|
||||
this.#ctx.notifications.toast({ message: `Test report sent through ${String(presets.length)} preset(s).` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,9 +152,6 @@ export default definePlugin({
|
||||
settings,
|
||||
|
||||
activate(ctx) {
|
||||
const template = ctx.settings.get('template');
|
||||
const migrated = migrateTemplate(template);
|
||||
if (migrated !== template) void ctx.settings.set('template', migrated);
|
||||
new ClubSecurity(ctx).start();
|
||||
},
|
||||
|
||||
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@vrcnext-examples/club-security",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "Reports who joins your instance: age verification, avatar performance, group membership and rejoins.",
|
||||
"description": "Per-club presets: filter instances, require 18+, avatar ranks, group membership or friendship, and report each join with a verdict.",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@vrcnext/plugin-api": "0.2.0"
|
||||
"@vrcnext/plugin-api": "0.3.0"
|
||||
}
|
||||
}
|
||||
+6
-20
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"id": "club-security",
|
||||
"name": "Club Security",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "^0.2.0",
|
||||
"description": "Reports each join to your instance: 18+ verification, avatar performance ranks, group membership and whether they were here with you before, to toast, desktop, VR or Discord.",
|
||||
"version": "2.0.0",
|
||||
"apiVersion": "^0.3.0",
|
||||
"description": "Per-club presets: filter instances, require 18+, avatar ranks, group membership or friendship, and report each join with a green, orange or red verdict to toast, desktop, VR or Discord.",
|
||||
"author": "vrcnext-plugins",
|
||||
"homepage": "https://github.com/vrcnext-plugins/vrcnext-plugin-system/tree/main/examples/club-security",
|
||||
"tags": [
|
||||
@@ -12,29 +12,15 @@
|
||||
"activity"
|
||||
],
|
||||
"permissions": [
|
||||
"host:events",
|
||||
"host:actions",
|
||||
"gamelog",
|
||||
"vrchat",
|
||||
"notifications",
|
||||
"native",
|
||||
"network"
|
||||
],
|
||||
"optionalPermissions": [],
|
||||
"actions": [
|
||||
"getTimelineForUser",
|
||||
"vrcGetAvatarDetail",
|
||||
"vrcGetCurrentInstance",
|
||||
"vrcGetGroupsForNetwork",
|
||||
"vrcGetInstanceAvatars"
|
||||
],
|
||||
"events": [
|
||||
"timelineForUser",
|
||||
"vrcAvatarDetail",
|
||||
"vrcCurrentInstance",
|
||||
"vrcGroupsForNetwork",
|
||||
"vrcInstanceAvatarFound",
|
||||
"vrcUser"
|
||||
],
|
||||
"actions": [],
|
||||
"events": [],
|
||||
"hosts": [
|
||||
"discord.com"
|
||||
]
|
||||
|
||||
@@ -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