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