Advanced DTS¶
Helpers, custom maps, emoji, multi-language templates and interactive buttons. For the basics, see Alert Templates (DTS). For the fields each alert type provides, see the DTS Field Reference.
Handlebars Helpers¶
PoracleNG uses a fixed set of helpers, listed in the Helper Reference below. It does not include the @budibase/handlebars-helpers library that PoracleJS used. A helper that isn't in the list prints nothing and shows no error, so a template copied from PoracleJS can lose text without any warning. 5.3+ adds capitalizeAll, ellipsis, startsWith and remove to cover the PoracleJS helpers templates use most.
Conditionals¶
Every comparison helper can be used in three ways:
- as a block:
{{#eq a b}}…{{else}}…{{/eq}} - as a subexpression:
{{#if (eq a b)}} - inline:
{{eq a b}}printstrueorfalse
{{#if iv}}IV: {{round iv}}%{{else}}No IV data{{/if}}
{{#eq futureEventTrigger 'start'}}starting{{else}}ending{{/eq}}
{{#isnt formName 'Normal'}} {{formName}}{{/isnt}}
{{#compare gruntRewardsList.first.chance '<' 100}}{{gruntRewardsList.first.chance}}% chance{{/compare}}
{{#gt tthm 30}}More than 30 minutes left{{/gt}}
{{#and pvpAvailable userHasPvpTracks}}Show PVP info{{/and}}
{{#or pvpGreat pvpUltra}}Has PVP data{{/or}}
{{!-- "value is one of" — {{#or size 1 5}} would only test that some argument is truthy --}}
{{#oneOf size 1 5}}Tiny or huge!{{/oneOf}}
{{#if (and (gte iv 90) (lt cp 1500))}}Great league candidate{{/if}}
{{#unless confirmedTime}}⚠️ Unverified despawn time{{/unless}}
eq, ne, is, isnt and oneOf compare values as numbers when both sides look like numbers (so "100.00" equals 100), and as text otherwise. compare with ==/!= always compares text. gt, gte, lt and lte always compare numbers.
Iterating¶
{{#each pvpGreat}}
- {{fullName}} #{{rank}} @{{cp}}CP (Lvl. {{levelWithCap}}){{#unless isLast}},{{/unless}}
{{/each}}
{{#forEach gruntRewardsList.first.monsters}}{{this.name}}{{#unless isLast}}, {{/unless}}{{/forEach}}
{{!-- reach the alert's own fields from inside a loop --}}
{{#each evolutions}}{{fullName}} ({{../name}}){{/each}}
Inside each and forEach you can use the PoracleJS-style isFirst/isLast fields as well as @index, @first, @last and @key. forEach also gives @total. Both show their {{else}} block when the list is empty.
Helper Reference¶
Comparison and logic
| Helper | Usage |
|---|---|
eq / is |
{{#eq a b}} equal |
ne / isnt |
{{#ne a b}} not equal |
gt, gte, lt, lte |
Numeric comparisons |
compare |
{{#compare a '<=' b}}, with operators ==, !=, <, >, <=, >= |
and, or |
Any number of arguments: all true / any true |
neither |
Any number of arguments: none true |
oneOf |
{{#oneOf value a b c}}: value equals one of the others |
not |
{{#not x}}…{{else}}…{{/not}} (block only) |
contains |
{{#contains collection value}}: text contains a substring, or a list contains an item (block only) |
startsWith (5.3+) |
{{#startsWith "EX" gymName}}: the prefix comes first. Also works as a subexpression. |
default |
{{default value "fallback"}}: the value if it's set, otherwise the fallback |
Math (all return numbers)
| Helper | Usage |
|---|---|
add / plus |
{{add a b}} |
subtract / minus |
{{minus lureTypeId 500}} |
multiply, divide |
Dividing by 0 gives 0 |
round, floor, ceil |
{{round iv}} |
toFixed / numberFormat |
{{toFixed iv 1}}: fixed number of decimals |
toInt |
Drops the decimal part |
addCommas |
{{addCommas 125000}} gives 125,000 |
pad0 |
{{pad0 id}} gives 025, {{pad0 id 4}} gives 0025 |
There is no sum helper. Use add.
Text
| Helper | Usage |
|---|---|
uppercase, lowercase |
Change case |
capitalize |
Capitalise the first letter |
capitalizeAll (5.3+) |
Capitalise the first letter of every word: (old) church gives (Old) Church |
concat |
{{concat 'Level ' level ' raid'}} (any number of arguments) |
replace, replaceFirst |
{{replace s "old" "new"}} replaces every match; replaceFirst only the first |
remove (5.3+) |
{{remove gymName " (EX)"}} removes every match |
truncate |
{{truncate s 20}} or {{truncate s 20 suffix="…"}}. The suffix counts towards the length and defaults to .... Counts bytes. |
ellipsis (5.3+) |
{{ellipsis gymName 20}}: the first 20 characters, plus … if the text was longer |
escape |
HTML-escape a value |
Lists
| Helper | Usage |
|---|---|
each, forEach |
Loops (see above) |
length |
Length of a list, map or text |
join |
{{join typeNameEng ", "}} |
first, last |
{{first list}}, or {{first list n=2}} for the first two |
Game data (these use the template's language and platform)
| Helper | Usage |
|---|---|
pokemonName, pokemonNameEng, pokemonNameAlt |
{{pokemonName 25}} |
pokemonForm, pokemonFormEng, pokemonFormAlt |
{{pokemonForm formId}} |
pokemon |
Block helper, see below |
pokemonBaseStats |
{{#with (pokemonBaseStats id formId)}}{{baseAttack}}{{/with}} |
calculateCp |
{{calculateCp baseStats level atk def sta}} or {{calculateCp pokemonId formId level atk def sta}} |
getPowerUpCost |
{{getPowerUpCost level 40}} or as a block with stardust, candy and xlCandy |
moveName, moveNameEng, moveNameAlt |
{{moveName quickMoveId}} |
moveType, moveTypeEng, moveTypeAlt |
Move type name |
moveEmoji, moveEmojiEng, moveEmojiAlt |
Move type emoji (all three give the same result) |
getEmoji |
{{getEmoji 'shiny'}}, see Custom Emoji |
translateAlt |
Translate a key into the alt language |
map, map2 |
See Custom Maps |
Argument counts matter
Calling a helper with too few arguments (e.g. {{add 1}}) makes the whole template fail. The user gets a fallback message instead of the alert (Template render error: … on 5.3+, Template not found: … on 5.2) and the admin channel is notified.
Template Variables (@language, @platform, @altLanguage)¶
Each render sets three variables that templates can read:
| Variable | Value |
|---|---|
@language |
The recipient's language (or [general] locale) |
@platform |
discord or telegram |
@altLanguage |
[locale] language (default en); used by the *Alt helpers |
{{#eq @platform 'telegram'}}*bold*{{else}}**bold**{{/eq}}
{{#ne @language 'en'}}({{nameEng}}){{/ne}}
On 5.3+ help and greeting templates get @language and @platform too: !help and the !poracle greeting use the user's language, and greetings sent by role reconciliation use [general] locale. On 5.2 these templates have no variables, so their helpers always use English.
Pokemon Lookup Helpers¶
Pokemon Block Helper¶
{{#pokemon id formId}}
{{fullName}} ({{fullNameEng}}): {{join typeName "/"}} {{join typeEmoji ""}}
Base stats: {{baseStats.baseAttack}}/{{baseStats.baseDefense}}/{{baseStats.baseStamina}}
{{/pokemon}}
Inside the block you can use name, nameEng, formName, formNameEng, fullName, fullNameEng, typeName, typeNameEng, typeEmoji, baseStats and hasEvolutions. Note that:
typeName,typeNameEngandtypeEmojiare lists here (unlike the alert fields), so usejoin.fullNameputs the form in brackets, e.g.Pikachu (Libre), and leaves out Normal forms.- The form is optional on 5.3+:
{{#pokemon id}}uses the base form. On 5.2 both arguments are required (pass0as the form), and{{#pokemon id}}makes the render fail. - Used inline,
{{pokemon id formId}}prints thefullName.
CP and Power-Up Cost¶
{{calculateCp baseStats 40 15 15 15}}
{{calculateCp 150 0 50 15 15 15}}
{{#getPowerUpCost level 40}}
Cost: {{stardust}} Stardust, {{candy}} Candy{{#if xlCandy}}, {{xlCandy}} XL Candy{{/if}}
{{/getPowerUpCost}}
baseStats is available on pokemon, raid, maxbattle, quest (pokemon rewards), PVP entries and evolution entries.
Custom Maps¶
Custom maps are lookup tables you define in config/customMaps/*.json and read in templates with {{map}}. They have nothing to do with static map images. Use them for things like friendly area names, counters for raid bosses, or IV symbols.
Note
The file formats, block mode, {{else}} and language variants described below are 5.3+. On 5.2 each file is a flat {"key": "value"} object named after the file, and a missing key prints the key itself.
File formats¶
Each file can hold any of these:
[
{ "name": "arealist", "map": { "london": "London (Central)" } },
{ "name": "arealist", "language": "de", "map": { "london": "London (Zentrum)" } }
]
- A map is found by its
name, not the file name. The flat form (the last example) is named after the file, andarealist.de.jsonis the German version ofarealist. - Values can be text, numbers or objects.
- If a version exists for the recipient's language, it's checked first, then the version without a language.
- Maps are read at startup, so restart after changing them. Editors can list them with
GET /api/dts/custommaps.
examples/customMaps/ contains ivSymbol.json and raidCounters.json.
Using maps¶
{{!-- Inline: prints the value, or nothing if the key is missing --}}
{{map 'ivSymbol' atk}}{{map 'ivSymbol' def}}{{map 'ivSymbol' sta}}
{{!-- Subexpression: a missing key counts as false --}}
{{#if (map 'raidCounters' pokemon_id)}}Counters available{{/if}}
{{!-- Block: object values become the block's context; {{else}} runs when the key is missing --}}
{{#map 'raidCounters' pokemon_id}}Counters ({{trainers}} trainers): {{counters}}{{else}}No counters yet{{/map}}
{{!-- map2: try a second key if the first is missing --}}
{{map2 'nicknames' (concat pokemon_id '_' form) pokemon_id}}
{{!-- area names --}}
{{#each matched}}{{map 'arealist' this}} {{/each}}
Used inline, an object value prints nothing. Use the block form to get at its fields.
Custom Emoji¶
Emoji fields (typeEmoji, boostWeatherEmoji, genderEmoji, teamEmoji, lureTypeEmoji, bearingEmoji, shinyPossibleEmoji and so on) are looked up by key. Each key first checks your per-platform overrides in config/emoji.json, then the built-in defaults.
config/emoji.json maps platform → key → emoji:
{
"discord": {
"type-fire": "<:poracle_type_fire:878636941975896085>",
"weather-sunny": "<:poracle_weather_sunny:878660407739416586>",
"team-valor": "<:valor:123456789012345678>"
},
"telegram": {
"shiny": "🌟"
}
}
The built-in keys are:
| Group | Keys |
|---|---|
| Types | type-normal, type-fire, type-water, type-grass, type-electric, type-ice, type-fighting, type-poison, type-ground, type-flying, type-psychic, type-bug, type-rock, type-ghost, type-dragon, type-dark, type-steel, type-fairy |
| Weather | weather-sunny, weather-rain, weather-partly-cloudy, weather-cloudy, weather-windy, weather-snow, weather-fog |
| Teams | team-uncontested, team-mystic, team-valor, team-instinct |
| Lures | lure-normal, lure-glacial, lure-mossy, lure-magnetic, lure-rainy, lure-sparkly |
| Gender | gender-male, gender-female, gender-none |
| Directions | north, northeast, east, southeast, south, southwest, west, northwest |
| Incidents | incident-goldstop, incident-kecleon, incident-showcase |
| Showcase focus (5.3+) | showcase-focus-pokemon, showcase-focus-type, showcase-focus-alignment, showcase-focus-class, showcase-focus-family, showcase-focus-buddy, showcase-focus-generation, showcase-focus-hatched, showcase-focus-mega, showcase-focus-shiny (all 🏆 by default; used by showcaseFocusEmoji) |
| Other | shiny, grunt-unknown |
examples/emoji.json has a complete Discord set of custom emoji. Use any key directly with {{getEmoji 'team-valor'}}. After editing, run !poracle-admin emoji reload.
Multiple Templates¶
Give entries different id values to offer several styles:
[
{ "id": "1", "type": "monster", "platform": "discord", "default": true,
"template": { "embed": { "title": "Compact: {{round iv}}% {{name}}" } } },
{ "id": "2", "type": "monster", "platform": "discord", "name": "Detailed",
"template": { "embed": { "title": "Detailed: {{fullName}}", "description": "Full stats..." } } }
]
Users choose one with !track pikachu template:2.
Multi-Language Templates¶
Add a language to an entry to target that language. See Template Selection for the full order PoracleNG follows. Keep in mind:
- An entry without a
languagematches every language. - Ids don't carry over between languages: a user whose language has no entry for their id gets that language's
defaultentry. - If a language has no templates at all, a
defaultentry with no language is used if you have one, otherwise thedefaultentry for[general] locale. - A
defaultentry in the user's language beats adefaultentry with no language. (5.2 had this the wrong way round; see Template Selection.) - The fields themselves (pokemon names, moves, addresses) are always translated into the recipient's language. Use the
*Engfields or*Althelpers for a second language.
See Multi-Language for setting up languages, and examples/dts/multilingual/ for examples.
Broadcast Templates¶
Define reusable broadcast message templates in config/broadcast.json for admin broadcasts.
Channel Templates¶
Auto-create Discord channels — and optionally categories, private threads, and a "click to join" picker post — from templates in config/channelTemplate.json. See Channel Auto-create for the template format, bulk sync rules, and the !autocreate admin command.
Snapshots¶
Snapshots are an opt-in on-disk store of per-delivery enrichment views — the resolved data that was used to render each alert message. They are required for Interactive Buttons: button click handlers look up the snapshot of the message you clicked on to know which gym, pokemon, area, etc. you wanted to act on.
Enable in config.toml:
[snapshots]
enabled = true
# path = "config/.cache/snapshots" # relative to BaseDir
# max_age_days = 7 # safety-sweep grace period
# sweep_interval_mins = 60 # background sweep cadence
With snapshots disabled (the default), the render path skips Discord components entirely — any buttons declared in DTS render as inert no-ops at click time, surfacing as "This alert has expired." You can keep your button definitions in templates and flip snapshots on whenever you're ready.
Snapshots are per-delivery, not per-event — one snapshot per delivered message, per user, per channel. Edits re-render through the pipeline and overwrite the previous snapshot, so snapshots always reflect "what the user currently sees".
Interactive Buttons¶
A template entry may declare a buttons[] array attaching one or more Discord interactive buttons to the rendered message. Buttons let users mute one species or gym with a click, unsubscribe from a tracking rule, redeliver an alert to DM, or open a "show me the PVP detail" ephemeral follow-up — all without leaving the alert.
Buttons are operator-authored: they ship as part of the DTS entry, and the operator controls their action, visibility, and styling.
Prerequisite: snapshots
Buttons require [snapshots] enabled = true. Without snapshots, button click handlers have no resolved view to act against and clicks fail with "This alert has expired." See Snapshots above.
Minimal Example (JSON)¶
{
"type": "raid",
"platform": "discord",
"language": "en",
"id": "1",
"template": { "embed": { "title": "{{fullName}} @ {{gymName}}" } },
"buttons": [
{
"id": "mute_gym_1h",
"label": "Mute this gym (1h)",
"style": "danger",
"action": "mute",
"scope": "gym",
"params": { "duration_min": 60 }
}
]
}
Minimal Example (TOML)¶
[[entry]]
id = "1"
type = "raid"
platform = "discord"
language = "en"
template = """
{ "embed": { "title": "{{fullName}} @ {{gymName}}" } }
"""
[[entry.buttons]]
id = "mute_gym_1h"
label = "Mute this gym (1h)"
style = "danger"
action = "mute"
scope = "gym"
params = { duration_min = 60 }
Button Fields¶
| Field | Required | Notes |
|---|---|---|
id |
yes | Click-side identifier; encoded in the Discord custom_id. Unique per template entry. |
label |
yes | Button label shown to the user. |
style |
no | primary / secondary / success / danger. Defaults to secondary. |
| Dispatch (pick exactly one) | yes | One of the four shapes below. |
action |
one of dispatch | Named handler in the action registry — server-side state change (mute, unsubscribe, redeliver, render). |
response_template_id |
one of dispatch | DTS lookup against type="buttonResponse". |
response_template_inline |
one of dispatch | Raw Handlebars body; rendered against the snapshot at click time. |
response_text |
one of dispatch | Plain-text Handlebars (no JSON parsing). |
scope |
for action="mute" / "unsubscribe" |
gym, pokemon, area, pokestop, station, everything, tracking. Picks which snapshot field is the identifier. |
params |
no | Free-form bag passed verbatim to the handler. mute reads duration_min (default 60); render reads template_id. |
applies_to |
no | Destination filter — subset of ["dm", "channel", "webhook", "any"]. Defaults to ["dm"] for mute/unsubscribe, ["any"] otherwise. |
show_if |
no | Handlebars expression evaluated at render time; falsy → button is not attached at all. Use for conditional buttons (e.g. PVP-only). |
visible_to |
no | Access control: anyone (default), registered (clicker has a Poracle account), admin (clicker is in [discord] admins). On DMs the admin gate is also enforced at render time. On channels and webhooks the gate is click-time only (Discord doesn't support per-viewer component visibility). |
Dispatch Modes¶
-
action— named handler in the action registry. Writes server-side state (a mute, an unsubscribe, a redeliver). Reply is a short ephemeral confirmation. -
response_template_id— looks up an entry wheretype="buttonResponse". Useful when a response is shared between multiple buttons or alert types. -
response_template_inline— render the literal string (or string array, joined by newlines) as a Handlebars template against the snapshot. Same field access as alert templates. JSON-shaped output becomes ephemeral Discord embeds; otherwise it lands as plain content. -
response_text— plain-text Handlebars output. No JSON parsing. Use for one-liners like"📍 \`"`.}}, {{longitude}
Registered Actions¶
GET /api/dts/actions returns the live list. Currently:
| Action | Behaviour |
|---|---|
mute |
Writes a mute for the clicker. Scope picks the snapshot field (gym → gym_id, pokemon → pokemon_id, area → first matched area, everything → user-wide). params.duration_min (default 60). |
unsubscribe |
v1 only accepts scope = "tracking"; full rule-UID unsubscribe is a follow-up. |
redeliver |
DM the alert to the clicker. v1 sends a stub confirmation; full re-render is a follow-up. |
render |
Render a different template id (params.template_id) as the click response. Useful for "show me the PVP detail" without an inline body. |
show_if — Conditional Visibility¶
show_if is a Handlebars expression evaluated against the resolved view at render time. The button is attached only when the expression is truthy. Use it for buttons that only make sense on certain alerts:
{ "id": "pvp", "label": "Show PVP details", "show_if": "{{pvpAvailable}}", "response_template_inline": "..." }
visible_to — Access Control¶
| Value | Meaning |
|---|---|
anyone (default) |
Anyone with channel-view permission can click. |
registered |
The clicker must have a Poracle account. Good for buttons that reveal precise coords or address. |
admin |
The clicker must be in [discord] admins. On DMs the button is hidden entirely for non-admins; on channels every viewer sees the button but non-admin clicks are rejected. |
Click-time Failure Messages¶
These messages surface as ephemeral replies in Discord — operators should know what each means:
| Message | Cause |
|---|---|
This alert has expired. |
No snapshot found for the message ID. Either [snapshots] enabled = false, the snapshot's TTL passed, or the safety sweep already cleaned it. |
This button is no longer available. |
The DTS entry the button came from was removed or renamed since the alert was sent. |
This button doesn't apply here. |
The applies_to set doesn't include the destination type. Usually shouldn't happen — the renderer filters at attachment time. |
This button isn't for you. |
visible_to check failed (e.g. a non-admin clicked an admin-only button on a channel alert). |
Slow down — try that again in a moment. |
Click cooldown — same user / same button / within debounce window. |
Worked Example: Monster Card with Four Buttons¶
A working end-to-end example covering inline response templates, action buttons, and gated visibility lives at examples/dts/buttons/monster-with-pvp.toml in the PoracleNG repo. It demonstrates:
- "Show PVP details" — inline response template, gated with
show_if: "{{pvpAvailable}}". - "Show map links" —
visible_to: "registered"so precise location stays one click away for registered users only. - "Copy coordinates" — plain-text
response_textwith raw lat/lon for paste. - "Mute this species" —
action: "mute", scopepokemon, default DM-onlyapplies_to.
Editor Support¶
GET /api/dts/actions returns each action's name, accepted scopes, whether a scope is required, and the params keys the handler accepts. The PoracleWeb editor uses this to drive its button-authoring UI. See API.md in the PoracleNG repo for the full action registry endpoint.
Tips¶
- Test your changes with
!poracle-test(see Testing Templates). - Use
{{round iv}}rather than{{iv}}, which has two decimal places. - Use
{{#if field}}around optional fields. formNormalisedis already empty for Normal forms, so you don't need{{#isnt formName 'Normal'}}.- Check helper names against the Helper Reference. A misspelt helper prints nothing.
- Discord limits embed titles to 256 characters, descriptions to 4096 and footers to 2048. Longer text is cut off.
- Use
{{> partialName}}and@includeto share snippets between templates.