Skip to main content

Automation Template Engine

This guide documents the internals of the automation template engine in crates/lo-automation/src/template.rs.

Architecture

The template engine resolves {{variableName}} placeholders in strings against a TemplateContext. Variables are organized into typed categories (structs), each optionally gated by a permission.

Core Components

  • TemplateContext -- Holds all variable categories and the permissions of the executing user/account.
  • parse(template, context) -- The main entry point. Replaces {{variables}} with values from the context.
  • extract_variables(template) -- Returns all variable names found in a template string.
  • required_permissions(template) -- Returns the set of permissions needed to fully resolve the template.
  • permission_for_variable(var) -- Returns the permission required for a single variable.

TemplateContext

pub struct TemplateContext {
pub channel: Option<ChannelVars>,
pub user: Option<UserVars>,
pub stream: Option<StreamVars>,
pub event: Option<EventVars>,
pub sub: Option<SubVars>,
pub tip: Option<TipVars>,
pub spotify: Option<SpotifyVars>,
pub channel_status: Option<ChannelStatusVars>,
pub cheer: Option<CheerVars>,
pub raid: Option<RaidVars>,
pub redemption: Option<RedemptionVars>,
pub hype_train: Option<HypeTrainVars>,
pub poll: Option<PollVars>,
pub prediction: Option<PredictionVars>,
pub shopify: Option<ShopifyVars>,
pub obs: Option<ObsVars>,
pub discord: Option<DiscordVars>,
pub bot: Option<BotVars>,
pub time: Option<TimeVars>,
pub custom: HashMap<String, String>,
pub permissions: Vec<String>,
}

Each Option<*Vars> field is populated only when the relevant data is available. The permissions field holds the executing user's permissions -- an empty list means full access (owner/system context).

Adding a New Variable

1. Define the variable struct

Add a new struct to template.rs:

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MyNewVars {
pub field_one: Option<String>,
pub field_two: Option<String>,
}

2. Add the field to TemplateContext

pub struct TemplateContext {
// ... existing fields ...
pub my_new: Option<MyNewVars>,
}

3. Add resolution in resolve_simple_variable

Map camelCase template variable names to struct fields:

fn resolve_simple_variable(name: &str, ctx: &TemplateContext) -> Option<String> {
match name {
// ... existing matches ...

// MyNew (requires my:permission)
"fieldOne" if has_perm(&ctx.permissions, "my:permission") => {
ctx.my_new.as_ref()?.field_one.clone()
}
"fieldTwo" if has_perm(&ctx.permissions, "my:permission") => {
ctx.my_new.as_ref()?.field_two.clone()
}

_ => None,
}
}

4. Add permission mapping in permission_for_simple_variable

fn permission_for_simple_variable(name: &str) -> Option<&'static str> {
match name {
// ... existing matches ...

// MyNew
"fieldOne" | "fieldTwo" => Some("my:permission"),

_ => None,
}
}

5. Add dotted path support (if needed)

For variables accessed via {{myNew.fieldOne}}, add a match arm in resolve_variable:

["myNew", "fieldOne"] if has_perm(&ctx.permissions, "my:permission") => {
ctx.my_new.as_ref()?.field_one.clone()
}

Permission Gating

Every variable can be optionally gated by a permission string. The gating works through two mechanisms:

Resolution-time gating

In resolve_simple_variable and resolve_variable, each match arm uses a guard clause:

"songName" if has_perm(&ctx.permissions, "spotify:read") => { ... }

The has_perm helper returns true if:

  • The permissions list is empty (owner/system context, full access), or
  • The permissions list contains the required permission string.

If the guard fails, the variable is not matched and falls through to None, which the parser converts to an empty string (for known variables) or leaves as-is (for unknown variables).

Static analysis via required_permissions

The required_permissions(template) function statically analyzes a template to determine which permissions are needed:

let perms = required_permissions("Now playing: {{songName}} by {{songArtist}}");
// Returns: ["spotify:read"]

This is used at automation save time to warn users about missing permissions. It calls permission_for_variable for each extracted variable name, collecting unique permission strings.

Current permission mapping

PermissionVariable Groups
NoneChannel, Bot, Time, Shopify, OBS, Discord, Custom (var.*)
chat:userinfoUser (username, displayName, avatarUrl, userId)
events:readStream, Channel Status, Sub, Cheer, Raid, Redemption, Follower, Tip, Hype Train, Poll, Prediction, Event (event.*)
spotify:readSpotify (songName, songArtist, etc.)

Variable Resolution Order

  1. The parse function finds all {{variable}} patterns via regex.
  2. For each match, resolve_variable is called.
  3. Dotted paths (event.type, discord.serverName, var.myVar) are handled first via prefix matching.
  4. Single-segment names are delegated to resolve_simple_variable.
  5. If resolution returns None:
    • If the variable is a known permission-gated variable (checked via permission_for_variable), it resolves to an empty string (permission denied or data not populated).
    • Otherwise, the {{variable}} placeholder is left unchanged in the output.

Testing

The template engine has comprehensive tests in the same file. When adding new variables, add corresponding tests:

#[test]
fn test_my_new_variable() {
let ctx = TemplateContext {
my_new: Some(MyNewVars {
field_one: Some("hello".to_string()),
..Default::default()
}),
..Default::default()
};
assert_eq!(parse("Value: {{fieldOne}}", &ctx), "Value: hello");
}