From ecb5a3bcbeb86395e44cb53d4cc2871fc8e51ba0 Mon Sep 17 00:00:00 2001 From: blu Date: Sun, 27 Sep 2026 03:38:04 +0200 Subject: [PATCH] Make the report a template Co-Authored-By: Claude Fable 5.1 --- README.md | 8 ++++++- src/notify.ts | 58 ++++++++++++++++++++++++++++++------------------- src/panel.ts | 2 +- src/settings.ts | 21 ++++++++++++++++++ 4 files changed, 65 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index cb7904c..c91f2f2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,13 @@ In Group: Yes Rejoin?: Yes (2 hours ago) ``` -`In Group` only appears when a group filter is set. +That is the default **Report template**, editable in the plugin's settings. The first line is +the title on desktop, VR and Discord; the rest is the body. A line whose placeholders are all +empty is left out, which is why `In Group` only appears when a group filter is set. + +Placeholders: `{name}` `{userId}` `{ageVerified}` `{ageStatus}` `{pcRank}` `{questRank}` +`{avatar}` `{platform}` `{inGroup}` `{rejoin}` `{rejoinAgo}` `{world}` `{worldId}` +`{instanceType}` `{location}` `{time}`. ## Filters diff --git a/src/notify.ts b/src/notify.ts index 1b67f56..4d280be 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -10,13 +10,11 @@ * `hosts`, so that is the only webhook host accepted here. */ -import type { PluginContext, SettingsValues } from '@vrcnext/plugin-api'; +import { fillTemplate, timeAgo, type PluginContext, type SettingsValues } from '@vrcnext/plugin-api'; import type { Facts, Joiner } from './collector.js'; -import { timeAgo } from '@vrcnext/plugin-api'; - import type { Rejoin } from './history.js'; -import type { Settings } from './settings.js'; +import { DEFAULT_TEMPLATE, type Settings } from './settings.js'; import type { CurrentInstance } from './vrcnext-data.js'; export interface Report { @@ -49,21 +47,36 @@ function rejoinText(rejoin: Rejoin): string { return rejoin.lastAt === undefined ? 'Yes' : `Yes (${timeAgo(rejoin.lastAt)})`; } -/** The report as the spec lays it out, one fact per line. */ -export function reportLines(report: Report): readonly string[] { - const { facts } = report; +/** Everything a template may name. `undefined` means "not applicable", so its line is dropped. */ +export function reportValues(report: Report): Readonly> { + const { facts, joiner, instance } = report; const age = facts.ageVerified === undefined ? 'Unknown' : `${yesNo(facts.ageVerified)}${facts.ageStatus === '' ? '' : ` (${facts.ageStatus})`}`; - const lines = [ - `Player "${report.joiner.name}" joined`, - `18+ Verified: ${age}`, - `Avatar PC Performance Rank: ${rank(facts.avatar?.pc ?? '')}`, - `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.rejoin)}`); - return lines; + return { + name: joiner.name, + userId: joiner.userId, + ageVerified: age, + ageStatus: facts.ageStatus, + pcRank: rank(facts.avatar?.pc ?? ''), + questRank: rank(facts.avatar?.quest ?? ''), + avatar: facts.avatar?.name ?? '', + platform: facts.platform, + inGroup: report.groupFilter === '' ? undefined : yesNo(facts.inGroup, 'Unknown (only visible memberships count)'), + rejoin: rejoinText(facts.rejoin), + rejoinAgo: facts.rejoin.lastAt === undefined ? '' : timeAgo(facts.rejoin.lastAt), + world: instance.worldName, + worldId: instance.worldId, + instanceType: instance.instanceType, + location: instance.location, + time: new Date(report.at).toLocaleTimeString(), + }; +} + +/** The report through the user's template, one line per entry. An empty template means the default. */ +export function reportLines(report: Report, template: string = DEFAULT_TEMPLATE): readonly string[] { + const chosen = template.trim() === '' ? DEFAULT_TEMPLATE : template; + return fillTemplate(chosen, reportValues(report)).split('\n'); } /** Everything a single-line surface can hold. */ @@ -114,10 +127,10 @@ async function sendNative(ctx: Ctx, wants: { readonly desktop: boolean; readonly ctx.logger.debug('The bridge has no target for the enabled channels.'); return false; } - const lines = reportLines(report).slice(1); + const lines = reportLines(report, ctx.settings.values.template); const result = await ctx.native.notify({ - title: `${report.joiner.name} joined`, - content: lines.join('\n'), + title: lines[0] ?? `${report.joiner.name} joined`, + content: lines.slice(1).join('\n'), timeoutSecs: 8, icon: 'security-high', sinks, @@ -137,9 +150,10 @@ async function sendDesktopAndVr(ctx: Ctx, values: SettingsValues, repo // Windows without the bridge's targets: VRCNext's own tray toast and wrist overlay in one call. if (ctx.notifications.desktopAvailable) { + const lines = reportLines(report, values.template); ctx.notifications.desktop({ - title: `${report.joiner.name} joined`, - subtitle: reportLines(report).slice(1).join(' · '), + title: lines[0] ?? `${report.joiner.name} joined`, + subtitle: lines.slice(1).join(' · '), accent: accentFor(report), ...(report.joiner.userId === '' ? {} : { friendId: report.joiner.userId }), }); @@ -155,7 +169,7 @@ async function sendDiscord(ctx: Ctx, values: SettingsValues, report: R ctx.logger.warn('Discord webhook is enabled but the URL is not a discord.com webhook URL.'); return; } - const lines = reportLines(report); + const lines = reportLines(report, values.template); const colour = { ok: 0x3ba55d, warn: 0xed4245, info: 0x5865f2 }[accentFor(report)]; const body = { username: 'Club Security', diff --git a/src/panel.ts b/src/panel.ts index 9827a4b..f0bb6a7 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -109,7 +109,7 @@ export class ReportPanel { const k = this.#ctx.ui.kit; if (this.#reports.length === 0) return [k.emptyState('No joins reported yet.')]; return this.#reports.map((report) => { - const lines = reportLines(report); + const lines = reportLines(report, this.#ctx.settings.values.template); const time = new Date(report.at).toLocaleTimeString(); return k.row({ label: `${time} · ${report.joiner.name}`, diff --git a/src/settings.ts b/src/settings.ts index b5939e7..2b6b764 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -19,6 +19,16 @@ export const INSTANCE_TYPES = [ 'group-members', ] as const; +/** The report as first specified, one fact per line. */ +export const DEFAULT_TEMPLATE = [ + 'Player "{name}" joined', + '18+ Verified: {ageVerified}', + 'Avatar PC Performance Rank: {pcRank}', + 'Avatar Quest Performance Rank: {questRank}', + 'In Group: {inGroup}', + 'Rejoin?: {rejoin}', +].join('\n'); + export const settings = { enabled: { kind: 'boolean', @@ -68,6 +78,17 @@ export const settings = { max: 120, step: 5, }, + template: { + kind: 'string', + multiline: true, + label: 'Report template', + description: + 'The first line is the title on desktop, VR and Discord; the rest is the body. A line whose placeholders ' + + 'are all empty is left out, which is how "In Group" only appears with a group filter. Placeholders: ' + + '{name} {userId} {ageVerified} {ageStatus} {pcRank} {questRank} {avatar} {platform} {inGroup} {rejoin} ' + + '{rejoinAgo} {world} {worldId} {instanceType} {location} {time}.', + default: DEFAULT_TEMPLATE, + }, notifyToast: { kind: 'boolean', label: 'In-app toast',