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

+44 -3
View File
@@ -15,6 +15,8 @@
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 {
profileLoaded,
@@ -45,6 +47,7 @@ export interface Facts {
readonly avatar: AvatarFacts | undefined;
/** `undefined` when no group filter is configured. */
readonly inGroup: boolean | undefined;
readonly history: MeetHistory;
}
type Ctx = PluginContext<Settings>;
@@ -92,6 +95,8 @@ export class FactCollector {
#latest: CurrentInstance | undefined;
/** Performance ranks by avatar id, so a popular avatar is only fetched once. */
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) {
this.#ctx = ctx;
@@ -99,6 +104,10 @@ export class FactCollector {
const instance = toCurrentInstance(payload);
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) => {
const perf = toAvatarPerformance(payload);
if (perf !== undefined) {
@@ -112,12 +121,14 @@ export class FactCollector {
}
/** 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 [profile, inGroup, avatar] = await Promise.all([
const joinedAt = Date.now();
const [profile, inGroup, avatar, history] = await Promise.all([
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.#history(joiner, filter, joinedAt, { ms: deadlineMs, signal }),
]);
return {
ageVerified: profile === undefined ? undefined : profile.ageVerified,
@@ -125,9 +136,39 @@ export class FactCollector {
platform: profile?.platform ?? '',
avatar,
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> {
const current = this.#latest === undefined ? undefined : findUser(this.#latest, joiner);
if (current !== undefined && profileLoaded(current)) return current;
+34 -2
View File
@@ -4,9 +4,41 @@
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';
/** 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 {
readonly instanceTypes: readonly 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')));
}
export function instanceMatches(filter: InstanceFilter, instance: CurrentInstance): boolean {
export function instanceMatches(filter: InstanceFilter, instance: InstanceShape): boolean {
if (!typeMatches(filter.instanceTypes, instance.instanceType)) return false;
const groupId = filter.groupId.toLowerCase();
if (groupId !== '' && instance.groupId.toLowerCase() !== groupId) return false;
+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 { 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 { CurrentInstance } from './vrcnext-data.js';
@@ -24,7 +24,8 @@ export interface Report {
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;
/** Whether any filter was active, so the matching-instance count is only shown when it means something. */
readonly filtered: boolean;
}
type Ctx = PluginContext<Settings>;
@@ -41,10 +42,24 @@ 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})`;
/**
* `Yes (met 3× before, last 26/09/2026, 21:40 in Club X; 2 of the last 3 in matching instances)`.
* Counts come from VRCNext: `meetCount` from its meet_again event, the rest from the ten most
* 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. */
@@ -60,7 +75,7 @@ export function reportLines(report: Report): readonly string[] {
`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)}`);
lines.push(`Rejoin?: ${rejoinText(report.facts.history, report.filtered)}`);
return lines;
}
@@ -73,7 +88,7 @@ export function reportSummary(report: Report): string {
`Quest ${rank(facts.avatar?.quest ?? '').replace('Unknown', '?')}`,
];
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(' · ')}`;
}
+2 -21
View File
@@ -6,7 +6,6 @@
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';
@@ -16,7 +15,6 @@ type Ctx = PluginContext<Settings>;
const MAX_SHOWN = 25;
export interface PanelDeps {
readonly memory: JoinMemory;
readonly currentInstance: () => CurrentInstance | undefined;
readonly sendTest: () => Promise<void>;
}
@@ -73,13 +71,9 @@ export class ReportPanel {
icon: 'build',
children: [
k.description('A test report uses the current instance and your own name, and goes to every enabled channel.'),
k.description('Rejoin history comes from VRCNext’s own timeline, so there is nothing to clear here.'),
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(); },
}),
),
],
});
@@ -108,7 +102,6 @@ export class ReportPanel {
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)) }),
];
}
@@ -121,21 +114,9 @@ export class ReportPanel {
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'),
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: '',
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;