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
|
# 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
|
✅ Tupper joined · Saturday Night
|
||||||
18+ Verified: Yes (18+)
|
✅ 18+ verified: 18+
|
||||||
Avatar PC Performance Rank: Good
|
✅ PC avatar rank: Good
|
||||||
Avatar Quest Performance Rank: Poor
|
✅ Group member: member
|
||||||
In Group: Yes
|
Avatar: Ava (PC Good · Quest Poor)
|
||||||
Rejoin?: Yes (2 hours ago)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
That is the default **Report template**, editable in the plugin's settings. The first line is
|
First line = title, rest = body. A line whose placeholders all came out empty is left out, which
|
||||||
the title on desktop, VR and Discord; the rest is the body. A line whose placeholders are all
|
is why the rejoin line only appears for someone who has been in that instance before.
|
||||||
empty is left out, which is why `In Group` only appears when a group filter is set.
|
|
||||||
|
|
||||||
`{name}` is short for `{{ name }}`; the full template language (conditions, filters, `{% if %}`
|
`{name}` is short for `{{ name }}`; the full template language (conditions, filters, `{% if %}`
|
||||||
blocks) is documented in the plugin system's API reference. For example:
|
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.
|
||||||
```
|
|
||||||
{rejoinEmoji} {name} {{ "is back" if rejoin else "is new" }} {ageVerifiedEmoji}
|
|
||||||
{pcRankEmoji} PC {pcRankText} · {questRankEmoji} Quest {questRankText}
|
|
||||||
{% if inGroup == false %}⚠️ Not a group member{% endif %}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Kind | Variables |
|
| 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` |
|
| Verdict | `result` `resultText` `resultEmoji` `resultColor` `checksText` `checksPlainText` `failedText` `unverifiedText` |
|
||||||
| Booleans (for conditions; empty when unknown) | `ageVerified` `inGroup` `rejoin`, plus `pcRank` and `questRank` (the raw rank, empty when unknown) |
|
| Player | `name` `playerId` `platform` `platformEmoji` `isFriend` `friendText` `ageVerified` `ageVerifiedText` `ageVerifiedEmoji` `ageStatus` |
|
||||||
| Emoji | `ageVerifiedEmoji` ✅❌❔ · `pcRankEmoji` / `questRankEmoji` 🟢🔵🟡🟠🔴⚪ · `platformEmoji` 🖥️📱🍎 · `inGroupEmoji` · `rejoinEmoji` 🔁🆕❔ |
|
| 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.
|
Booleans (`ageVerified`, `inGroup`, `rejoin`, `isFriend`) are empty when unknown, so
|
||||||
|
`{{ "yes" if rejoin else "no" }}` and `{% if inGroup == false %}…{% endif %}` both behave. A
|
||||||
## Filters
|
template that does not parse is reported in the log and the default is used instead.
|
||||||
|
|
||||||
All optional, all in the plugin's settings card. Empty means "any".
|
|
||||||
|
|
||||||
| Setting | Meaning |
|
|
||||||
| :--- | :--- |
|
|
||||||
| Instance types | Comma-separated: `public`, `friends+`, `friends`, `hidden`, `private`, `invite_plus`, `group-public`, `group-plus`, `group-members`, or `group` for every group instance. |
|
|
||||||
| Group | A `grp_…` id. Only instances of that group count, and the report says whether the joiner is a member. |
|
|
||||||
| Worlds | Comma-separated `wrld_…` ids. |
|
|
||||||
|
|
||||||
## Channels
|
|
||||||
|
|
||||||
| Channel | How |
|
|
||||||
| :--- | :--- |
|
|
||||||
| In-app toast | Always available. One line. |
|
|
||||||
| Desktop | A VRCNext Bridge desktop target on any platform; VRCNext's tray toast on Windows when the bridge delivers nothing. |
|
|
||||||
| VR | A VRCNext Bridge VR target (WayVR and similar) on any platform; SteamVR wrist overlay on Windows when the bridge delivers nothing. |
|
|
||||||
| Discord | A `discord.com` webhook URL, posted as an embed through `ctx.http.fetch` (`discord.com` is the plugin's only declared host). |
|
|
||||||
|
|
||||||
## Where each fact comes from
|
## Where each fact comes from
|
||||||
|
|
||||||
|
Everything goes through `ctx.vrchat`, which reads VRCNext's data **without opening its dialogs**.
|
||||||
|
|
||||||
| Fact | Source | Caveat |
|
| 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. |
|
| 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 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. |
|
| 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. |
|
||||||
| In group | `vrcGetGroupsForNetwork`, the joiner's publicly visible groups. | A member who hides the membership shows as "No". |
|
| Group membership | The groups the user shows publicly. | A member who hides the membership is unverified, not a failure. |
|
||||||
| 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. |
|
| 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
|
Each lookup runs in parallel and degrades to "unknown" on its own timeout rather than holding up
|
||||||
`Unknown` for the rest.
|
the report.
|
||||||
|
|
||||||
## Your own joins
|
## Your own joins
|
||||||
|
|
||||||
The signed-in account comes from the `vrcUser` event VRCNext pushes after login; the plugin
|
The signed-in account comes from `ctx.vrchat.self()`. VRChat logs an `OnPlayerJoined` line for the
|
||||||
reads no page globals. VRChat logs an `OnPlayerJoined` line for the local player too, and right after it one line for
|
local player too, and right after it one line for every player already in the instance. The plugin
|
||||||
every player already in the instance. The plugin ignores your own line and treats joins inside
|
ignores your own line and treats joins inside the settle window after it (or after a world change)
|
||||||
the settle window after it (or after a world change) as "already here" and does not report them.
|
as "already here".
|
||||||
|
|
||||||
## Layout and permissions
|
## Layout and permissions
|
||||||
|
|
||||||
Flat, like every plugin: `plugin.json`, `main.ts`, `src/`. The manifest declares `host:events`
|
Flat, like every plugin: `plugin.json`, `main.ts`, `src/`. The manifest declares `gamelog` (joins
|
||||||
(the five `vrc…` events above and `vrcUser`), `host:actions` (the four `vrcGet…` lookups),
|
and world changes), `vrchat` (the read-only data above), `notifications` (toast, Windows tray
|
||||||
`gamelog` (joins and world changes), `notifications` (toast, confirm, Windows tray toast),
|
toast), `native` (bridge targets) and `network` with `discord.com` as its only host. No VRCNext
|
||||||
`native` (bridge targets) and `network` with `discord.com` as its only host. Nothing is optional:
|
actions and no raw events: the `vrchat` capability covers everything this plugin reads.
|
||||||
every channel is a plain setting, and a category that is off in the settings is simply never called.
|
|
||||||
@@ -1,40 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* Club Security.
|
* Club Security.
|
||||||
*
|
*
|
||||||
* Watches VRChat's game log for players joining your instance. When the instance passes the
|
* Watches VRChat's game log for players joining your instance. For every enabled preset whose
|
||||||
* configured filters (type, group, world), it gathers what VRCNext knows about the joiner and
|
* filters match the instance, it gathers what VRCNext knows about the joiner through
|
||||||
* sends one report per join to the enabled channels.
|
* `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
|
* Compare with `plugin.json`: every category used here is declared there.
|
||||||
* and event names are listed so they are granted when the plugin is enabled.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { definePlugin, type PluginContext, type PluginId } from '@vrcnext/plugin-api';
|
import { definePlugin, type PluginContext, type PluginId, type VrcInstance } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
import { FactCollector, type Joiner } from './src/collector.js';
|
import { collectFacts, type Joiner } from './src/facts.js';
|
||||||
import { filterFrom, instanceMatches } from './src/filters.js';
|
import { matchingPresets } from './src/filters.js';
|
||||||
import { notifyAll, type Report } from './src/notify.js';
|
import { notify } from './src/notify.js';
|
||||||
import { ReportPanel } from './src/panel.js';
|
import { ReportPanel } from './src/panel.js';
|
||||||
import { migrateTemplate, settings } from './src/settings.js';
|
import { evaluate } from './src/requirements.js';
|
||||||
import { toSelf, type Self } from './src/vrcnext-data.js';
|
import { type Report } from './src/report.js';
|
||||||
|
import { settings, type Preset } from './src/settings.js';
|
||||||
|
|
||||||
type Ctx = PluginContext<typeof settings>;
|
type Ctx = PluginContext<typeof settings>;
|
||||||
|
|
||||||
/** VRCNext's parsed game-log kinds for `[Behaviour] OnPlayerJoined` and `Joining wrld_…`. */
|
/** VRCNext's parsed game-log kinds for `[Behaviour] OnPlayerJoined` and `Joining wrld_…`. */
|
||||||
const JOIN_EVENT = 'gl_player_join';
|
const JOIN_EVENT = 'gl_player_join';
|
||||||
const WORLD_JOIN_EVENT = 'gl_world_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 {
|
class ClubSecurity {
|
||||||
readonly #ctx: Ctx;
|
readonly #ctx: Ctx;
|
||||||
readonly #collector: FactCollector;
|
|
||||||
readonly #panel: ReportPanel;
|
readonly #panel: ReportPanel;
|
||||||
/**
|
#instance: VrcInstance | undefined;
|
||||||
* The signed-in account, from the `vrcUser` event VRCNext pushes after login and whenever the
|
|
||||||
* profile is re-rendered. Empty until the first push: before that, only the settle window
|
|
||||||
* keeps the local player's own `OnPlayerJoined` line from being reported.
|
|
||||||
*/
|
|
||||||
#self: Self = { id: '', name: '' };
|
|
||||||
/** Joiners currently being looked up, so a duplicate log line does not produce two reports. */
|
/** Joiners currently being looked up, so a duplicate log line does not produce two reports. */
|
||||||
readonly #inFlight = new Set<string>();
|
readonly #inFlight = new Set<string>();
|
||||||
/**
|
/**
|
||||||
@@ -45,62 +41,70 @@ class ClubSecurity {
|
|||||||
|
|
||||||
constructor(ctx: Ctx) {
|
constructor(ctx: Ctx) {
|
||||||
this.#ctx = ctx;
|
this.#ctx = ctx;
|
||||||
this.#collector = new FactCollector(ctx);
|
|
||||||
this.#panel = new ReportPanel(ctx, {
|
this.#panel = new ReportPanel(ctx, {
|
||||||
currentInstance: () => this.#collector.instance,
|
currentInstance: () => this.#instance,
|
||||||
sendTest: () => this.#sendTest(),
|
sendTest: () => this.#sendTest(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
this.#ctx.events.on('vrcUser', (payload) => {
|
this.#ctx.gameLog.onType(WORLD_JOIN_EVENT, () => {
|
||||||
const self = toSelf(payload);
|
this.#startSettling();
|
||||||
if (self !== undefined) this.#self = self;
|
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) => {
|
this.#ctx.gameLog.onType(JOIN_EVENT, (entry) => {
|
||||||
void this.#onJoin({ name: entry.message, userId: entry.detail });
|
void this.#onJoin({ name: entry.message, userId: entry.detail });
|
||||||
});
|
});
|
||||||
this.#panel.install();
|
this.#panel.install();
|
||||||
// Ask for the instance we are already in, so the first join after enabling is not missed.
|
void this.#refreshInstance();
|
||||||
this.#ctx.bridge.send('vrcGetCurrentInstance');
|
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.`);
|
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 {
|
#startSettling(): void {
|
||||||
this.#settledAt = Date.now() + this.#ctx.settings.get('settleSecs') * 1000;
|
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> {
|
async #onJoin(joiner: Joiner): Promise<void> {
|
||||||
const values = this.#ctx.settings.values;
|
if (joiner.name === '') return;
|
||||||
if (!values.enabled || joiner.name === '') return;
|
|
||||||
if (this.#isSelf(joiner)) {
|
if (this.#isSelf(joiner)) {
|
||||||
this.#startSettling();
|
this.#startSettling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const instance = this.#collector.instance;
|
|
||||||
if (instance === undefined) {
|
|
||||||
this.#ctx.logger.debug(`${joiner.name} joined but the current instance is not known yet.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const filter = filterFrom(values);
|
|
||||||
if (!instanceMatches(filter, instance)) return;
|
|
||||||
|
|
||||||
const key = joiner.userId === '' ? `name:${joiner.name}` : joiner.userId;
|
|
||||||
if (this.#inFlight.has(key)) return;
|
|
||||||
this.#inFlight.add(key);
|
|
||||||
try {
|
|
||||||
if (Date.now() < this.#settledAt) {
|
if (Date.now() < this.#settledAt) {
|
||||||
// VRCNext records the meeting itself, so nothing is lost by not reporting it.
|
// 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.`);
|
this.#ctx.logger.debug(`${joiner.name} was already here when you joined; not reported.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const facts = await this.#collector.collect(joiner, filter, values.collectTimeoutSecs * 1000);
|
const key = joiner.userId === '' ? `name:${joiner.name}` : joiner.userId;
|
||||||
const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId };
|
if (this.#inFlight.has(key)) return;
|
||||||
this.#panel.push(report);
|
this.#inFlight.add(key);
|
||||||
this.#ctx.logger.info(`Reported ${joiner.name}: 18+ ${String(facts.ageVerified ?? '?')}, PC ${facts.avatar?.pc ?? '?'}, Quest ${facts.avatar?.quest ?? '?'}.`);
|
try {
|
||||||
await notifyAll(this.#ctx, report);
|
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;
|
||||||
|
}
|
||||||
|
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) {
|
} catch (error) {
|
||||||
this.#ctx.logger.error(`Report for ${joiner.name} failed: ${String(error)}`);
|
this.#ctx.logger.error(`Report for ${joiner.name} failed: ${String(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,28 +112,38 @@ class ClubSecurity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#isSelf(joiner: Joiner): boolean {
|
/** One fact collection, then one report per preset. */
|
||||||
const self = this.#self;
|
async #report(joiner: Joiner, instance: VrcInstance, presets: readonly Preset[], deadlineMs: number): Promise<void> {
|
||||||
if (joiner.userId !== '' && self.id !== '') return joiner.userId === self.id;
|
const facts = await collectFacts(this.#ctx.vrchat, joiner, instance, {
|
||||||
return self.name !== '' && joiner.name === self.name;
|
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> {
|
async #sendTest(): Promise<void> {
|
||||||
const instance = this.#collector.instance;
|
await this.#refreshInstance();
|
||||||
if (instance === undefined) {
|
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 });
|
this.#ctx.notifications.toast({ message: 'Join an instance first; the test uses it.', ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const self = this.#self;
|
const presets = matchingPresets(this.#ctx.settings.get('presets'), instance);
|
||||||
const me = instance.users.find((u) => u.id === self.id) ?? instance.users[0];
|
if (presets.length === 0) {
|
||||||
const name = me?.displayName ?? self.name;
|
this.#ctx.notifications.toast({ message: 'No enabled preset matches this instance.', ok: false });
|
||||||
const joiner: Joiner = { name: name === '' ? 'Test player' : name, userId: me?.id ?? '' };
|
return;
|
||||||
const filter = filterFrom(this.#ctx.settings.values);
|
}
|
||||||
const facts = await this.#collector.collect(joiner, filter, 10_000);
|
await this.#report({ name: self.displayName, userId: self.id }, instance, presets, 10_000);
|
||||||
const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId };
|
this.#ctx.notifications.toast({ message: `Test report sent through ${String(presets.length)} preset(s).` });
|
||||||
await notifyAll(this.#ctx, report);
|
|
||||||
this.#ctx.notifications.toast({ message: 'Test report sent to every enabled channel.' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +152,6 @@ export default definePlugin({
|
|||||||
settings,
|
settings,
|
||||||
|
|
||||||
activate(ctx) {
|
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();
|
new ClubSecurity(ctx).start();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@vrcnext-examples/club-security",
|
"name": "@vrcnext-examples/club-security",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"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",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vrcnext/plugin-api": "0.2.0"
|
"@vrcnext/plugin-api": "0.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-20
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "club-security",
|
"id": "club-security",
|
||||||
"name": "Club Security",
|
"name": "Club Security",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"apiVersion": "^0.2.0",
|
"apiVersion": "^0.3.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.",
|
"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",
|
"author": "vrcnext-plugins",
|
||||||
"homepage": "https://github.com/vrcnext-plugins/vrcnext-plugin-system/tree/main/examples/club-security",
|
"homepage": "https://github.com/vrcnext-plugins/vrcnext-plugin-system/tree/main/examples/club-security",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12,29 +12,15 @@
|
|||||||
"activity"
|
"activity"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"host:events",
|
|
||||||
"host:actions",
|
|
||||||
"gamelog",
|
"gamelog",
|
||||||
|
"vrchat",
|
||||||
"notifications",
|
"notifications",
|
||||||
"native",
|
"native",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"optionalPermissions": [],
|
"optionalPermissions": [],
|
||||||
"actions": [
|
"actions": [],
|
||||||
"getTimelineForUser",
|
"events": [],
|
||||||
"vrcGetAvatarDetail",
|
|
||||||
"vrcGetCurrentInstance",
|
|
||||||
"vrcGetGroupsForNetwork",
|
|
||||||
"vrcGetInstanceAvatars"
|
|
||||||
],
|
|
||||||
"events": [
|
|
||||||
"timelineForUser",
|
|
||||||
"vrcAvatarDetail",
|
|
||||||
"vrcCurrentInstance",
|
|
||||||
"vrcGroupsForNetwork",
|
|
||||||
"vrcInstanceAvatarFound",
|
|
||||||
"vrcUser"
|
|
||||||
],
|
|
||||||
"hosts": [
|
"hosts": [
|
||||||
"discord.com"
|
"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 { Preset } from './settings.js';
|
||||||
import type { Settings } from './settings.js';
|
|
||||||
|
|
||||||
/** The three things a filter looks at; both a live instance and a past location provide them. */
|
/** What a filter looks at. */
|
||||||
export interface InstanceShape {
|
export type InstanceShape = Pick<VrcInstance, 'worldId' | 'instanceType' | 'groupId'>;
|
||||||
readonly worldId: string;
|
|
||||||
readonly instanceType: string;
|
|
||||||
readonly groupId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export function presetMatches(preset: Preset, instance: InstanceShape): boolean {
|
||||||
* VRCNext's `ParseLocation`, ported: the instance type a location string encodes. Needed for
|
if (preset.instanceTypes.length > 0 && !preset.instanceTypes.includes(instance.instanceType as (typeof preset.instanceTypes)[number])) return false;
|
||||||
* past locations from the timeline, which carry no parsed type.
|
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;
|
||||||
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;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One line for the status card: what the filter currently restricts to. */
|
/** The enabled presets that match, in order. */
|
||||||
export function describeFilter(filter: InstanceFilter): string {
|
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[] = [];
|
const parts: string[] = [];
|
||||||
if (filter.instanceTypes.length > 0) parts.push(`types: ${filter.instanceTypes.join(', ')}`);
|
if (preset.instanceTypes.length > 0) parts.push(`types: ${preset.instanceTypes.join(', ')}`);
|
||||||
if (filter.groupId !== '') parts.push(`group: ${filter.groupId}`);
|
if (preset.group !== '') parts.push('one group');
|
||||||
if (filter.worldIds.length > 0) parts.push(`worlds: ${String(filter.worldIds.length)}`);
|
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(' · ');
|
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.
|
* 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
|
* `ctx.vrchat.userTimeline` returns the ten most recent timeline events involving the player,
|
||||||
* a timestamp and the location it happened in. VRCNext keeps those in SQLite, so they survive
|
* each with a timestamp and the location it happened in. VRCNext keeps those in SQLite, so they
|
||||||
* VRCNext and VRChat restarts and reach back to when VRCNext was installed. The check is strict on
|
* survive restarts and reach back to when VRCNext was installed. The check is strict on purpose:
|
||||||
* purpose: same world and same instance id, not "met somewhere" and not "an instance like this".
|
* same world and same instance id, not "met somewhere".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface TimelineEntry {
|
import { parseLocation, type VrcTimelineEvent } from '@vrcnext/plugin-api';
|
||||||
readonly timestamp: string;
|
|
||||||
readonly location: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Rejoin {
|
export interface Rejoin {
|
||||||
/** `undefined` when VRCNext did not answer in time. */
|
/** `undefined` when VRCNext did not answer in time. */
|
||||||
@@ -21,44 +18,21 @@ export interface Rejoin {
|
|||||||
|
|
||||||
export const UNKNOWN_REJOIN: Rejoin = { seenHere: undefined, lastAt: undefined };
|
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
|
* 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(
|
export function rejoinIn(
|
||||||
entries: readonly TimelineEntry[] | undefined,
|
events: readonly Pick<VrcTimelineEvent, 'timestamp' | 'location'>[] | undefined,
|
||||||
location: string,
|
location: string,
|
||||||
joinedAt: number,
|
joinedAt: number,
|
||||||
): Rejoin {
|
): Rejoin {
|
||||||
if (entries === undefined) return UNKNOWN_REJOIN;
|
if (events === undefined) return UNKNOWN_REJOIN;
|
||||||
const key = instanceKey(location);
|
const key = parseLocation(location).key;
|
||||||
if (key === '') return { seenHere: false, lastAt: undefined };
|
if (key === '') return { seenHere: false, lastAt: undefined };
|
||||||
const earlier = entries
|
const earlier = events
|
||||||
.filter((e) => instanceKey(e.location) === key)
|
.filter((e) => parseLocation(e.location).key === key)
|
||||||
.filter((e) => {
|
.filter((e) => {
|
||||||
const at = Date.parse(e.timestamp);
|
const at = Date.parse(e.timestamp);
|
||||||
return Number.isFinite(at) && at < joinedAt - 5_000;
|
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, one line.
|
||||||
* - In-app toast: always available.
|
* - Desktop and VR: through the VRCNext Bridge's targets, addressed by name; VR overlays get the
|
||||||
* - Desktop and VR: on any platform through the VRCNext Bridge, whose targets are addressed by
|
* preset's VR template (WayVR draws with one font and shows nothing for emoji). On Windows,
|
||||||
* name; on Windows when the bridge delivers nothing, VRCNext's own tray toast plus SteamVR wrist overlay,
|
* when the bridge delivers nothing, VRCNext's own tray toast and wrist overlay.
|
||||||
* which is a single action that reaches both.
|
* - Discord: the preset's embed, rendered with the report's values and posted to its webhook
|
||||||
* - Discord: a webhook POST through `ctx.http.fetch`. Only `discord.com` is in the manifest's
|
* through `ctx.http.fetch`. Only `discord.com` is in the manifest's `hosts`.
|
||||||
* `hosts`, so that is the only webhook host accepted here.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TemplateError,
|
renderEmbed,
|
||||||
renderTemplate,
|
webhookPayload,
|
||||||
timeAgo,
|
|
||||||
type PluginContext,
|
type PluginContext,
|
||||||
type SettingsValues,
|
type TemplateError,
|
||||||
type TemplateValues,
|
|
||||||
} from '@vrcnext/plugin-api';
|
} from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
import type { Facts, Joiner } from './collector.js';
|
import { reportLines, reportSummary, reportValues, type Report } from './report.js';
|
||||||
import type { Rejoin } from './history.js';
|
import type { Settings } from './settings.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Ctx = PluginContext<Settings>;
|
type Ctx = PluginContext<Settings>;
|
||||||
|
|
||||||
/** Matches the host declared in plugin.json; `ptb.`/`canary.` would need their own entries. */
|
/** Matches the host declared in plugin.json; `ptb.`/`canary.` would need their own entries. */
|
||||||
const DISCORD_WEBHOOK = /^https:\/\/discord\.com\/api\/webhooks\/\d+\/[\w-]+$/;
|
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' {
|
function accentFor(report: Report): 'ok' | 'warn' | 'info' {
|
||||||
const { facts } = report;
|
if (report.evaluation.verdict === 'failed') return 'warn';
|
||||||
if (facts.ageVerified === false || facts.avatar?.pc === 'VeryPoor') return 'warn';
|
if (report.evaluation.verdict === 'met') return 'ok';
|
||||||
if (facts.ageVerified === true) return 'ok';
|
|
||||||
return 'info';
|
return 'info';
|
||||||
}
|
}
|
||||||
|
|
||||||
function warnTemplate(ctx: Ctx, error: TemplateError): void {
|
function warnTemplate(ctx: Ctx, report: Report, error: TemplateError): void {
|
||||||
ctx.logger.warn(`Report template is invalid, using the default: ${error.message}`);
|
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. */
|
/** 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[] {
|
function isVrTarget(name: string): boolean {
|
||||||
return targets
|
return /wayvr|openvr|steamvr|overlay|xso/i.test(name);
|
||||||
.filter((t) => {
|
|
||||||
const vr = /wayvr|openvr|steamvr|overlay|xso/i.test(t.name);
|
|
||||||
return vr ? wantVr : wantDesktop;
|
|
||||||
})
|
|
||||||
.map((t) => t.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Through the bridge: `true` when at least one target accepted. `ok: false` with only the
|
* 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
|
* `bridge` pseudo-sink failing means the bridge is not reachable, which is a normal state.
|
||||||
* an error; a target that refused is logged by name.
|
|
||||||
*/
|
*/
|
||||||
async function sendNative(ctx: Ctx, wants: { readonly desktop: boolean; readonly vr: boolean }, report: Report): Promise<boolean> {
|
async function sendNative(ctx: Ctx, report: Report): Promise<boolean> {
|
||||||
|
const { preset } = report;
|
||||||
let targets: readonly { readonly name: string }[];
|
let targets: readonly { readonly name: string }[];
|
||||||
try {
|
try {
|
||||||
targets = await ctx.native.targets();
|
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)}`);
|
ctx.logger.debug(`Bridge targets not available: ${String(error)}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const values = ctx.settings.values;
|
const names = targets.map((t) => t.name);
|
||||||
const desktopSinks = wants.desktop ? pickSinks(targets, true, false) : [];
|
const batches = [
|
||||||
const vrSinks = wants.vr ? pickSinks(targets, false, true) : [];
|
{ sinks: preset.desktop ? names.filter((n) => !isVrTarget(n)) : [], template: preset.template },
|
||||||
if (desktopSinks.length === 0 && vrSinks.length === 0) {
|
{ 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.');
|
ctx.logger.debug('The bridge has no target for the enabled channels.');
|
||||||
return false;
|
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;
|
let delivered = false;
|
||||||
for (const batch of batches.filter((b) => b.sinks.length > 0)) {
|
for (const batch of batches) {
|
||||||
const lines = reportLines(report, batch.template, (e) => { warnTemplate(ctx, e); });
|
const lines = reportLines(report, batch.template, (e) => { warnTemplate(ctx, report, e); });
|
||||||
const result = await ctx.native.notify({
|
const result = await ctx.native.notify({
|
||||||
title: lines[0] ?? `${report.joiner.name} joined`,
|
title: lines[0] ?? `${report.joiner.name} joined`,
|
||||||
content: lines.slice(1).join('\n'),
|
content: lines.slice(1).join('\n'),
|
||||||
timeoutSecs: values.notifyTimeoutSecs,
|
timeoutSecs: ctx.settings.get('notifyTimeoutSecs'),
|
||||||
icon: 'security-high',
|
icon: 'security-high',
|
||||||
sinks: batch.sinks,
|
sinks: batch.sinks,
|
||||||
// Never critical: KDE ignores the expiry for critical notifications and keeps them on screen.
|
// 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;
|
delivered ||= result.ok;
|
||||||
}
|
}
|
||||||
if (!delivered) ctx.logger.debug('Bridge not reachable or every target refused; falling back.');
|
|
||||||
return delivered;
|
return delivered;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendDesktopAndVr(ctx: Ctx, values: SettingsValues<Settings>, report: Report): Promise<void> {
|
async function sendDesktopAndVr(ctx: Ctx, report: Report): Promise<void> {
|
||||||
const wants = { desktop: values.notifyDesktop, vr: values.notifyVr };
|
const { preset } = report;
|
||||||
if (!wants.desktop && !wants.vr) return;
|
if (!preset.desktop && !preset.vr) return;
|
||||||
if (await sendNative(ctx, wants, report)) return;
|
if (await sendNative(ctx, report)) return;
|
||||||
|
|
||||||
// Windows without the bridge's targets: VRCNext's own tray toast and wrist overlay in one call.
|
// Windows without the bridge's targets: VRCNext's own tray toast and wrist overlay in one call.
|
||||||
if (ctx.notifications.desktopAvailable) {
|
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({
|
ctx.notifications.desktop({
|
||||||
title: lines[0] ?? `${report.joiner.name} joined`,
|
title: lines[0] ?? `${report.joiner.name} joined`,
|
||||||
subtitle: lines.slice(1).join(' · '),
|
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.');
|
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> {
|
async function sendDiscord(ctx: Ctx, report: Report): Promise<void> {
|
||||||
if (!values.notifyDiscord) return;
|
const { discord } = report.preset;
|
||||||
const url = values.discordWebhookUrl.trim();
|
if (!discord.enabled) return;
|
||||||
|
const url = discord.webhookUrl.trim();
|
||||||
if (!DISCORD_WEBHOOK.test(url)) {
|
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;
|
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, {
|
const response = await ctx.http.fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(webhookPayload(embed, { username: 'Club Security' })),
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
referrerPolicy: 'no-referrer',
|
referrerPolicy: 'no-referrer',
|
||||||
});
|
});
|
||||||
if (!response.ok) ctx.logger.warn(`Discord webhook answered ${String(response.status)}.`);
|
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. */
|
/** Sends on every channel the preset enables. Never throws: a failed channel is logged. */
|
||||||
export async function notifyAll(ctx: Ctx, report: Report): Promise<void> {
|
export async function notify(ctx: Ctx, report: Report): Promise<void> {
|
||||||
const values = ctx.settings.values;
|
if (report.preset.toast) ctx.notifications.toast({ message: reportSummary(report), ok: accentFor(report) !== 'warn' });
|
||||||
if (values.notifyToast) ctx.notifications.toast({ message: reportSummary(report), ok: accentFor(report) !== 'warn' });
|
const results = await Promise.allSettled([sendDesktopAndVr(ctx, report), sendDiscord(ctx, report)]);
|
||||||
const results = await Promise.allSettled([
|
|
||||||
sendDesktopAndVr(ctx, values, report),
|
|
||||||
sendDiscord(ctx, values, report),
|
|
||||||
]);
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === 'rejected') ctx.logger.warn(`Notification channel failed: ${String(result.reason)}`);
|
if (result.status === 'rejected') ctx.logger.warn(`Notification channel failed: ${String(result.reason)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-33
@@ -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.
|
* 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 { describePreset, presetMatches } from './filters.js';
|
||||||
import { reportLines, type Report } from './notify.js';
|
import { reportLines, type Report } from './report.js';
|
||||||
|
import { VERDICT_TEXT, type Verdict } from './requirements.js';
|
||||||
import type { Settings } from './settings.js';
|
import type { Settings } from './settings.js';
|
||||||
import type { CurrentInstance } from './vrcnext-data.js';
|
|
||||||
|
|
||||||
type Ctx = PluginContext<Settings>;
|
type Ctx = PluginContext<Settings>;
|
||||||
|
|
||||||
const MAX_SHOWN = 25;
|
const MAX_SHOWN = 25;
|
||||||
|
|
||||||
|
const VERDICT_TONE: Readonly<Record<Verdict, UiBadgeTone>> = { met: 'ok', unverified: 'warning', failed: 'err' };
|
||||||
|
|
||||||
export interface PanelDeps {
|
export interface PanelDeps {
|
||||||
readonly currentInstance: () => CurrentInstance | undefined;
|
readonly currentInstance: () => VrcInstance | undefined;
|
||||||
readonly sendTest: () => Promise<void>;
|
readonly sendTest: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +39,13 @@ export class ReportPanel {
|
|||||||
icon: 'security',
|
icon: 'security',
|
||||||
render: (tab) => { this.#render(tab); },
|
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({
|
this.#ctx.ui.addSettingsCard({
|
||||||
title: 'Club Security',
|
title: 'Club Security',
|
||||||
icon: 'security',
|
icon: 'security',
|
||||||
render: (card) => {
|
render: (card) => {
|
||||||
card.appendChild(this.#ctx.ui.kit.description(
|
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 {
|
#render(tab: HTMLElement): void {
|
||||||
const k = this.#ctx.ui.kit;
|
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' });
|
this.#list = k.card({ title: 'Recent reports', icon: 'history' });
|
||||||
const actions = k.card({
|
const actions = k.card({
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
icon: 'build',
|
icon: 'build',
|
||||||
children: [
|
children: [
|
||||||
k.description('A test report uses the current instance and your own name, and goes to every enabled channel.'),
|
k.description('A test report uses the current instance and your own account, and goes through every preset that matches it.'),
|
||||||
k.description('Rejoin is answered from VRCNext’s own timeline: was this player in this exact instance with you before.'),
|
k.description('Green: every requirement verified. Orange: something could not be checked. Red: a requirement was checked and not met.'),
|
||||||
k.buttonRow(
|
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)[] {
|
#statusRows(): readonly (HTMLElement | DocumentFragment)[] {
|
||||||
const k = this.#ctx.ui.kit;
|
const k = this.#ctx.ui.kit;
|
||||||
const values = this.#ctx.settings.values;
|
const presets = this.#ctx.settings.get('presets');
|
||||||
const filter = filterFrom(values);
|
|
||||||
const instance = this.#deps.currentInstance();
|
const instance = this.#deps.currentInstance();
|
||||||
const matches = instance !== undefined && instanceMatches(filter, instance);
|
if (presets.length === 0) return [k.emptyState('No presets yet. Add one in Settings → Plugins → Club Security.')];
|
||||||
const channels = [
|
const rows = presets.map((preset) => {
|
||||||
values.notifyToast && 'toast',
|
const matches = instance !== undefined && preset.enabled && presetMatches(preset, instance);
|
||||||
values.notifyDesktop && 'desktop',
|
const channels = [preset.toast && 'toast', preset.desktop && 'desktop', preset.vr && 'VR', preset.discord.enabled && 'Discord']
|
||||||
values.notifyVr && 'VR',
|
.filter((c): c is string => typeof c === 'string');
|
||||||
values.notifyDiscord && 'Discord',
|
return k.row({
|
||||||
].filter((c): c is string => typeof c === 'string');
|
label: preset.name,
|
||||||
return [
|
detail: `${describePreset(preset)} · ${channels.length === 0 ? 'no channels' : channels.join(', ')}`,
|
||||||
k.row({ label: 'Reporting', value: values.enabled ? k.badge('ok', 'On') : k.badge('neutral', 'Off') }),
|
value: !preset.enabled ? k.badge('neutral', 'Off') : instance === undefined ? k.badge('neutral', 'Waiting') : k.badge(matches ? 'ok' : 'neutral', matches ? 'Watching' : 'Not here'),
|
||||||
k.row({ label: 'Watching', detail: describeFilter(filter) }),
|
});
|
||||||
k.row({
|
});
|
||||||
|
rows.push(k.row({
|
||||||
label: 'Current instance',
|
label: 'Current instance',
|
||||||
detail: instance === undefined ? 'Not in an instance' : `${instance.worldName} · ${instance.instanceType}`,
|
detail: instance === undefined ? 'Not in an instance' : `${instance.worldName} · ${instance.instanceType}`,
|
||||||
value: instance === undefined ? undefined : k.badge(matches ? 'ok' : 'neutral', matches ? 'Matches' : 'Filtered out'),
|
}));
|
||||||
}),
|
return rows;
|
||||||
k.row({ label: 'Channels', detail: channels.length === 0 ? 'none enabled' : channels.join(', ') }),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#reportRows(): readonly (HTMLElement | DocumentFragment)[] {
|
#reportRows(): readonly (HTMLElement | DocumentFragment)[] {
|
||||||
const k = this.#ctx.ui.kit;
|
const k = this.#ctx.ui.kit;
|
||||||
if (this.#reports.length === 0) return [k.emptyState('No joins reported yet.')];
|
if (this.#reports.length === 0) return [k.emptyState('No joins reported yet.')];
|
||||||
return this.#reports.map((report) => {
|
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 time = new Date(report.at).toLocaleTimeString();
|
||||||
|
const verdict = report.evaluation.verdict;
|
||||||
return k.row({
|
return k.row({
|
||||||
label: `${time} · ${report.joiner.name}`,
|
label: `${time} · ${report.joiner.name} · ${report.preset.name}`,
|
||||||
detail: lines.slice(1).join(' · '),
|
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 };
|
||||||
|
}
|
||||||
+155
-114
@@ -1,151 +1,192 @@
|
|||||||
/**
|
/**
|
||||||
* Settings schema. The host renders these as rows on the plugin's settings card and persists
|
* Settings schema.
|
||||||
* them; the plugin reads them through `ctx.settings`.
|
*
|
||||||
|
* 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. */
|
/** The report as text: first line = title, rest = body. Used for toasts, desktop and VR. */
|
||||||
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. */
|
|
||||||
export const DEFAULT_TEMPLATE = [
|
export const DEFAULT_TEMPLATE = [
|
||||||
'Player "{name}" joined',
|
'{resultEmoji} {name} {{ "is back" if rejoin else "joined" }} · {preset}',
|
||||||
'18+ Verified: {ageVerifiedText}',
|
'{checksText}',
|
||||||
'Avatar PC Performance Rank: {pcRankText}',
|
'Avatar: {avatar} (PC {pcRankText} · Quest {questRankText})',
|
||||||
'Avatar Quest Performance Rank: {questRankText}',
|
'{{ "Seen here before, " + rejoinAgo if rejoin else "" }}',
|
||||||
'In Group: {inGroupText}',
|
|
||||||
'Rejoin?: {rejoinText}',
|
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
/**
|
/** The same, without emoji: WayVR draws with one font and shows nothing for symbols. */
|
||||||
* The first version's variables were text; they are booleans now, with the wording under
|
export const DEFAULT_TEMPLATE_VR = [
|
||||||
* `…Text`. A stored template written against the old names is rewritten once on activation, so
|
'{name} {{ "is back" if rejoin else "joined" }}: {resultText}',
|
||||||
* an edited format keeps rendering words rather than `true`.
|
'{checksPlainText}',
|
||||||
*/
|
].join('\n');
|
||||||
export function migrateTemplate(template: string): string {
|
|
||||||
return template.replace(/\{(ageVerified|pcRank|questRank|inGroup|rejoin)\}/g, '{$1Text}');
|
|
||||||
}
|
|
||||||
|
|
||||||
export const settings = {
|
/** The report as a Discord embed. Every text is a template; `{resultColor}` colours the bar. */
|
||||||
enabled: {
|
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',
|
kind: 'boolean',
|
||||||
label: 'Report joins',
|
label: 'Require 18+ verification',
|
||||||
description: 'Master switch. Filters below decide which instances count.',
|
description: 'Met when VRChat shows 18+. Hidden or unknown counts as unverified; a verified account under 18 fails.',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
instanceTypes: {
|
minPcRank: {
|
||||||
kind: 'string',
|
kind: 'select',
|
||||||
label: 'Only in these instance types',
|
label: 'PC avatar rank at least',
|
||||||
description:
|
description: 'An unknown rank counts as unverified.',
|
||||||
'Comma-separated. Leave empty for every instance. Types: public, friends+, friends, hidden, ' +
|
default: 'any',
|
||||||
'private, invite_plus, group-public, group-plus, group-members, or just "group" for all group instances.',
|
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: '',
|
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: {
|
template: {
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
multiline: true,
|
multiline: true,
|
||||||
label: 'Report template',
|
label: 'Report template',
|
||||||
description:
|
description: 'First line = title, rest = body. A line whose placeholders are all empty is left out. Variables are listed under the Discord embed.',
|
||||||
'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.',
|
|
||||||
default: DEFAULT_TEMPLATE,
|
default: DEFAULT_TEMPLATE,
|
||||||
},
|
},
|
||||||
templateVr: {
|
templateVr: {
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
multiline: true,
|
multiline: true,
|
||||||
label: 'VR overlay template (optional)',
|
label: 'VR overlay template',
|
||||||
description:
|
description: 'Plain text: WayVR shows nothing for emoji. Empty means the report template.',
|
||||||
'Used for VR overlay targets instead of the report template. Leave empty to use the same. WayVR draws with one ' +
|
default: DEFAULT_TEMPLATE_VR,
|
||||||
'font and shows nothing for emoji and most symbols, so keep this to plain text.',
|
},
|
||||||
|
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: '',
|
default: '',
|
||||||
|
placeholder: 'https://discord.com/api/webhooks/…',
|
||||||
|
format: 'url',
|
||||||
},
|
},
|
||||||
notifyToast: {
|
embed: {
|
||||||
kind: 'boolean',
|
kind: 'embed',
|
||||||
label: 'In-app toast',
|
label: 'Embed',
|
||||||
default: true,
|
default: DEFAULT_EMBED,
|
||||||
|
variables: TEMPLATE_VARIABLES,
|
||||||
},
|
},
|
||||||
notifyDesktop: {
|
},
|
||||||
kind: 'boolean',
|
},
|
||||||
label: 'Desktop notification',
|
} as const satisfies SettingsSchema;
|
||||||
description: 'Through the VRCNext Bridge on any platform, or VRCNext’s tray toast on Windows.',
|
|
||||||
default: true,
|
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: {
|
notifyTimeoutSecs: {
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
label: 'Seconds a desktop or VR notification stays',
|
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,
|
default: 30,
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 60,
|
max: 60,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
unit: 's',
|
||||||
notifyVr: {
|
slider: true,
|
||||||
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/…',
|
|
||||||
},
|
},
|
||||||
} as const satisfies SettingsSchema;
|
} as const satisfies SettingsSchema;
|
||||||
|
|
||||||
export type Settings = typeof settings;
|
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