Template expressions and more variables
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
ecb5a3bcbe
commit
166cedd972
3 files changed
+108
-30
No files matched your search
@@ -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
|
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.
|
empty is left out, which is why `In Group` only appears when a group filter is set.
|
||||||
|
|
||||||
Placeholders: `{name}` `{userId}` `{ageVerified}` `{ageStatus}` `{pcRank}` `{questRank}`
|
`{name}` is short for `{{ name }}`; the full template language (conditions, filters, `{% if %}`
|
||||||
`{avatar}` `{platform}` `{inGroup}` `{rejoin}` `{rejoinAgo}` `{world}` `{worldId}`
|
blocks) is documented in the plugin system's API reference. For example:
|
||||||
`{instanceType}` `{location}` `{time}`.
|
|
||||||
|
```
|
||||||
|
{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
|
## Filters
|
||||||
|
|
||||||
|
|||||||
+82
-18
@@ -10,7 +10,14 @@
|
|||||||
* `hosts`, so that is the only webhook host accepted here.
|
* `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 { Facts, Joiner } from './collector.js';
|
||||||
import type { Rejoin } from './history.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)})`;
|
return rejoin.lastAt === undefined ? 'Yes' : `Yes (${timeAgo(rejoin.lastAt)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything a template may name. `undefined` means "not applicable", so its line is dropped. */
|
/** VRChat's performance rank colours, as emoji. */
|
||||||
export function reportValues(report: Report): Readonly<Record<string, string | undefined>> {
|
const RANK_EMOJI: Readonly<Record<string, string>> = {
|
||||||
|
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 { facts, joiner, instance } = report;
|
||||||
const age = facts.ageVerified === undefined
|
const groupFiltered = report.groupFilter !== '';
|
||||||
? 'Unknown'
|
const pc = facts.avatar?.pc ?? '';
|
||||||
: `${yesNo(facts.ageVerified)}${facts.ageStatus === '' ? '' : ` (${facts.ageStatus})`}`;
|
const quest = facts.avatar?.quest ?? '';
|
||||||
|
const seenHere = facts.rejoin.seenHere;
|
||||||
|
const at = new Date(report.at);
|
||||||
return {
|
return {
|
||||||
name: joiner.name,
|
name: joiner.name,
|
||||||
|
playerId: joiner.userId,
|
||||||
userId: 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,
|
ageStatus: facts.ageStatus,
|
||||||
pcRank: rank(facts.avatar?.pc ?? ''),
|
pcRank: pc === '' ? undefined : pc,
|
||||||
questRank: rank(facts.avatar?.quest ?? ''),
|
pcRankText: rank(pc),
|
||||||
|
pcRankEmoji: RANK_EMOJI[pc] ?? '⚪',
|
||||||
|
questRank: quest === '' ? undefined : quest,
|
||||||
|
questRankText: rank(quest),
|
||||||
|
questRankEmoji: RANK_EMOJI[quest] ?? '⚪',
|
||||||
avatar: facts.avatar?.name ?? '',
|
avatar: facts.avatar?.name ?? '',
|
||||||
platform: facts.platform,
|
platform: facts.platform,
|
||||||
inGroup: report.groupFilter === '' ? undefined : yesNo(facts.inGroup, 'Unknown (only visible memberships count)'),
|
platformEmoji: PLATFORM_EMOJI.find(([re]) => re.test(facts.platform))?.[1] ?? '❔',
|
||||||
rejoin: rejoinText(facts.rejoin),
|
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),
|
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,
|
world: instance.worldName,
|
||||||
worldId: instance.worldId,
|
worldId: instance.worldId,
|
||||||
instanceType: instance.instanceType,
|
instanceType: instance.instanceType,
|
||||||
|
instanceId: instance.location.split('~')[0]?.split(':')[1] ?? '',
|
||||||
location: instance.location,
|
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;
|
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. */
|
/** Everything a single-line surface can hold. */
|
||||||
@@ -99,6 +159,10 @@ function accentFor(report: Report): 'ok' | 'warn' | 'info' {
|
|||||||
return '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. */
|
/** 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[] {
|
function pickSinks(targets: readonly { readonly name: string }[], wantDesktop: boolean, wantVr: boolean): readonly string[] {
|
||||||
return targets
|
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.');
|
ctx.logger.debug('The bridge has no target for the enabled channels.');
|
||||||
return false;
|
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({
|
const result = await ctx.native.notify({
|
||||||
title: lines[0] ?? `${report.joiner.name} joined`,
|
title: lines[0] ?? `${report.joiner.name} joined`,
|
||||||
content: lines.slice(1).join('\n'),
|
content: lines.slice(1).join('\n'),
|
||||||
@@ -150,7 +214,7 @@ async function sendDesktopAndVr(ctx: Ctx, values: SettingsValues<Settings>, repo
|
|||||||
|
|
||||||
// Windows without the bridge's targets: VRCNext's own tray toast and wrist overlay in one call.
|
// Windows without the bridge's targets: VRCNext's own tray toast and wrist overlay in one call.
|
||||||
if (ctx.notifications.desktopAvailable) {
|
if (ctx.notifications.desktopAvailable) {
|
||||||
const lines = reportLines(report, values.template);
|
const lines = reportLines(report, values.template, (e) => { warnTemplate(ctx, e); });
|
||||||
ctx.notifications.desktop({
|
ctx.notifications.desktop({
|
||||||
title: lines[0] ?? `${report.joiner.name} joined`,
|
title: lines[0] ?? `${report.joiner.name} joined`,
|
||||||
subtitle: lines.slice(1).join(' · '),
|
subtitle: lines.slice(1).join(' · '),
|
||||||
@@ -169,7 +233,7 @@ async function sendDiscord(ctx: Ctx, values: SettingsValues<Settings>, report: R
|
|||||||
ctx.logger.warn('Discord webhook is enabled but the URL is not a discord.com webhook URL.');
|
ctx.logger.warn('Discord webhook is enabled but the URL is not a discord.com webhook URL.');
|
||||||
return;
|
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 colour = { ok: 0x3ba55d, warn: 0xed4245, info: 0x5865f2 }[accentFor(report)];
|
||||||
const body = {
|
const body = {
|
||||||
username: 'Club Security',
|
username: 'Club Security',
|
||||||
|
|||||||
+10
-9
@@ -22,11 +22,11 @@ export const INSTANCE_TYPES = [
|
|||||||
/** The report as first specified, one fact per line. */
|
/** The report as first specified, one fact per line. */
|
||||||
export const DEFAULT_TEMPLATE = [
|
export const DEFAULT_TEMPLATE = [
|
||||||
'Player "{name}" joined',
|
'Player "{name}" joined',
|
||||||
'18+ Verified: {ageVerified}',
|
'18+ Verified: {ageVerifiedText}',
|
||||||
'Avatar PC Performance Rank: {pcRank}',
|
'Avatar PC Performance Rank: {pcRankText}',
|
||||||
'Avatar Quest Performance Rank: {questRank}',
|
'Avatar Quest Performance Rank: {questRankText}',
|
||||||
'In Group: {inGroup}',
|
'In Group: {inGroupText}',
|
||||||
'Rejoin?: {rejoin}',
|
'Rejoin?: {rejoinText}',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
export const settings = {
|
export const settings = {
|
||||||
@@ -83,10 +83,11 @@ export const settings = {
|
|||||||
multiline: true,
|
multiline: true,
|
||||||
label: 'Report template',
|
label: 'Report template',
|
||||||
description:
|
description:
|
||||||
'The first line is the title on desktop, VR and Discord; the rest is the body. A line whose placeholders ' +
|
'First line = title, rest = body. A line whose placeholders are all empty is left out. {name} is short for ' +
|
||||||
'are all empty is left out, which is how "In Group" only appears with a group filter. Placeholders: ' +
|
'{{ name }}; expressions like {{ "yes" if rejoin else "no" }}, {{ pcRank | upper }} and {% if inGroup == false %}…{% endif %} work. ' +
|
||||||
'{name} {userId} {ageVerified} {ageStatus} {pcRank} {questRank} {avatar} {platform} {inGroup} {rejoin} ' +
|
'Text: name playerId ageVerifiedText ageStatus pcRankText questRankText avatar platform inGroupText rejoinText rejoinAgo ' +
|
||||||
'{rejoinAgo} {world} {worldId} {instanceType} {location} {time}.',
|
'rejoinSince world worldId instanceType instanceId location time date. Booleans: ageVerified inGroup rejoin. Emoji: ' +
|
||||||
|
'ageVerifiedEmoji pcRankEmoji questRankEmoji platformEmoji inGroupEmoji rejoinEmoji.',
|
||||||
default: DEFAULT_TEMPLATE,
|
default: DEFAULT_TEMPLATE,
|
||||||
},
|
},
|
||||||
notifyToast: {
|
notifyToast: {
|
||||||
|
|||||||
Reference in new issue
Block a user