Rejoin history from VRCNext's timeline; drop the private join memory

This commit is contained in:
Claude committed 2026-09-27 02:31:14 +02:00
1 parent 8d6850c825
commit 49b675bdcd
10 files changed
+250 -162

No files matched your search

+4 -4
View File
@@ -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
+11 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
}
-91
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
} }
-7
View File
@@ -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;