From ad608daf8bdeee26286df9a1acc5a40cd30879f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 02:38:26 +0200 Subject: [PATCH] Rejoin: same exact instance before, with time ago --- README.md | 7 ++- main.ts | 9 ++-- plugin.json | 3 +- src/collector.ts | 44 +++++------------ src/history.ts | 126 +++++++++++++++++------------------------------ src/notify.ts | 31 +++--------- src/panel.ts | 4 +- 7 files changed, 76 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 29a186c..cb7904c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Player "Tupper" joined Avatar PC Performance Rank: Good Avatar Quest Performance Rank: Poor In Group: Yes -Rejoin?: Yes (met 2× before; last 26/09/2026, 21:40:12 in Lovers Wedding; 1 of the last 3 in matching instances) +Rejoin?: Yes (2 hours ago) ``` `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. | | 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 | 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. | +| Rejoin | `getTimelineForUser`: the player's ten most recent timeline events, each with its location. Yes when one of them is this exact instance (same world and instance id) from before this join, with how long ago. | Survives VRCNext and VRChat restarts and reaches back to when VRCNext was installed, but only ten events deep per player. | Everything waits at most the configured number of seconds, then reports what it has, with `Unknown` for the rest. @@ -49,8 +49,7 @@ 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 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": not reported, and nothing -is lost because VRCNext records the meeting itself. +the settle window after it (or after a world change) as "already here" and does not report them. ## Layout and permissions diff --git a/main.ts b/main.ts index 9f016cc..a41e552 100644 --- a/main.ts +++ b/main.ts @@ -12,7 +12,7 @@ import { definePlugin, type PluginContext, type PluginId } from '@vrcnext/plugin-api'; import { FactCollector, type Joiner } from './src/collector.js'; -import { filterFrom, instanceMatches, type InstanceFilter } from './src/filters.js'; +import { filterFrom, instanceMatches } from './src/filters.js'; import { notifyAll, type Report } from './src/notify.js'; import { ReportPanel } from './src/panel.js'; import { settings } from './src/settings.js'; @@ -24,9 +24,6 @@ type Ctx = PluginContext; const JOIN_EVENT = 'gl_player_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 { readonly #ctx: Ctx; @@ -100,7 +97,7 @@ class ClubSecurity { return; } const facts = await this.#collector.collect(joiner, filter, values.collectTimeoutSecs * 1000); - const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId, filtered: isFiltered(filter) }; + const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId }; 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); @@ -130,7 +127,7 @@ class ClubSecurity { 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, 10_000); - const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId, filtered: isFiltered(filter) }; + const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId }; await notifyAll(this.#ctx, report); this.#ctx.notifications.toast({ message: 'Test report sent to every enabled channel.' }); } diff --git a/plugin.json b/plugin.json index 055d89d..c75c15d 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,7 @@ "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 VRCNext's meeting 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 whether they were in this instance with you before, 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": [ @@ -28,7 +28,6 @@ "vrcGetInstanceAvatars" ], "events": [ - "timelineEvent", "timelineForUser", "vrcAvatarDetail", "vrcCurrentInstance", diff --git a/src/collector.ts b/src/collector.ts index 1ba1377..43a6c00 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -16,7 +16,7 @@ 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 { rejoinIn, toTimelineEntries, UNKNOWN_REJOIN, type Rejoin } from './history.js'; import type { Settings } from './settings.js'; import { profileLoaded, @@ -47,7 +47,7 @@ export interface Facts { readonly avatar: AvatarFacts | undefined; /** `undefined` when no group filter is configured. */ readonly inGroup: boolean | undefined; - readonly history: MeetHistory; + readonly rejoin: Rejoin; } type Ctx = PluginContext; @@ -95,8 +95,6 @@ export class FactCollector { #latest: CurrentInstance | undefined; /** Performance ranks by avatar id, so a popular avatar is only fetched once. */ readonly #perfCache = new Map(); - /** The latest first_meet / meet_again VRCNext pushed per user; it can arrive before the join line. */ - readonly #meets = new Map(); constructor(ctx: Ctx) { this.#ctx = ctx; @@ -104,10 +102,6 @@ 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) { @@ -124,11 +118,11 @@ export class FactCollector { async collect(joiner: Joiner, filter: InstanceFilter, deadlineMs: number): Promise { const signal = this.#ctx.signal; const joinedAt = Date.now(); - const [profile, inGroup, avatar, history] = await Promise.all([ + const [profile, inGroup, avatar, rejoin] = await Promise.all([ this.#profile(joiner, deadlineMs, signal), filter.groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, filter.groupId, deadlineMs, signal), this.#avatar(joiner, deadlineMs, signal), - this.#history(joiner, filter, joinedAt, { ms: deadlineMs, signal }), + this.#rejoin(joiner, joinedAt, { ms: deadlineMs, signal }), ]); return { ageVerified: profile === undefined ? undefined : profile.ageVerified, @@ -136,37 +130,25 @@ export class FactCollector { platform: profile?.platform ?? '', avatar, inGroup, - history, + rejoin, }; } /** - * 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. + * Whether VRCNext's timeline already has this player in this exact instance. Legacy accounts + * without a `usr_` id cannot be looked up. */ - async #history( - joiner: Joiner, - filter: InstanceFilter, - joinedAt: number, - window: { readonly ms: number; readonly signal: AbortSignal }, - ): Promise { - 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( + async #rejoin(joiner: Joiner, joinedAt: number, window: { readonly ms: number; readonly signal: AbortSignal }): Promise { + const location = this.#latest?.location ?? ''; + if (joiner.userId === '' || location === '') return UNKNOWN_REJOIN; + const pending = waitFor( this.#ctx.events, 'timelineForUser', (payload) => toTimelineEntries(payload, joiner.userId), - { ms, signal }, + window, ); this.#ctx.bridge.send('getTimelineForUser', { userId: joiner.userId }); - const [meet, entries] = await Promise.all([meetPending, timelinePending]); - return summarise(meet, entries, filter, joinedAt); + return rejoinIn(await pending, location, joinedAt); } async #profile(joiner: Joiner, ms: number, signal: AbortSignal): Promise { diff --git a/src/history.ts b/src/history.ts index 7bc71d2..2a48834 100644 --- a/src/history.ts +++ b/src/history.ts @@ -1,51 +1,25 @@ /** - * Whether a joiner has been met before, from VRCNext's own timeline. + * Whether a joiner has been in this exact instance with you 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. + * `getTimelineForUser` returns the ten most recent timeline events involving the player, each with + * a timestamp and the location it happened in. VRCNext keeps those in SQLite, so they survive + * VRCNext and VRChat restarts and reach back to when VRCNext was installed. The check is strict on + * purpose: same world and same instance id, not "met somewhere" and not "an instance like this". */ -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 interface Rejoin { + /** `undefined` when VRCNext did not answer in time. */ + readonly seenHere: boolean | undefined; + /** When the earlier visit was recorded, as VRCNext's timestamp. */ + readonly lastAt: string | undefined; } -export const UNKNOWN_HISTORY: MeetHistory = { - metBefore: undefined, - meetCount: 0, - lastMet: undefined, - matchingBefore: undefined, - windowSize: 0, -}; +export const UNKNOWN_REJOIN: Rejoin = { seenHere: undefined, lastAt: undefined }; function rec(value: unknown): Record | undefined { return typeof value === 'object' && value !== null ? (value as Record) : undefined; @@ -55,17 +29,6 @@ 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); @@ -73,44 +36,47 @@ export function toTimelineEntries(payload: unknown, userId: string): readonly Ti 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']), - }]; + return entry === undefined ? [] : [{ timestamp: str(entry['timestamp']), location: str(entry['location']) }]; }); } +/** `wrld_…:12345` — the part of a location that identifies the instance, without its modifiers. */ +export function instanceKey(location: string): string { + return location.split('~')[0] ?? ''; +} + /** - * 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. + * Looks for an earlier event in the same instance. Events from this join are excluded by + * timestamp: anything at or after `joinedAt` describes this visit, not an earlier one. */ -export function summarise( - meet: MeetEvent | undefined, +export function rejoinIn( entries: readonly TimelineEntry[] | undefined, - filter: InstanceFilter, + location: string, 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); +): Rejoin { + if (entries === undefined) return UNKNOWN_REJOIN; + const key = instanceKey(location); + if (key === '') return { seenHere: false, lastAt: undefined }; + const earlier = entries + .filter((e) => instanceKey(e.location) === key) + .filter((e) => { + const at = Date.parse(e.timestamp); + return Number.isFinite(at) && at < joinedAt - 5_000; + }) + .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); 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, - }; + return { seenHere: last !== undefined, lastAt: last?.timestamp }; +} + +/** `3 minutes ago`, `2 hours ago`, `5 days ago`. */ +export function timeAgo(iso: string, now = Date.now()): string { + const ms = now - Date.parse(iso); + if (!Number.isFinite(ms) || ms < 0) return 'just now'; + const minutes = Math.floor(ms / 60_000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${String(minutes)} minute${minutes === 1 ? '' : 's'} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 48) return `${String(hours)} hour${hours === 1 ? '' : 's'} ago`; + const days = Math.floor(hours / 24); + return `${String(days)} day${days === 1 ? '' : 's'} ago`; } diff --git a/src/notify.ts b/src/notify.ts index dded2fb..d7a30d2 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -13,7 +13,7 @@ import type { PluginContext, SettingsValues } from '@vrcnext/plugin-api'; import type { Facts, Joiner } from './collector.js'; -import type { MeetHistory } from './history.js'; +import { timeAgo, type Rejoin } from './history.js'; import type { Settings } from './settings.js'; import type { CurrentInstance } from './vrcnext-data.js'; @@ -24,8 +24,6 @@ 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; - /** Whether any filter was active, so the matching-instance count is only shown when it means something. */ - readonly filtered: boolean; } type Ctx = PluginContext; @@ -42,24 +40,11 @@ function rank(value: string): string { return value === '' ? 'Unknown' : value; } -/** - * `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 says how many events it looked at. - */ -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('; ')})`; +/** `Yes (2 hours ago)`: this player was in this exact instance with you before, per VRCNext's timeline. */ +function rejoinText(rejoin: Rejoin): string { + if (rejoin.seenHere === undefined) return 'Unknown'; + if (!rejoin.seenHere) return 'No'; + return rejoin.lastAt === undefined ? 'Yes' : `Yes (${timeAgo(rejoin.lastAt)})`; } /** The report as the spec lays it out, one fact per line. */ @@ -75,7 +60,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.facts.history, report.filtered)}`); + lines.push(`Rejoin?: ${rejoinText(report.facts.rejoin)}`); return lines; } @@ -88,7 +73,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.facts.history.metBefore === undefined ? 'history ?' : (report.facts.history.metBefore ? 'rejoin' : 'new')); + bits.push(report.facts.rejoin.seenHere === undefined ? 'rejoin ?' : (report.facts.rejoin.seenHere ? 'rejoin' : 'new')); return `${report.joiner.name} joined · ${bits.join(' · ')}`; } diff --git a/src/panel.ts b/src/panel.ts index 4cab93d..9827a4b 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -71,7 +71,7 @@ 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.description('Rejoin is answered from VRCNext’s own timeline: was this player in this exact instance with you before.'), k.buttonRow( k.button({ label: 'Send test notification', icon: 'send', onClick: () => { void this.#deps.sendTest(); } }), ), @@ -114,7 +114,7 @@ export class ReportPanel { return k.row({ label: `${time} · ${report.joiner.name}`, detail: lines.slice(1).join(' · '), - value: report.facts.history.metBefore === true ? k.badge('warn', 'Rejoin') : k.badge('accent', report.facts.history.metBefore === false ? 'New' : 'History ?'), + value: report.facts.rejoin.seenHere === true ? k.badge('warn', 'Rejoin') : k.badge('accent', report.facts.rejoin.seenHere === false ? 'New' : 'Rejoin ?'), }); }); }