Rejoin history from VRCNext's timeline; drop the private join memory
This commit is contained in:
1 parent
8d6850c825
commit
49b675bdcd
10 files changed
+250
-162
No files matched your search
@@ -8,7 +8,7 @@ Player "Tupper" joined
|
|||||||
Avatar PC Performance Rank: Good
|
Avatar PC Performance Rank: Good
|
||||||
Avatar Quest Performance Rank: Poor
|
Avatar Quest Performance Rank: Poor
|
||||||
In Group: Yes
|
In Group: Yes
|
||||||
Rejoin?: Yes (2× before, last 26/09/2026, 21:40:12)
|
Rejoin?: Yes (met 2× before; last 26/09/2026, 21:40:12 in Lovers Wedding; 1 of the last 3 in matching instances)
|
||||||
```
|
```
|
||||||
|
|
||||||
`In Group` only appears when a group filter is set.
|
`In Group` only appears when a group filter is set.
|
||||||
@@ -39,7 +39,7 @@ All optional, all in the plugin's settings card. Empty means "any".
|
|||||||
| 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+ 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. |
|
| 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". |
|
| 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. |
|
| Rejoin | VRCNext's own timeline: its `first_meet` / `meet_again` event for this join (with its meeting count) and `getTimelineForUser`, the player's ten most recent timeline events with where and when. | Survives VRCNext and VRChat restarts and reaches back to when VRCNext was installed. The "in matching instances" count only sees those ten events, and says so. |
|
||||||
|
|
||||||
Everything waits at most the configured number of seconds, then reports what it has, with
|
Everything waits at most the configured number of seconds, then reports what it has, with
|
||||||
`Unknown` for the rest.
|
`Unknown` for the rest.
|
||||||
@@ -49,8 +49,8 @@ Everything waits at most the configured number of seconds, then reports what it
|
|||||||
The signed-in account comes from the `vrcUser` event VRCNext pushes after login; the plugin
|
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
|
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
|
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 settle window after it (or after a world change) as "already here": not reported, and nothing
|
||||||
the rejoin check but not reported.
|
is lost because VRCNext records the meeting itself.
|
||||||
|
|
||||||
## Layout and permissions
|
## Layout and permissions
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
import { definePlugin, type PluginContext, type PluginId } from '@vrcnext/plugin-api';
|
import { definePlugin, type PluginContext, type PluginId } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
import { FactCollector, type Joiner } from './src/collector.js';
|
import { FactCollector, type Joiner } from './src/collector.js';
|
||||||
import { filterFrom, instanceMatches } from './src/filters.js';
|
import { filterFrom, instanceMatches, type InstanceFilter } from './src/filters.js';
|
||||||
import { JoinMemory } from './src/memory.js';
|
|
||||||
import { notifyAll, type Report } from './src/notify.js';
|
import { notifyAll, type Report } from './src/notify.js';
|
||||||
import { ReportPanel } from './src/panel.js';
|
import { ReportPanel } from './src/panel.js';
|
||||||
import { settings } from './src/settings.js';
|
import { settings } from './src/settings.js';
|
||||||
@@ -25,10 +24,13 @@ type Ctx = PluginContext<typeof settings>;
|
|||||||
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';
|
||||||
|
|
||||||
|
function isFiltered(filter: InstanceFilter): boolean {
|
||||||
|
return filter.instanceTypes.length > 0 || filter.groupId !== '' || filter.worldIds.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
class ClubSecurity {
|
class ClubSecurity {
|
||||||
readonly #ctx: Ctx;
|
readonly #ctx: Ctx;
|
||||||
readonly #collector: FactCollector;
|
readonly #collector: FactCollector;
|
||||||
readonly #memory: JoinMemory;
|
|
||||||
readonly #panel: ReportPanel;
|
readonly #panel: ReportPanel;
|
||||||
/**
|
/**
|
||||||
* The signed-in account, from the `vrcUser` event VRCNext pushes after login and whenever the
|
* The signed-in account, from the `vrcUser` event VRCNext pushes after login and whenever the
|
||||||
@@ -47,9 +49,7 @@ class ClubSecurity {
|
|||||||
constructor(ctx: Ctx) {
|
constructor(ctx: Ctx) {
|
||||||
this.#ctx = ctx;
|
this.#ctx = ctx;
|
||||||
this.#collector = new FactCollector(ctx);
|
this.#collector = new FactCollector(ctx);
|
||||||
this.#memory = new JoinMemory(ctx.settings);
|
|
||||||
this.#panel = new ReportPanel(ctx, {
|
this.#panel = new ReportPanel(ctx, {
|
||||||
memory: this.#memory,
|
|
||||||
currentInstance: () => this.#collector.instance,
|
currentInstance: () => this.#collector.instance,
|
||||||
sendTest: () => this.#sendTest(),
|
sendTest: () => this.#sendTest(),
|
||||||
});
|
});
|
||||||
@@ -94,14 +94,13 @@ class ClubSecurity {
|
|||||||
if (this.#inFlight.has(key)) return;
|
if (this.#inFlight.has(key)) return;
|
||||||
this.#inFlight.add(key);
|
this.#inFlight.add(key);
|
||||||
try {
|
try {
|
||||||
const previous = await this.#memory.record(key, joiner.name, instance.location);
|
|
||||||
if (Date.now() < this.#settledAt) {
|
if (Date.now() < this.#settledAt) {
|
||||||
this.#ctx.logger.debug(`${joiner.name} was already here when you joined; remembered, not reported.`);
|
// VRCNext records the meeting itself, so nothing is lost by not reporting it.
|
||||||
this.#panel.refresh();
|
this.#ctx.logger.debug(`${joiner.name} was already here when you joined; not reported.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const facts = await this.#collector.collect(joiner, filter.groupId, values.collectTimeoutSecs * 1000);
|
const facts = await this.#collector.collect(joiner, filter, values.collectTimeoutSecs * 1000);
|
||||||
const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId, previous };
|
const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId, filtered: isFiltered(filter) };
|
||||||
this.#panel.push(report);
|
this.#panel.push(report);
|
||||||
this.#ctx.logger.info(`Reported ${joiner.name}: 18+ ${String(facts.ageVerified ?? '?')}, PC ${facts.avatar?.pc ?? '?'}, Quest ${facts.avatar?.quest ?? '?'}.`);
|
this.#ctx.logger.info(`Reported ${joiner.name}: 18+ ${String(facts.ageVerified ?? '?')}, PC ${facts.avatar?.pc ?? '?'}, Quest ${facts.avatar?.quest ?? '?'}.`);
|
||||||
await notifyAll(this.#ctx, report);
|
await notifyAll(this.#ctx, report);
|
||||||
@@ -130,15 +129,8 @@ class ClubSecurity {
|
|||||||
const name = me?.displayName ?? self.name;
|
const name = me?.displayName ?? self.name;
|
||||||
const joiner: Joiner = { name: name === '' ? 'Test player' : name, userId: me?.id ?? '' };
|
const joiner: Joiner = { name: name === '' ? 'Test player' : name, userId: me?.id ?? '' };
|
||||||
const filter = filterFrom(this.#ctx.settings.values);
|
const filter = filterFrom(this.#ctx.settings.values);
|
||||||
const facts = await this.#collector.collect(joiner, filter.groupId, 10_000);
|
const facts = await this.#collector.collect(joiner, filter, 10_000);
|
||||||
const report: Report = {
|
const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId, filtered: isFiltered(filter) };
|
||||||
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);
|
await notifyAll(this.#ctx, report);
|
||||||
this.#ctx.notifications.toast({ message: 'Test report sent to every enabled channel.' });
|
this.#ctx.notifications.toast({ message: 'Test report sent to every enabled channel.' });
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -3,10 +3,14 @@
|
|||||||
"name": "Club Security",
|
"name": "Club Security",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"apiVersion": "^0.2.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.",
|
"description": "When someone joins your instance, gathers age verification, avatar performance, group membership and VRCNext's meeting history, then notifies you in-app, on the desktop, in VR or on 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": ["notifications", "privacy", "activity"],
|
"tags": [
|
||||||
|
"notifications",
|
||||||
|
"privacy",
|
||||||
|
"activity"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"host:events",
|
"host:events",
|
||||||
"host:actions",
|
"host:actions",
|
||||||
@@ -17,17 +21,22 @@
|
|||||||
],
|
],
|
||||||
"optionalPermissions": [],
|
"optionalPermissions": [],
|
||||||
"actions": [
|
"actions": [
|
||||||
|
"getTimelineForUser",
|
||||||
|
"vrcGetAvatarDetail",
|
||||||
"vrcGetCurrentInstance",
|
"vrcGetCurrentInstance",
|
||||||
"vrcGetGroupsForNetwork",
|
"vrcGetGroupsForNetwork",
|
||||||
"vrcGetInstanceAvatars",
|
"vrcGetInstanceAvatars"
|
||||||
"vrcGetAvatarDetail"
|
|
||||||
],
|
],
|
||||||
"events": [
|
"events": [
|
||||||
"vrcUser",
|
"timelineEvent",
|
||||||
|
"timelineForUser",
|
||||||
|
"vrcAvatarDetail",
|
||||||
"vrcCurrentInstance",
|
"vrcCurrentInstance",
|
||||||
"vrcGroupsForNetwork",
|
"vrcGroupsForNetwork",
|
||||||
"vrcInstanceAvatarFound",
|
"vrcInstanceAvatarFound",
|
||||||
"vrcAvatarDetail"
|
"vrcUser"
|
||||||
],
|
],
|
||||||
"hosts": ["discord.com"]
|
"hosts": [
|
||||||
|
"discord.com"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+44
-3
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
import type { EventBus, PluginContext } from '@vrcnext/plugin-api';
|
import type { EventBus, PluginContext } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
|
import type { InstanceFilter } from './filters.js';
|
||||||
|
import { summarise, toMeetEvent, toTimelineEntries, type MeetEvent, type MeetHistory } from './history.js';
|
||||||
import type { Settings } from './settings.js';
|
import type { Settings } from './settings.js';
|
||||||
import {
|
import {
|
||||||
profileLoaded,
|
profileLoaded,
|
||||||
@@ -45,6 +47,7 @@ export interface Facts {
|
|||||||
readonly avatar: AvatarFacts | undefined;
|
readonly avatar: AvatarFacts | undefined;
|
||||||
/** `undefined` when no group filter is configured. */
|
/** `undefined` when no group filter is configured. */
|
||||||
readonly inGroup: boolean | undefined;
|
readonly inGroup: boolean | undefined;
|
||||||
|
readonly history: MeetHistory;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Ctx = PluginContext<Settings>;
|
type Ctx = PluginContext<Settings>;
|
||||||
@@ -92,6 +95,8 @@ export class FactCollector {
|
|||||||
#latest: CurrentInstance | undefined;
|
#latest: CurrentInstance | undefined;
|
||||||
/** Performance ranks by avatar id, so a popular avatar is only fetched once. */
|
/** Performance ranks by avatar id, so a popular avatar is only fetched once. */
|
||||||
readonly #perfCache = new Map<string, AvatarFacts>();
|
readonly #perfCache = new Map<string, AvatarFacts>();
|
||||||
|
/** The latest first_meet / meet_again VRCNext pushed per user; it can arrive before the join line. */
|
||||||
|
readonly #meets = new Map<string, MeetEvent>();
|
||||||
|
|
||||||
constructor(ctx: Ctx) {
|
constructor(ctx: Ctx) {
|
||||||
this.#ctx = ctx;
|
this.#ctx = ctx;
|
||||||
@@ -99,6 +104,10 @@ export class FactCollector {
|
|||||||
const instance = toCurrentInstance(payload);
|
const instance = toCurrentInstance(payload);
|
||||||
if (instance !== undefined) this.#latest = instance;
|
if (instance !== undefined) this.#latest = instance;
|
||||||
});
|
});
|
||||||
|
ctx.events.on('timelineEvent', (payload) => {
|
||||||
|
const meet = toMeetEvent(payload);
|
||||||
|
if (meet !== undefined) this.#meets.set(meet.userId, meet);
|
||||||
|
});
|
||||||
ctx.events.on('vrcAvatarDetail', (payload) => {
|
ctx.events.on('vrcAvatarDetail', (payload) => {
|
||||||
const perf = toAvatarPerformance(payload);
|
const perf = toAvatarPerformance(payload);
|
||||||
if (perf !== undefined) {
|
if (perf !== undefined) {
|
||||||
@@ -112,12 +121,14 @@ export class FactCollector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Runs every lookup in parallel and returns whatever arrived before the deadline. */
|
/** Runs every lookup in parallel and returns whatever arrived before the deadline. */
|
||||||
async collect(joiner: Joiner, groupId: string, deadlineMs: number): Promise<Facts> {
|
async collect(joiner: Joiner, filter: InstanceFilter, deadlineMs: number): Promise<Facts> {
|
||||||
const signal = this.#ctx.signal;
|
const signal = this.#ctx.signal;
|
||||||
const [profile, inGroup, avatar] = await Promise.all([
|
const joinedAt = Date.now();
|
||||||
|
const [profile, inGroup, avatar, history] = await Promise.all([
|
||||||
this.#profile(joiner, deadlineMs, signal),
|
this.#profile(joiner, deadlineMs, signal),
|
||||||
groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, groupId, deadlineMs, signal),
|
filter.groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, filter.groupId, deadlineMs, signal),
|
||||||
this.#avatar(joiner, deadlineMs, signal),
|
this.#avatar(joiner, deadlineMs, signal),
|
||||||
|
this.#history(joiner, filter, joinedAt, { ms: deadlineMs, signal }),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
ageVerified: profile === undefined ? undefined : profile.ageVerified,
|
ageVerified: profile === undefined ? undefined : profile.ageVerified,
|
||||||
@@ -125,9 +136,39 @@ export class FactCollector {
|
|||||||
platform: profile?.platform ?? '',
|
platform: profile?.platform ?? '',
|
||||||
avatar,
|
avatar,
|
||||||
inGroup,
|
inGroup,
|
||||||
|
history,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VRCNext's own record of earlier meetings: the live meet event for this join, plus the user's
|
||||||
|
* recent timeline. Legacy accounts without a `usr_` id have neither.
|
||||||
|
*/
|
||||||
|
async #history(
|
||||||
|
joiner: Joiner,
|
||||||
|
filter: InstanceFilter,
|
||||||
|
joinedAt: number,
|
||||||
|
window: { readonly ms: number; readonly signal: AbortSignal },
|
||||||
|
): Promise<MeetHistory> {
|
||||||
|
const { ms, signal } = window;
|
||||||
|
if (joiner.userId === '') return summarise(undefined, undefined, filter, joinedAt);
|
||||||
|
const meetPending = this.#meets.get(joiner.userId) !== undefined
|
||||||
|
? Promise.resolve(this.#meets.get(joiner.userId))
|
||||||
|
: waitFor(this.#ctx.events, 'timelineEvent', (payload) => {
|
||||||
|
const meet = toMeetEvent(payload);
|
||||||
|
return meet?.userId === joiner.userId ? meet : undefined;
|
||||||
|
}, { ms: Math.min(ms, 8_000), signal });
|
||||||
|
const timelinePending = waitFor(
|
||||||
|
this.#ctx.events,
|
||||||
|
'timelineForUser',
|
||||||
|
(payload) => toTimelineEntries(payload, joiner.userId),
|
||||||
|
{ ms, signal },
|
||||||
|
);
|
||||||
|
this.#ctx.bridge.send('getTimelineForUser', { userId: joiner.userId });
|
||||||
|
const [meet, entries] = await Promise.all([meetPending, timelinePending]);
|
||||||
|
return summarise(meet, entries, filter, joinedAt);
|
||||||
|
}
|
||||||
|
|
||||||
async #profile(joiner: Joiner, ms: number, signal: AbortSignal): Promise<InstanceUser | undefined> {
|
async #profile(joiner: Joiner, ms: number, signal: AbortSignal): Promise<InstanceUser | undefined> {
|
||||||
const current = this.#latest === undefined ? undefined : findUser(this.#latest, joiner);
|
const current = this.#latest === undefined ? undefined : findUser(this.#latest, joiner);
|
||||||
if (current !== undefined && profileLoaded(current)) return current;
|
if (current !== undefined && profileLoaded(current)) return current;
|
||||||
|
|||||||
+34
-2
@@ -4,9 +4,41 @@
|
|||||||
|
|
||||||
import type { SettingsValues } from '@vrcnext/plugin-api';
|
import type { SettingsValues } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
import type { CurrentInstance } from './vrcnext-data.js';
|
import { groupIdOf } from './vrcnext-data.js';
|
||||||
import type { Settings } 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. */
|
||||||
|
export interface InstanceShape {
|
||||||
|
readonly worldId: string;
|
||||||
|
readonly instanceType: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VRCNext's `ParseLocation`, ported: the instance type a location string encodes. Needed for
|
||||||
|
* past locations from the timeline, which carry no parsed type.
|
||||||
|
*/
|
||||||
|
export function instanceTypeOf(location: string): string {
|
||||||
|
const instance = location.split(':')[1] ?? '';
|
||||||
|
if (instance.includes('~private(')) return instance.includes('~canRequestInvite') ? 'invite_plus' : 'private';
|
||||||
|
if (instance.includes('~friends+(')) return 'friends+';
|
||||||
|
if (instance.includes('~friends(')) return 'friends';
|
||||||
|
if (instance.includes('~hidden(')) return 'hidden';
|
||||||
|
if (instance.includes('~group(')) {
|
||||||
|
const access = /groupAccessType\(([^)]+)\)/.exec(instance)?.[1] ?? '';
|
||||||
|
if (access === 'public') return 'group-public';
|
||||||
|
if (access === 'plus') return 'group-plus';
|
||||||
|
if (access === 'members') return 'group-members';
|
||||||
|
return 'group';
|
||||||
|
}
|
||||||
|
return 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shapeOfLocation(location: string): InstanceShape {
|
||||||
|
const worldId = location.split(':')[0] ?? '';
|
||||||
|
return { worldId: worldId.startsWith('wrld_') ? worldId : '', instanceType: instanceTypeOf(location), groupId: groupIdOf(location) };
|
||||||
|
}
|
||||||
|
|
||||||
export interface InstanceFilter {
|
export interface InstanceFilter {
|
||||||
readonly instanceTypes: readonly string[];
|
readonly instanceTypes: readonly string[];
|
||||||
readonly groupId: string;
|
readonly groupId: string;
|
||||||
@@ -34,7 +66,7 @@ function typeMatches(wanted: readonly string[], actual: string): boolean {
|
|||||||
return wanted.some((w) => w === type || (w === 'group' && type.startsWith('group')));
|
return wanted.some((w) => w === type || (w === 'group' && type.startsWith('group')));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function instanceMatches(filter: InstanceFilter, instance: CurrentInstance): boolean {
|
export function instanceMatches(filter: InstanceFilter, instance: InstanceShape): boolean {
|
||||||
if (!typeMatches(filter.instanceTypes, instance.instanceType)) return false;
|
if (!typeMatches(filter.instanceTypes, instance.instanceType)) return false;
|
||||||
const groupId = filter.groupId.toLowerCase();
|
const groupId = filter.groupId.toLowerCase();
|
||||||
if (groupId !== '' && instance.groupId.toLowerCase() !== groupId) return false;
|
if (groupId !== '' && instance.groupId.toLowerCase() !== groupId) return false;
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Whether a joiner has been met before, from VRCNext's own timeline.
|
||||||
|
*
|
||||||
|
* VRCNext keeps a `known_users` table and, on every join, records a `first_meet` or `meet_again`
|
||||||
|
* timeline event for that player (pushed live as `timelineEvent`, with `meetCount` on repeats).
|
||||||
|
* `getTimelineForUser` returns the ten most recent timeline events involving the player, each
|
||||||
|
* with a timestamp and the location it happened in. Both survive VRCNext and VRChat restarts and
|
||||||
|
* reach back to when VRCNext was installed, which no memory of the plugin's own could.
|
||||||
|
*
|
||||||
|
* The ten-event window is the honest limit: "how many times in a matching instance" counts only
|
||||||
|
* what is in it, and the report says so.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { instanceMatches, shapeOfLocation, type InstanceFilter } from './filters.js';
|
||||||
|
|
||||||
|
export interface MeetEvent {
|
||||||
|
readonly type: 'first_meet' | 'meet_again';
|
||||||
|
readonly userId: string;
|
||||||
|
readonly timestamp: string;
|
||||||
|
readonly meetCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineEntry {
|
||||||
|
readonly type: string;
|
||||||
|
readonly timestamp: string;
|
||||||
|
readonly worldName: string;
|
||||||
|
readonly location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeetHistory {
|
||||||
|
/** `undefined` when VRCNext said nothing in time. */
|
||||||
|
readonly metBefore: boolean | undefined;
|
||||||
|
/** VRCNext's count of earlier meetings, when it reported one. */
|
||||||
|
readonly meetCount: number;
|
||||||
|
readonly lastMet: { readonly at: string; readonly worldName: string } | undefined;
|
||||||
|
/** Earlier events, within the window VRCNext returned, that happened in a matching instance. */
|
||||||
|
readonly matchingBefore: number | undefined;
|
||||||
|
/** How many earlier events the window held, so the report can say how far back it looked. */
|
||||||
|
readonly windowSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNKNOWN_HISTORY: MeetHistory = {
|
||||||
|
metBefore: undefined,
|
||||||
|
meetCount: 0,
|
||||||
|
lastMet: undefined,
|
||||||
|
matchingBefore: undefined,
|
||||||
|
windowSize: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 `timelineEvent` push to the two meeting kinds; anything else is `undefined`. */
|
||||||
|
export function toMeetEvent(payload: unknown): MeetEvent | undefined {
|
||||||
|
const r = rec(payload);
|
||||||
|
if (r === undefined) return undefined;
|
||||||
|
const type = str(r['type']);
|
||||||
|
const userId = str(r['userId']);
|
||||||
|
if ((type !== 'first_meet' && type !== 'meet_again') || userId === '') return undefined;
|
||||||
|
const count = r['meetCount'];
|
||||||
|
return { type, userId, timestamp: str(r['timestamp']), meetCount: typeof count === 'number' ? count : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
if (entry === undefined) return [];
|
||||||
|
return [{
|
||||||
|
type: str(entry['type']),
|
||||||
|
timestamp: str(entry['timestamp']),
|
||||||
|
worldName: str(entry['worldName']),
|
||||||
|
location: str(entry['location']),
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines the live meet event with the user's timeline into one answer.
|
||||||
|
*
|
||||||
|
* Events from the current join are excluded by timestamp: anything at or after `joinedAt`
|
||||||
|
* describes this visit, not an earlier one.
|
||||||
|
*/
|
||||||
|
export function summarise(
|
||||||
|
meet: MeetEvent | undefined,
|
||||||
|
entries: readonly TimelineEntry[] | undefined,
|
||||||
|
filter: InstanceFilter,
|
||||||
|
joinedAt: number,
|
||||||
|
): MeetHistory {
|
||||||
|
const earlier = (entries ?? []).filter((e) => {
|
||||||
|
const at = Date.parse(e.timestamp);
|
||||||
|
return Number.isFinite(at) && at < joinedAt - 5_000;
|
||||||
|
});
|
||||||
|
earlier.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||||
|
|
||||||
|
const metBefore = meet !== undefined ? meet.type === 'meet_again' : (entries === undefined ? undefined : earlier.length > 0);
|
||||||
|
const last = earlier[0];
|
||||||
|
const matching = entries === undefined
|
||||||
|
? undefined
|
||||||
|
: earlier.filter((e) => e.location !== '' && instanceMatches(filter, shapeOfLocation(e.location))).length;
|
||||||
|
return {
|
||||||
|
metBefore,
|
||||||
|
meetCount: meet?.meetCount ?? 0,
|
||||||
|
lastMet: last === undefined ? undefined : { at: last.timestamp, worldName: last.worldName },
|
||||||
|
matchingBefore: matching,
|
||||||
|
windowSize: earlier.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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', '{}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+23
-8
@@ -13,7 +13,7 @@
|
|||||||
import type { PluginContext, SettingsValues } from '@vrcnext/plugin-api';
|
import type { PluginContext, SettingsValues } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
import type { Facts, Joiner } from './collector.js';
|
import type { Facts, Joiner } from './collector.js';
|
||||||
import type { SeenEntry } from './memory.js';
|
import type { MeetHistory } from './history.js';
|
||||||
import type { Settings } from './settings.js';
|
import type { Settings } from './settings.js';
|
||||||
import type { CurrentInstance } from './vrcnext-data.js';
|
import type { CurrentInstance } from './vrcnext-data.js';
|
||||||
|
|
||||||
@@ -24,7 +24,8 @@ export interface Report {
|
|||||||
readonly facts: Facts;
|
readonly facts: Facts;
|
||||||
/** Whether a group filter was active, so the "In Group" line is only shown when it means something. */
|
/** Whether a group filter was active, so the "In Group" line is only shown when it means something. */
|
||||||
readonly groupFilter: string;
|
readonly groupFilter: string;
|
||||||
readonly previous: SeenEntry | undefined;
|
/** Whether any filter was active, so the matching-instance count is only shown when it means something. */
|
||||||
|
readonly filtered: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Ctx = PluginContext<Settings>;
|
type Ctx = PluginContext<Settings>;
|
||||||
@@ -41,10 +42,24 @@ function rank(value: string): string {
|
|||||||
return value === '' ? 'Unknown' : value;
|
return value === '' ? 'Unknown' : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejoinText(previous: SeenEntry | undefined): string {
|
/**
|
||||||
if (previous === undefined) return 'No (first time)';
|
* `Yes (met 3× before, last 26/09/2026, 21:40 in Club X; 2 of the last 3 in matching instances)`.
|
||||||
const when = new Date(previous.lastSeen).toLocaleString();
|
* Counts come from VRCNext: `meetCount` from its meet_again event, the rest from the ten most
|
||||||
return `Yes (${String(previous.joins)}× before, last ${when})`;
|
* recent timeline events it returned, which is why the matching count names its window.
|
||||||
|
*/
|
||||||
|
function rejoinText(history: MeetHistory, filtered: boolean): string {
|
||||||
|
if (history.metBefore === undefined) return 'Unknown';
|
||||||
|
if (!history.metBefore) return 'No (first time)';
|
||||||
|
const bits: string[] = [];
|
||||||
|
if (history.meetCount > 0) bits.push(`met ${String(history.meetCount)}× before`);
|
||||||
|
if (history.lastMet !== undefined) {
|
||||||
|
const when = new Date(history.lastMet.at).toLocaleString();
|
||||||
|
bits.push(`last ${when}${history.lastMet.worldName === '' ? '' : ` in ${history.lastMet.worldName}`}`);
|
||||||
|
}
|
||||||
|
if (filtered && history.matchingBefore !== undefined && history.windowSize > 0) {
|
||||||
|
bits.push(`${String(history.matchingBefore)} of the last ${String(history.windowSize)} in matching instances`);
|
||||||
|
}
|
||||||
|
return bits.length === 0 ? 'Yes' : `Yes (${bits.join('; ')})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The report as the spec lays it out, one fact per line. */
|
/** The report as the spec lays it out, one fact per line. */
|
||||||
@@ -60,7 +75,7 @@ export function reportLines(report: Report): readonly string[] {
|
|||||||
`Avatar Quest Performance Rank: ${rank(facts.avatar?.quest ?? '')}`,
|
`Avatar Quest Performance Rank: ${rank(facts.avatar?.quest ?? '')}`,
|
||||||
];
|
];
|
||||||
if (report.groupFilter !== '') lines.push(`In Group: ${yesNo(facts.inGroup, 'Unknown (only visible memberships count)')}`);
|
if (report.groupFilter !== '') lines.push(`In Group: ${yesNo(facts.inGroup, 'Unknown (only visible memberships count)')}`);
|
||||||
lines.push(`Rejoin?: ${rejoinText(report.previous)}`);
|
lines.push(`Rejoin?: ${rejoinText(report.facts.history, report.filtered)}`);
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +88,7 @@ export function reportSummary(report: Report): string {
|
|||||||
`Quest ${rank(facts.avatar?.quest ?? '').replace('Unknown', '?')}`,
|
`Quest ${rank(facts.avatar?.quest ?? '').replace('Unknown', '?')}`,
|
||||||
];
|
];
|
||||||
if (report.groupFilter !== '') bits.push(`group: ${yesNo(facts.inGroup, '?')}`);
|
if (report.groupFilter !== '') bits.push(`group: ${yesNo(facts.inGroup, '?')}`);
|
||||||
bits.push(report.previous === undefined ? 'new' : 'rejoin');
|
bits.push(report.facts.history.metBefore === undefined ? 'history ?' : (report.facts.history.metBefore ? 'rejoin' : 'new'));
|
||||||
return `${report.joiner.name} joined · ${bits.join(' · ')}`;
|
return `${report.joiner.name} joined · ${bits.join(' · ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-21
@@ -6,7 +6,6 @@
|
|||||||
import type { PluginContext } from '@vrcnext/plugin-api';
|
import type { PluginContext } from '@vrcnext/plugin-api';
|
||||||
|
|
||||||
import { describeFilter, filterFrom, instanceMatches } from './filters.js';
|
import { describeFilter, filterFrom, instanceMatches } from './filters.js';
|
||||||
import type { JoinMemory } from './memory.js';
|
|
||||||
import { reportLines, type Report } from './notify.js';
|
import { reportLines, type Report } from './notify.js';
|
||||||
import type { Settings } from './settings.js';
|
import type { Settings } from './settings.js';
|
||||||
import type { CurrentInstance } from './vrcnext-data.js';
|
import type { CurrentInstance } from './vrcnext-data.js';
|
||||||
@@ -16,7 +15,6 @@ type Ctx = PluginContext<Settings>;
|
|||||||
const MAX_SHOWN = 25;
|
const MAX_SHOWN = 25;
|
||||||
|
|
||||||
export interface PanelDeps {
|
export interface PanelDeps {
|
||||||
readonly memory: JoinMemory;
|
|
||||||
readonly currentInstance: () => CurrentInstance | undefined;
|
readonly currentInstance: () => CurrentInstance | undefined;
|
||||||
readonly sendTest: () => Promise<void>;
|
readonly sendTest: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -73,13 +71,9 @@ export class ReportPanel {
|
|||||||
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 name, and goes to every enabled channel.'),
|
||||||
|
k.description('Rejoin history comes from VRCNext’s own timeline, so there is nothing to clear here.'),
|
||||||
k.buttonRow(
|
k.buttonRow(
|
||||||
k.button({ label: 'Send test notification', icon: 'send', onClick: () => { void this.#deps.sendTest(); } }),
|
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(); },
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -108,7 +102,6 @@ export class ReportPanel {
|
|||||||
value: instance === undefined ? undefined : k.badge(matches ? 'ok' : 'neutral', matches ? 'Matches' : 'Filtered out'),
|
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: 'Channels', detail: channels.length === 0 ? 'none enabled' : channels.join(', ') }),
|
||||||
k.row({ label: 'Players remembered', value: k.valueText(String(this.#deps.memory.size)) }),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,21 +114,9 @@ export class ReportPanel {
|
|||||||
return k.row({
|
return k.row({
|
||||||
label: `${time} · ${report.joiner.name}`,
|
label: `${time} · ${report.joiner.name}`,
|
||||||
detail: lines.slice(1).join(' · '),
|
detail: lines.slice(1).join(' · '),
|
||||||
value: report.previous === undefined ? k.badge('accent', 'New') : k.badge('warn', 'Rejoin'),
|
value: report.facts.history.metBefore === true ? k.badge('warn', 'Rejoin') : k.badge('accent', report.facts.history.metBefore === false ? 'New' : 'History ?'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -97,13 +97,6 @@ export const settings = {
|
|||||||
default: '',
|
default: '',
|
||||||
placeholder: 'https://discord.com/api/webhooks/…',
|
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;
|
} as const satisfies SettingsSchema;
|
||||||
|
|
||||||
export type Settings = typeof settings;
|
export type Settings = typeof settings;
|
||||||
Reference in new issue
Block a user