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
+44
-3
@@ -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
@@ -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
@@ -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 { 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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user