diff --git a/README.md b/README.md index c91f2f2..c6634f6 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,22 @@ That is the default **Report template**, editable in the plugin's settings. The 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}`. +`{name}` is short for `{{ name }}`; the full template language (conditions, filters, `{% if %}` +blocks) is documented in the plugin system's API reference. For example: + +``` +{rejoinEmoji} {name} {{ "is back" if rejoin else "is new" }} {ageVerifiedEmoji} +{pcRankEmoji} PC {pcRankText} · {questRankEmoji} Quest {questRankText} +{% if inGroup == false %}⚠️ Not a group member{% endif %} +``` + +| Kind | Variables | +| :--- | :--- | +| Text | `name` `playerId` `ageVerifiedText` `ageStatus` `pcRankText` `questRankText` `avatar` `platform` `inGroupText` `rejoinText` `rejoinAgo` `rejoinSince` `rejoinAt` `world` `worldId` `instanceType` `instanceId` `location` `groupId` `time` `date` `timestamp` | +| Booleans (for conditions; empty when unknown) | `ageVerified` `inGroup` `rejoin`, plus `pcRank` and `questRank` (the raw rank, empty when unknown) | +| Emoji | `ageVerifiedEmoji` ✅❌❔ · `pcRankEmoji` / `questRankEmoji` 🟢🔵🟡🟠🔴⚪ · `platformEmoji` 🖥️📱🍎 · `inGroupEmoji` · `rejoinEmoji` 🔁🆕❔ | + +A template that does not parse is reported in the log and the default is used instead. ## Filters diff --git a/src/notify.ts b/src/notify.ts index 4d280be..81f99c4 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -10,7 +10,14 @@ * `hosts`, so that is the only webhook host accepted here. */ -import { fillTemplate, timeAgo, type PluginContext, type SettingsValues } from '@vrcnext/plugin-api'; +import { + TemplateError, + renderTemplate, + timeAgo, + type PluginContext, + type SettingsValues, + type TemplateValues, +} from '@vrcnext/plugin-api'; import type { Facts, Joiner } from './collector.js'; import type { Rejoin } from './history.js'; @@ -47,36 +54,89 @@ function rejoinText(rejoin: Rejoin): string { return rejoin.lastAt === undefined ? 'Yes' : `Yes (${timeAgo(rejoin.lastAt)})`; } -/** Everything a template may name. `undefined` means "not applicable", so its line is dropped. */ -export function reportValues(report: Report): Readonly> { +/** VRChat's performance rank colours, as emoji. */ +const RANK_EMOJI: Readonly> = { + Excellent: '🟢', Good: '🔵', Medium: '🟡', Poor: '🟠', VeryPoor: '🔴', +}; + +const PLATFORM_EMOJI: readonly (readonly [RegExp, string])[] = [ + [/windows/i, '🖥️'], [/android|quest/i, '📱'], [/ios/i, '🍎'], +]; + +function triState(value: boolean | undefined, yes: string, no: string, unknown = '❔'): string { + return value === undefined ? unknown : (value ? yes : no); +} + +/** + * Everything a template may name. `undefined` means "not applicable", so a line that only + * shows that value is dropped. Three flavours per fact: the boolean for conditions, `…Text` for + * the spec's wording, `…Emoji` for compact formats. + */ +export function reportValues(report: Report): TemplateValues { const { facts, joiner, instance } = report; - const age = facts.ageVerified === undefined - ? 'Unknown' - : `${yesNo(facts.ageVerified)}${facts.ageStatus === '' ? '' : ` (${facts.ageStatus})`}`; + const groupFiltered = report.groupFilter !== ''; + const pc = facts.avatar?.pc ?? ''; + const quest = facts.avatar?.quest ?? ''; + const seenHere = facts.rejoin.seenHere; + const at = new Date(report.at); return { name: joiner.name, + playerId: joiner.userId, userId: joiner.userId, - ageVerified: age, + ageVerified: facts.ageVerified, + ageVerifiedText: facts.ageVerified === undefined + ? 'Unknown' + : `${yesNo(facts.ageVerified)}${facts.ageStatus === '' ? '' : ` (${facts.ageStatus})`}`, + ageVerifiedEmoji: triState(facts.ageVerified, '✅', '❌'), ageStatus: facts.ageStatus, - pcRank: rank(facts.avatar?.pc ?? ''), - questRank: rank(facts.avatar?.quest ?? ''), + pcRank: pc === '' ? undefined : pc, + pcRankText: rank(pc), + pcRankEmoji: RANK_EMOJI[pc] ?? '⚪', + questRank: quest === '' ? undefined : quest, + questRankText: rank(quest), + questRankEmoji: RANK_EMOJI[quest] ?? '⚪', avatar: facts.avatar?.name ?? '', platform: facts.platform, - inGroup: report.groupFilter === '' ? undefined : yesNo(facts.inGroup, 'Unknown (only visible memberships count)'), - rejoin: rejoinText(facts.rejoin), + platformEmoji: PLATFORM_EMOJI.find(([re]) => re.test(facts.platform))?.[1] ?? '❔', + groupId: report.groupFilter, + inGroup: groupFiltered ? facts.inGroup : undefined, + inGroupText: groupFiltered ? yesNo(facts.inGroup, 'Unknown (only visible memberships count)') : undefined, + inGroupEmoji: groupFiltered ? triState(facts.inGroup, '✅', '❌') : undefined, + rejoin: seenHere, + rejoinText: rejoinText(facts.rejoin), + rejoinEmoji: triState(seenHere, '🔁', '🆕'), rejoinAgo: facts.rejoin.lastAt === undefined ? '' : timeAgo(facts.rejoin.lastAt), + rejoinSince: facts.rejoin.lastAt === undefined ? '' : new Date(facts.rejoin.lastAt).toLocaleString(), + rejoinAt: facts.rejoin.lastAt ?? '', world: instance.worldName, worldId: instance.worldId, instanceType: instance.instanceType, + instanceId: instance.location.split('~')[0]?.split(':')[1] ?? '', location: instance.location, - time: new Date(report.at).toLocaleTimeString(), + time: at.toLocaleTimeString(), + date: at.toLocaleDateString(), + timestamp: at.toISOString(), }; } -/** 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[] { +/** + * The report through the user's template, one line per entry. An empty template means the + * default; a template that does not parse falls back to it, with `onError` told why. + */ +export function reportLines( + report: Report, + template: string = DEFAULT_TEMPLATE, + onError?: (error: TemplateError) => void, +): readonly string[] { const chosen = template.trim() === '' ? DEFAULT_TEMPLATE : template; - return fillTemplate(chosen, reportValues(report)).split('\n'); + const values = reportValues(report); + try { + return renderTemplate(chosen, values).split('\n'); + } catch (error) { + if (!(error instanceof TemplateError)) throw error; + onError?.(error); + return renderTemplate(DEFAULT_TEMPLATE, values).split('\n'); + } } /** Everything a single-line surface can hold. */ @@ -99,6 +159,10 @@ function accentFor(report: Report): 'ok' | 'warn' | 'info' { return 'info'; } +function warnTemplate(ctx: Ctx, error: TemplateError): void { + ctx.logger.warn(`Report template is invalid, using the default: ${error.message}`); +} + /** Bridge targets: the VR overlay is the one whose name says so; the rest are desktop. */ function pickSinks(targets: readonly { readonly name: string }[], wantDesktop: boolean, wantVr: boolean): readonly string[] { return targets @@ -127,7 +191,7 @@ 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, ctx.settings.values.template); + const lines = reportLines(report, ctx.settings.values.template, (e) => { warnTemplate(ctx, e); }); const result = await ctx.native.notify({ title: lines[0] ?? `${report.joiner.name} joined`, content: lines.slice(1).join('\n'), @@ -150,7 +214,7 @@ 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); + const lines = reportLines(report, values.template, (e) => { warnTemplate(ctx, e); }); ctx.notifications.desktop({ title: lines[0] ?? `${report.joiner.name} joined`, subtitle: lines.slice(1).join(' · '), @@ -169,7 +233,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, values.template); + const lines = reportLines(report, values.template, (e) => { warnTemplate(ctx, e); }); const colour = { ok: 0x3ba55d, warn: 0xed4245, info: 0x5865f2 }[accentFor(report)]; const body = { username: 'Club Security', diff --git a/src/settings.ts b/src/settings.ts index 2b6b764..f435f7e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -22,11 +22,11 @@ export const INSTANCE_TYPES = [ /** 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}', + '18+ Verified: {ageVerifiedText}', + 'Avatar PC Performance Rank: {pcRankText}', + 'Avatar Quest Performance Rank: {questRankText}', + 'In Group: {inGroupText}', + 'Rejoin?: {rejoinText}', ].join('\n'); export const settings = { @@ -83,10 +83,11 @@ export const settings = { 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}.', + 'First line = title, rest = body. A line whose placeholders are all empty is left out. {name} is short for ' + + '{{ name }}; expressions like {{ "yes" if rejoin else "no" }}, {{ pcRank | upper }} and {% if inGroup == false %}…{% endif %} work. ' + + 'Text: name playerId ageVerifiedText ageStatus pcRankText questRankText avatar platform inGroupText rejoinText rejoinAgo ' + + 'rejoinSince world worldId instanceType instanceId location time date. Booleans: ageVerified inGroup rejoin. Emoji: ' + + 'ageVerifiedEmoji pcRankEmoji questRankEmoji platformEmoji inGroupEmoji rejoinEmoji.', default: DEFAULT_TEMPLATE, }, notifyToast: {