diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cbefe9..19373c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes will land here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/spec/v2.0.0.html). -## [0.1.16] - Delays up to 1 hour, Twitch events and alerts, smart lights, and two InstantClones side by side +## [0.1.16] - Delays up to 1 hour, Twitch events and alerts, smart lights, chat games and points, and two InstantClones side by side ### Delays up to 1 hour @@ -291,6 +291,74 @@ dock's destination list says "Can't connect", with the reason on hover. seconds, and Shelly and WLED are only ever asked directly: an answer pointing somewhere else counts as a no. +### Chat games and points + +What a chat economy needs, as steps you can build anything else with too. + +- **Viewer value** keeps a number per viewer under a name you pick (coins, + points), between streams. It gives, takes, sets, or **spends**: taken only + when the viewer has enough, with `{paid}` saying yes or no. It can also pay + **everyone who chatted lately**, or a viewer the step names. Read it + anywhere as `{viewer.coins}` or `{target.coins}`, add `.rank` for their + place, and `{top.coins}` lists the five who hold the most. The step's + editor shows who holds the most and lets you fix a viewer by hand. +- **Maths** adds, subtracts, multiplies, divides, takes a percent, the + smaller or bigger of two numbers, or a random number between them. +- **A "% chance" check** in If: `25` passes one time in four, `0.1` one in + a thousand. +- **Wait for chat** pauses a run until someone types something (anyone, the + viewer, or who they named), up to 5 minutes, and hands on who answered. + Duels, "first to type !grab" and turn-based games need nothing else. +- **Blocks**: a new kind of trigger that never starts on its own. A **Run a + block** step runs its steps by name with the run's values, so a check + written once serves every command. A block can run itself (a turn that + repeats), 8 deep at most. +- **Remember a value** can keep its text between runs and streams, read + back as `{saved.name}`. +- **A chat command can answer a viewer who is too early**, with + `{cooldown_left}`, once per wait. Cooldowns of 10 minutes or more now + survive a restart, so a daily bonus stays daily. +- **Stop** can leave its run out of the history, for a timer that rolls for + a rare drop every minute. +- **Your values** (a button on the Integrations tab): things you set once + and any message or step reads as `{my.name}`, like what a point is + called, how often a game is won or a list to pick from. Change one there + and everything that uses it follows. Each value shows what it is for, + where it is used, and one field made for it: a slider for a chance, a + box with one entry per line for a list. Its name and label sit behind + the pencil. A second tab shows what your viewers hold, where you can set + one viewer's amount or start everyone over. In a counting word, write a + value with a `$`: `{viewer.coins#$my.coin#$my.coins}` says "1 gem" or + "5 gems" in your words. A recipe brings the values it uses; one you + already set stays as you set it, and the import preview says so. +- **Channel points rewards, made and managed here.** A "Someone redeems + channel points" trigger now has a list to tick which rewards start it + (or any reward), and under it your rewards: a switch for each, edit + (name, cost, description, whether the viewer types a message) and + delete, plus **New reward**, all live on your channel without opening + Twitch. Renaming a reward there updates the integration you are editing. + A **Channel points reward** step switches one on or off during a run, so + a reward is only there when it makes sense. Twitch only lets an app + change rewards it made itself, and channel points need an Affiliate or + Partner channel. If you logged in to Twitch before this build, log out + and in again once under Connections to allow it; hearing redemptions + keeps working either way. +- **A chat message trigger can match whole words.** "Has the word" with + `nt` fires on "nt!" but not on "want" or "don't". New triggers start with + it; ones you already have keep "Contains these letters". Under the + choice, the editor says what it means and marks a few messages made from + your own text as firing or not, and **Try a message** tells you for any + message you type. +- **A test run starts from the values you give it**: `counter.wins`, + `viewer.coins` and `target.coins` set what the test reads, and what it + counts or pays stays in the test. Nothing saved changes. +- A counter is now read as it is at that moment, not as an earlier step of + the same run left it, so a run that waited sees what changed meanwhile. + The Counter step can also save its value under a fixed name. +- Twitch events (follow, sub, cheer, raid, channel points) bring + `{user_login}`, and chat commands `{target_login}`, so viewer values work + from them. + ### Integrations - **Why a run failed:** the editor's "Last ran · failed" and every Recent diff --git a/src/config.rs b/src/config.rs index 13a3d86..09fac2c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -170,6 +170,8 @@ pub struct Settings { /// Integrations (alerts, chat commands, automations). Each is saved as /// one `integration.=` line. See `crate::integrations`. pub integrations: Vec, + /// Values the streamer sets once for every integration (`{my.name}`). + pub user_values: Vec, /// Discord webhooks the integrations can post to. pub discord_channels: Vec, /// Phone pushes (ntfy). @@ -801,6 +803,18 @@ impl Settings { let look = self.alerts_look.to_json().to_json(); writeln!(f, "alerts.look={}", one_line(&look))?; } + for v in &self.user_values { + let kept = json::obj([ + ("label", json::str(&v.label)), + ("value", json::str(&v.value)), + ]); + writeln!( + f, + "integration_value.{}={}", + one_line(&v.name), + one_line(&kept.to_json()) + )?; + } for i in &self.integrations { writeln!( f, @@ -815,6 +829,25 @@ impl Settings { /// Load one integration line. A line that no longer parses (a hand edit, /// a newer version's step type) is skipped rather than failing the /// whole config: the other integrations and settings still load. + /// One `integration_value.=` line. A broken or repeated + /// one is skipped, like a broken integration. + fn load_user_value(&mut self, name: &str, value: &str) { + use crate::integrations::model::{UserValue, MAX_USER_VALUES}; + let Ok(mut parsed) = json::parse(value) else { + return; + }; + if let json::Value::Obj(fields) = &mut parsed { + fields.push(("name".to_string(), json::str(name))); + } + let Some(kept) = UserValue::from_json(&parsed) else { + return; + }; + let known = self.user_values.iter().any(|v| v.name == kept.name); + if !known && self.user_values.len() < MAX_USER_VALUES { + self.user_values.push(kept); + } + } + fn load_integration(&mut self, value: &str) { if self.integrations.len() >= MAX_INTEGRATIONS { return; @@ -963,6 +996,7 @@ impl Settings { midi_device: String::new(), crash_protection: CrashProtection::default(), integrations: Vec::new(), + user_values: Vec::new(), discord_channels: Vec::new(), phone: PhoneConnection::default(), alerts_look: Default::default(), @@ -1475,6 +1509,9 @@ impl Settings { } "integrations_migrated" => self.integrations_migrated = value == "true", k if k.starts_with("integration.") => self.load_integration(value), + k if k.starts_with("integration_value.") => { + self.load_user_value(&k["integration_value.".len()..], value) + } k if k.starts_with("connection.discord.") => { self.load_discord_channel(&k["connection.discord.".len()..], value) } @@ -3978,6 +4015,11 @@ Name@x" .unwrap(), crate::integrations::presets::build("mod_controls", "mods2".into(), "").unwrap(), ], + user_values: vec![crate::integrations::model::UserValue { + name: "coins".into(), + label: "What coins = called".into(), + value: "gems | \"shiny\"".into(), + }], discord_channels: vec![DiscordChannel { id: "mods".into(), name: "Mods = team".into(), diff --git a/src/integrations/api.rs b/src/integrations/api.rs index 0b6812a..3c2bab5 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -9,8 +9,9 @@ use super::event; use super::model::{ valid_discord_webhook, valid_id, DiscordChannel, Integration, PhoneConnection, Roles, Trigger, - MAX_INTEGRATIONS, + UserValue, MAX_INTEGRATIONS, MAX_USER_VALUES, }; +use super::twitch::helix::{RewardEdit, MAX_REWARD_PROMPT, MAX_REWARD_TITLE}; use super::{alert_media, alerts, engine, lights, obsws, presets, recipe, runner, twitch::Which}; use crate::config::Settings; use crate::controller::Controller; @@ -99,6 +100,14 @@ pub async fn route( } ("POST", "/integrations/duplicate") => duplicate(&req(), settings, cfg_path), ("POST", "/integrations/test") => test(&req(), &handle).await, + // The streamer's own values (`{my.name}`), all at once. + ("POST", "/integrations/values") => save_user_values(&req(), settings, cfg_path), + // What viewers hold (coins, points): the dashboard lists and edits it. + ("GET", "/integrations/viewers") => ok(handle.viewers_json()), + ("POST", "/integrations/viewers") => match handle.change_viewers(&req()) { + Ok(()) => ok(handle.viewers_json()), + Err(why) => fail(why), + }, // The live meter of a chat activity trigger being tuned. ("POST", "/integrations/meter") => match req().get("trigger").map(meter_rules) { Some(Ok(rules)) => ok(handle.chat_meter(&rules)), @@ -107,6 +116,9 @@ pub async fn route( }, ("GET", "/integrations/obs") => ok(handle.obs_status()), ("POST", "/integrations/lights/scan") => scan_lights().await, + // Channel points rewards made here: list, make, switch, delete. + ("GET", "/integrations/rewards") => rewards(&handle, RewardCall::List).await, + ("POST", "/integrations/rewards") => rewards(&handle, RewardCall::from(&req())).await, ("POST", "/integrations/lights/try") => try_light(&req()).await, ("POST", "/integrations/fetch") => fetch(&req()).await, ("POST", "/integrations/convert") => convert(&req()), @@ -434,6 +446,10 @@ fn overview( // For the "Report it" links, which fill in the issue form's version. ("version", json::str(env!("CARGO_PKG_VERSION"))), ("twitch_shared_chat", Value::Bool(s.twitch_shared_chat)), + ( + "values", + Value::Arr(s.user_values.iter().map(UserValue::to_json).collect()), + ), // Markers, clips and chat act on the logged-in channel; say so when // the Twitch destinations stream somewhere else. ( @@ -592,12 +608,19 @@ pub fn unknown_vars(integration: &Integration) -> Vec { .iter() .map(|(n, _)| n.to_string()) .collect(); - let mut prefixes = vec!["counter.".to_string(), "clip.".to_string()]; + // What is kept between runs is read by name; nothing lists the names. + let mut prefixes: Vec = [ + "counter.", "clip.", "viewer.", "target.", "top.", "saved.", "my.", + ] + .map(String::from) + .to_vec(); + known.push("active.count".to_string()); match &h.trigger { Trigger::Event { kind, .. } => { known.extend(kind.vars().iter().map(|(n, _)| n.to_string())) } - Trigger::ChatCommand { .. } | Trigger::ChatMessage { .. } => { + // A block runs inside whatever started it: most often a command. + Trigger::ChatCommand { .. } | Trigger::ChatMessage { .. } | Trigger::Block { .. } => { for n in 1..=9 { known.push(format!("arg{n}")); } @@ -624,6 +647,18 @@ pub fn unknown_vars(integration: &Integration) -> Vec { name => format!("{name}."), }), StepKind::SetVar => known.push(s.param("name").trim().to_string()), + StepKind::Counter if !s.param("save_as").trim().is_empty() => { + known.push(s.param("save_as").trim().to_string()) + } + StepKind::Viewer if s.param("op") == "spend" => known.push("paid".to_string()), + StepKind::Math => known.push(match s.param("save_as").trim() { + "" => "result".to_string(), + name => name.to_string(), + }), + StepKind::WaitChat => prefixes.push(match s.param("save_as").trim() { + "" => "reply.".to_string(), + name => format!("{name}."), + }), StepKind::EditText => known.push(match s.param("save_as").trim() { "" => "text".to_string(), name => name.to_string(), @@ -1003,6 +1038,106 @@ async fn scan_lights() -> Reply { )])) } +/// What the dashboard asks of the channel's rewards. +enum RewardCall { + List, + Create(RewardEdit), + Update { id: String, edit: RewardEdit }, + Delete { id: String }, + Invalid(String), +} + +impl RewardCall { + /// With an `id`: `delete` removes it, anything else changes the parts + /// that are given (`enabled` alone switches it). Without: makes one, + /// which needs a `title` and a `cost`. + fn from(req: &Value) -> RewardCall { + let id = req.str_or("id", "").to_string(); + if !id.is_empty() && req.bool_or("delete", false) { + return RewardCall::Delete { id }; + } + let text = |name: &str| { + req.get(name) + .and_then(Value::as_str) + .map(|t| t.trim().to_string()) + }; + let edit = RewardEdit { + title: text("title"), + cost: req.get("cost").and_then(Value::as_f64).map(|c| c as u64), + prompt: text("prompt"), + wants_input: req.get("wants_input").and_then(Value::as_bool), + enabled: req.get("enabled").and_then(Value::as_bool), + }; + let making = id.is_empty(); + let title_fits = |t: &String| !t.is_empty() && t.chars().count() <= MAX_REWARD_TITLE; + let problem = if making && (edit.title.is_none() || edit.cost.is_none()) { + Some("a reward needs a name and a cost".to_string()) + } else if !edit.title.as_ref().is_none_or(title_fits) { + Some(format!( + "a reward's name is 1 to {MAX_REWARD_TITLE} characters" + )) + } else if !edit + .cost + .is_none_or(|cost| (1..=1_000_000_000).contains(&cost)) + { + Some("a reward costs at least 1 point".to_string()) + } else if edit + .prompt + .as_ref() + .is_some_and(|p| p.chars().count() > MAX_REWARD_PROMPT) + { + Some(format!( + "a description is {MAX_REWARD_PROMPT} characters at most" + )) + } else { + None + }; + match (problem, making) { + (Some(why), _) => RewardCall::Invalid(why), + (None, true) => RewardCall::Create(RewardEdit { + enabled: Some(true), + ..edit + }), + (None, false) => RewardCall::Update { id, edit }, + } + } +} + +/// Do `call` on Twitch, then answer with the rewards as they are now. +async fn rewards(handle: &Arc, call: RewardCall) -> Reply { + let twitch = handle.twitch.clone(); + let listed = tokio::task::spawn_blocking(move || { + match call { + RewardCall::List => {} + RewardCall::Create(spec) => { + twitch.create_reward(&spec)?; + } + RewardCall::Update { id, edit } => twitch.update_reward(&id, &edit)?, + RewardCall::Delete { id } => twitch.delete_reward(&id)?, + RewardCall::Invalid(why) => return Err(why), + } + twitch.rewards() + }) + .await + .unwrap_or_else(|_| Err("the call to Twitch crashed".to_string())); + match listed { + Ok(list) => { + let rewards = list.iter().map(|r| { + json::obj([ + ("id", json::str(&r.id)), + ("title", json::str(&r.title)), + ("cost", Value::Num(r.cost as f64)), + ("enabled", Value::Bool(r.enabled)), + ("prompt", json::str(&r.prompt)), + ("wants_input", Value::Bool(r.wants_input)), + ]) + }); + ok(json::obj([("rewards", Value::Arr(rewards.collect()))])) + } + Err(why) => fail(why), + } +} + /// Flash a device so the user sees which one it is. async fn try_light(req: &Value) -> Reply { let device = req.str_or("device", "").to_string(); @@ -1088,7 +1223,12 @@ fn export(req: &Value, s: &Settings, cfg_path: &Path) -> Reply { ("ok", Value::Bool(true)), ( "recipe", - json::str(recipe::export(&name, &chosen, look, &folder)), + json::str(recipe::export( + &name, + &chosen, + (look, &folder), + &s.user_values, + )), ), ])) } @@ -1126,6 +1266,7 @@ fn import( ("items", Value::Arr(items)), ("uses_discord", Value::Bool(parsed.uses_discord())), ("local_effects", Value::Bool(parsed.has_local_effects())), + ("values", shared_values_review(&parsed, &settings.borrow())), ( "alerts", shared_alerts_review(&parsed, &settings.borrow().alerts_look), @@ -1173,6 +1314,13 @@ fn import( )); } s.integrations.extend(parsed.integrations); + // A value the importer already set stays theirs. + for value in parsed.values { + let known = s.user_values.iter().any(|v| v.name == value.name); + if !known && s.user_values.len() < MAX_USER_VALUES { + s.user_values.push(value); + } + } if let Some(theme) = theme { s.alerts_look = theme; } @@ -1228,6 +1376,50 @@ fn shared_alerts_review(parsed: &recipe::Recipe, mine: &alerts::Look) -> Value { ]) } +/// The values a recipe brings, for the import preview: each with whether +/// the importer already has one under that name (theirs is then kept). +fn shared_values_review(parsed: &recipe::Recipe, s: &Settings) -> Value { + let review = |value: &UserValue| { + let mine = s.user_values.iter().find(|v| v.name == value.name); + json::obj([ + ("name", json::str(&value.name)), + ("label", json::str(&value.label)), + ("value", json::str(&value.value)), + ("mine", mine.map_or(Value::Null, |v| json::str(&v.value))), + ]) + }; + Value::Arr(parsed.values.iter().map(review).collect()) +} + +/// Replace the streamer's own values with the list the dashboard sent. +fn save_user_values( + req: &Value, + settings: &Arc>, + cfg_path: &Path, +) -> Reply { + let sent = req.get("values").and_then(Value::as_array).unwrap_or(&[]); + let mut values: Vec = Vec::new(); + for value in sent { + let Some(value) = UserValue::from_json(value) else { + return fail(format!( + "\"{}\" can't be saved: a name is letters, digits and _, and a value has a length limit", + value.str_or("name", "") + )); + }; + if values.iter().any(|v| v.name == value.name) { + return fail(format!("two values are named \"{}\"", value.name)); + } + values.push(value); + } + if values.len() > MAX_USER_VALUES { + return fail(format!("at most {MAX_USER_VALUES} values")); + } + update(settings, cfg_path, |s| { + s.user_values = values; + Ok(()) + }) +} + /// Save the uploads a recipe brought and point its alerts at them. /// Returns the sharer's theme (pointed at those uploads too) when the /// importer chose to use it. @@ -1303,6 +1495,7 @@ fn trigger_review(i: &Integration) -> Vec { (format!("{command} in chat"), who_can(*roles)) } Trigger::ChatMessage { roles, .. } => ("a chat message".into(), who_can(*roles)), + Trigger::Block { name } => (format!("the block \"{name}\""), "its other triggers"), Trigger::ChatActivity(a) => ("chat gets busy".into(), who_can(a.roles)), Trigger::Webhook { .. } => ("a web call".into(), "apps with its secret link"), Trigger::Shortcut { .. } => ("a button".into(), "you"), @@ -1377,6 +1570,7 @@ fn summary(i: &Integration) -> String { Trigger::Event { kind, .. } => kind.label().to_string(), Trigger::ChatCommand { command, .. } => format!("{command} in chat"), Trigger::ChatMessage { .. } => "a chat message".to_string(), + Trigger::Block { name } => format!("the block \"{name}\" is run"), Trigger::Timer { every_ms, .. } => { format!("every {} min", every_ms / 60_000) } @@ -1706,8 +1900,8 @@ mod tests { json::str(recipe::export( "kit", std::slice::from_ref(&i), - None, - Path::new(""), + (None, Path::new("")), + &[], )), ), ("apply", Value::Bool(true)), @@ -1760,6 +1954,7 @@ mod tests { let mut parsed = recipe::Recipe { name: "shared".into(), integrations: Vec::new(), + values: Vec::new(), alerts: Some(recipe::SharedAlerts { look: alerts::Look::default(), media: vec![ @@ -1796,6 +1991,7 @@ mod tests { name: "shared".into(), integrations: vec![integration], alerts: None, + values: Vec::new(), }; keep_shared_alerts(&mut parsed, false, &cfg).unwrap(); let step = &parsed.integrations[0].handlers[0].steps[0]; diff --git a/src/integrations/chat_room.rs b/src/integrations/chat_room.rs new file mode 100644 index 0000000..2514971 --- /dev/null +++ b/src/integrations/chat_room.rs @@ -0,0 +1,139 @@ +//! What runs need from chat while they are going: who talked lately (to +//! reward the people who are there), and the next message a "Wait for +//! chat" step is waiting for. +//! +//! The engine tells it every chat message; steps ask it. + +use crate::sync::Mutex; +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use tokio::sync::oneshot; + +/// How long a chatter counts as there, at most. +pub const MAX_ACTIVE: Duration = Duration::from_secs(60 * 60); +/// Steps waiting for chat at once. Past this a new wait fails at once. +const MAX_WAITERS: usize = 64; + +/// A chat message a waiting step was handed. +#[derive(Clone, Debug, PartialEq)] +pub struct Heard { + pub user: String, + pub user_login: String, + pub message: String, +} + +struct Waiter { + /// What the message must start with, in lowercase; empty for anything. + starts_with: String, + /// The only login it listens to; None for anyone. + from: Option, + reply: oneshot::Sender, +} + +#[derive(Default)] +pub struct ChatRoom { + last_spoke: Mutex>, + waiters: Mutex>, +} + +impl ChatRoom { + /// A chat message arrived: note who spoke, and hand it to every step + /// that was waiting for it. + pub fn heard(&self, heard: &Heard, now: Instant) { + self.last_spoke.lock().insert(heard.user_login.clone(), now); + let text = heard.message.trim().to_lowercase(); + let mut waiters = self.waiters.lock(); + let (wanted, rest): (Vec, Vec) = waiters.drain(..).partition(|w| { + text.starts_with(&w.starts_with) + && w.from + .as_ref() + .is_none_or(|login| *login == heard.user_login) + }); + // A waiter whose step gave up (its wait ran out) is dropped here. + *waiters = rest.into_iter().filter(|w| !w.reply.is_closed()).collect(); + for waiter in wanted { + let _ = waiter.reply.send(heard.clone()); + } + } + + /// Wait for the next message that starts with `starts_with` (anything + /// when empty), from `from` or from anyone. + pub fn wait( + &self, + starts_with: &str, + from: Option, + ) -> Result, String> { + let mut waiters = self.waiters.lock(); + waiters.retain(|w| !w.reply.is_closed()); + if waiters.len() >= MAX_WAITERS { + return Err("too many steps are waiting for chat already".to_string()); + } + let (reply, answer) = oneshot::channel(); + waiters.push(Waiter { + starts_with: starts_with.trim().to_lowercase(), + from, + reply, + }); + Ok(answer) + } + + /// The logins that spoke within `within`, in name order. + pub fn active(&self, within: Duration, now: Instant) -> Vec { + let mut logins: Vec = self + .last_spoke + .lock() + .iter() + .filter(|(_, at)| now.saturating_duration_since(**at) <= within) + .map(|(login, _)| login.clone()) + .collect(); + logins.sort(); + logins + } + + /// Forget who spoke too long ago to count. + pub fn prune(&self, now: Instant) { + self.last_spoke + .lock() + .retain(|_, at| now.saturating_duration_since(*at) <= MAX_ACTIVE); + self.waiters.lock().retain(|w| !w.reply.is_closed()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn said(login: &str, message: &str) -> Heard { + Heard { + user: login.to_uppercase(), + user_login: login.to_string(), + message: message.to_string(), + } + } + + #[test] + fn a_wait_gets_the_first_message_that_fits() { + let room = ChatRoom::default(); + let mut anyone = room.wait("!grab", None).unwrap(); + let mut only_bo = room.wait("!accept", Some("bo".to_string())).unwrap(); + room.heard(&said("ana", "hello"), Instant::now()); + room.heard(&said("ana", "!accept"), Instant::now()); + assert!(anyone.try_recv().is_err()); + assert!(only_bo.try_recv().is_err(), "ana isn't who it waits for"); + room.heard(&said("ana", "!GRAB it"), Instant::now()); + room.heard(&said("bo", "!accept"), Instant::now()); + assert_eq!(anyone.try_recv().unwrap().user_login, "ana"); + assert_eq!(only_bo.try_recv().unwrap().user_login, "bo"); + } + + #[test] + fn only_recent_chatters_are_active() { + let room = ChatRoom::default(); + let start = Instant::now(); + room.heard(&said("old", "hi"), start); + room.heard(&said("new", "hi"), start + Duration::from_secs(500)); + let now = start + Duration::from_secs(600); + assert_eq!(room.active(Duration::from_secs(300), now), ["new"]); + assert_eq!(room.active(Duration::from_secs(900), now), ["new", "old"]); + } +} diff --git a/src/integrations/effects.rs b/src/integrations/effects.rs index e25ae87..6db58f1 100644 --- a/src/integrations/effects.rs +++ b/src/integrations/effects.rs @@ -310,6 +310,10 @@ impl Host for RealHost { self.twitch.marker(description) } + fn reward(&self, title: &str, enabled: bool) -> Result<(), String> { + self.twitch.set_reward_named(title, enabled) + } + fn clip(&self) -> Result { let id = self.twitch.clip()?; Ok(ClipInfo { diff --git a/src/integrations/engine.rs b/src/integrations/engine.rs index fe2fdd2..8992c4d 100644 --- a/src/integrations/engine.rs +++ b/src/integrations/engine.rs @@ -20,11 +20,12 @@ use super::activity::ChatWindow; use super::alerts::Board; +use super::chat_room::{ChatRoom, Heard}; use super::effects::RealHost; use super::event::{Event, EventKind}; use super::model::{ - pad_matches, ChatActivity, DiscordChannel, Integration, MatchMode, PhoneConnection, Roles, - Trigger, + self, pad_matches, ChatActivity, DiscordChannel, Integration, MatchMode, PhoneConnection, + Roles, Step, StepKind, Trigger, }; use super::obsws::{self, ObsNews}; use super::runner::{self, RunContext, RunEnv, RunStatus, StepLog}; @@ -76,9 +77,26 @@ fn held_back( too_soon.then_some(HeldBack::Cooldown) } -/// Whether a viewer used a chat command too recently to use it again. -fn viewer_cooling_down(last: Option, user_cooldown_ms: u64, now: Instant) -> bool { - last.is_some_and(|t| now.saturating_duration_since(t) < Duration::from_millis(user_cooldown_ms)) +/// How long a viewer still has to wait before using a chat command again, +/// in milliseconds; None when they may. Times are Unix milliseconds, so a +/// cooldown outlives a restart. +fn cooldown_left(last: Option, user_cooldown_ms: u64, now_ms: u64) -> Option { + let waited = now_ms.saturating_sub(last?); + (waited < user_cooldown_ms).then(|| user_cooldown_ms - waited) +} + +/// A cooldown this long or longer is saved to the data file: a daily bonus +/// must stay daily across a restart, and a 3 second one isn't worth a write. +const LASTING_COOLDOWN_MS: u64 = 10 * 60_000; + +/// `5h 12m`, `4m 10s` or `8s`: how long is left, as chat would say it. +fn cooldown_left_text(ms: u64) -> String { + let seconds = ms.div_ceil(1000); + match seconds { + 3600.. => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60), + 60.. => format!("{}m {}s", seconds / 60, seconds % 60), + _ => format!("{seconds}s"), + } } /// Each viewer waits out a command's cooldown on their own. @@ -230,7 +248,55 @@ pub struct Handle { activity_held: Mutex<&'static str>, } +/// Viewers the dashboard lists per value: the ones holding the most. +const VIEWERS_LISTED: usize = 100; + impl Handle { + /// Every viewer value with how many viewers hold some, and who holds + /// the most. + pub fn viewers_json(&self) -> Value { + let viewers = self.store.viewers.lock(); + let values = viewers.names().into_iter().map(|(name, holders)| { + let top = viewers + .top(&name, VIEWERS_LISTED) + .into_iter() + .map(|(login, amount)| { + json::obj([ + ("login", json::str(login)), + ("amount", Value::Num(amount as f64)), + ]) + }); + json::obj([ + ("name", json::str(&name)), + ("holders", Value::Num(holders as f64)), + ("top", Value::Arr(top.collect())), + ]) + }); + json::obj([("values", Value::Arr(values.collect()))]) + } + + /// The dashboard setting what one viewer holds (`value`, `login`, + /// `amount`), or forgetting a whole value (`value`, `clear`). + pub fn change_viewers(&self, req: &Value) -> Result<(), String> { + let value = req.str_or("value", "").trim(); + if !super::viewers::valid_name(value) { + return Err("that isn't a viewer value's name".to_string()); + } + let mut viewers = self.store.viewers.lock(); + if req.bool_or("clear", false) { + viewers.clear(value); + } else { + let login = super::viewers::login_key(req.str_or("login", "")); + let amount = req.get("amount").and_then(Value::as_f64); + let (false, Some(amount)) = (login.is_empty(), amount) else { + return Err("name the viewer and the amount".to_string()); + }; + viewers.apply(value, &login, super::viewers::Change::Set(amount as i64))?; + } + self.store.dirty.store(true, Ordering::Relaxed); + Ok(()) + } + /// Report something that happened. Never blocks; safe from any thread. pub fn emit(&self, event: Event) { if self.tx.try_send(Input::Event(event)).is_err() { @@ -599,7 +665,13 @@ struct Engine { /// Messages from Shared Chat partners count too (`twitch_shared_chat`). shared_chat: bool, last_run: HashMap, - viewer_last: HashMap, + /// When each viewer last used each chat command (`viewer_key`), in + /// Unix milliseconds. The long ones are in the store too. + viewer_last: HashMap, + /// The viewers already told they are on cooldown, so each hears it + /// once per wait. + cooldown_told: HashMap, + chat_room: Arc, timers: HashMap, /// Per integration: (started, doing something). See the module comment. limits: HashMap, Arc)>, @@ -664,13 +736,15 @@ impl Engine { let discord_messages = Arc::new(Mutex::new(HashMap::new())); let discord_turns = Arc::new(runner::DiscordTurns::default()); let timeline = Arc::new(Mutex::new(Timeline::default())); + let chat_room = Arc::new(ChatRoom::default()); let env = Arc::new(make_env( - Vec::new(), + (Vec::new(), Vec::new()), PhoneConnection::default(), - &store, + (&store, &chat_room), (&discord_messages, &discord_turns), (&timeline, &handle), )); + let viewer_last = store.cooldowns.lock().clone().into_iter().collect(); Engine { handle, host, @@ -680,7 +754,9 @@ impl Engine { env, shared_chat: false, last_run: HashMap::new(), - viewer_last: HashMap::new(), + viewer_last, + cooldown_told: HashMap::new(), + chat_room, timers: HashMap::new(), limits: HashMap::new(), global: ( @@ -765,9 +841,9 @@ impl Engine { fn apply_settings(&mut self, s: &Settings) { self.integrations = s.integrations.iter().cloned().map(Arc::new).collect(); self.env = Arc::new(make_env( - s.discord_channels.clone(), + (s.discord_channels.clone(), s.user_values.clone()), s.phone.clone(), - &self.store, + (&self.store, &self.chat_room), (&self.discord_messages, &self.discord_turns), (&self.timeline, &self.handle), )); @@ -781,6 +857,7 @@ impl Engine { let keep = |key: &String| alive.iter().any(|id| key.starts_with(&format!("{id}#"))); self.last_run.retain(|k, _| keep(k)); self.viewer_last.retain(|k, _| keep(k)); + self.cooldown_told.retain(|k, _| keep(k)); self.timers.retain(|k, _| keep(k)); self.still_down_fired.retain(|k, _| keep(k)); self.steady_fired.retain(|k, _| keep(k)); @@ -806,7 +883,17 @@ impl Engine { } } } - self.viewer_last.retain(|_, t| t.elapsed() < longest_viewer); + let (now, longest) = (unix_ms(), longest_viewer.as_millis() as u64); + let waiting = |at: &u64| now.saturating_sub(*at) < longest; + self.viewer_last.retain(|_, at| waiting(at)); + self.cooldown_told.retain(|_, at| waiting(at)); + { + let mut saved = self.store.cooldowns.lock(); + let before = saved.len(); + saved.retain(|key, _| self.viewer_last.contains_key(key)); + self.uses_dirty |= saved.len() != before; + } + self.chat_room.prune(Instant::now()); self.last_run.retain(|_, t| t.elapsed() < longest_run); self.flap.prune(Instant::now()); // Here rather than on every settings change: a delete undone within @@ -830,7 +917,7 @@ impl Engine { } fn save_uses_if_due(&mut self) { - self.uses_dirty |= self.store.counters_dirty.swap(false, Ordering::Relaxed); + self.uses_dirty |= self.store.dirty.swap(false, Ordering::Relaxed); if self.uses_dirty && self.uses_saved.elapsed() >= USES_SAVE_EVERY { self.uses_saved = Instant::now(); // A failed write stays due, for the next round. @@ -1190,12 +1277,23 @@ impl Engine { return; } self.handle.chat.lock().push(&msg, Instant::now()); + self.chat_room.heard( + &Heard { + user: msg.display_name.clone(), + user_login: msg.user_login.clone(), + message: msg.text.clone(), + }, + Instant::now(), + ); self.check_activity(&msg); let text = msg.text.trim(); let mut words = text.split_whitespace(); let first = words.next().unwrap_or("").to_lowercase(); let args: Vec<&str> = words.collect(); - let candidates: Vec<(Arc, usize, String)> = self + let now = unix_ms(); + // (integration, trigger, label, the viewer's cooldown, how much of it is left) + type Candidate = (Arc, usize, String, u64, Option); + let candidates: Vec = self .enabled_handlers() .filter_map(|(i, idx, h)| match &h.trigger { Trigger::ChatCommand { @@ -1203,27 +1301,31 @@ impl Engine { aliases, roles, user_cooldown_ms, + .. } if (*command == first || aliases.contains(&first)) && allowed(*roles, &msg) => { let last = self .viewer_last .get(&viewer_key(&i.id, idx, &msg.user_login)); - let cooling = - viewer_cooling_down(last.copied(), *user_cooldown_ms, Instant::now()); - (!cooling).then(|| (i.clone(), idx, first.clone())) + let left = cooldown_left(last.copied(), *user_cooldown_ms, now); + Some((i.clone(), idx, first.clone(), *user_cooldown_ms, left)) } Trigger::ChatMessage { pattern, mode, roles, } if allowed(*roles, &msg) && text_matches(text, pattern, *mode) => { - Some((i, idx, "chat".to_string())) + Some((i, idx, "chat".to_string(), 0, None)) } _ => None, }) .collect(); - for (integration, idx, label) in candidates { + for (integration, idx, label, cooldown_ms, left) in candidates { let key = viewer_key(&integration.id, idx, &msg.user_login); let vars = chat_vars(&msg, &args); + if let Some(left) = left { + self.tell_cooldown(&integration, idx, (&msg, vars), left); + continue; + } let trigger = if label == "chat" { "Chat message".to_string() } else { @@ -1232,11 +1334,58 @@ impl Engine { // A command that didn't run (quiet hours, busy) doesn't make the // viewer wait before trying again. if self.start_run(integration, idx, vars, Some(msg.id.clone()), trigger) { - self.viewer_last.insert(key, Instant::now()); + self.cooldown_told.remove(&key); + if cooldown_ms >= LASTING_COOLDOWN_MS { + self.store.cooldowns.lock().insert(key.clone(), now); + self.uses_dirty = true; + } + self.viewer_last.insert(key, now); } } } + /// Answer a viewer who used a command before their cooldown was over, + /// when the command has such an answer: once per wait, and never in + /// the integration's quiet hours. + fn tell_cooldown( + &mut self, + integration: &Integration, + idx: usize, + (msg, mut vars): (&ChatMessage, BTreeMap), + left_ms: u64, + ) { + let Trigger::ChatCommand { cooldown_reply, .. } = &integration.handlers[idx].trigger else { + return; + }; + let clock = super::clock::now(); + let minute_of_day = (clock.hour * 60 + clock.minute) as u16; + let quiet = integration.quiet.is_some_and(|q| q.contains(minute_of_day)); + let key = viewer_key(&integration.id, idx, &msg.user_login); + if cooldown_reply.is_empty() || quiet || self.cooldown_told.contains_key(&key) { + return; + } + self.cooldown_told.insert(key, unix_ms()); + vars.insert("cooldown_left".to_string(), cooldown_left_text(left_ms)); + let steps = [Step::new( + StepKind::Chat, + &[("text", cooldown_reply), ("reply", "yes")], + )]; + let ctx = RunContext { + integration_id: integration.id.clone(), + vars, + reply_to: Some(msg.id.clone()), + test: false, + dry: false, + gate: None, + blocks: Default::default(), + }; + let host: Arc = self.host.clone(); + let env = self.env.clone(); + tokio::spawn(async move { + runner::run(&steps, ctx, host, env).await; + }); + } + /// Why chat activity triggers that only run while streaming can't fire /// now, or empty; "hold" holds back every one. For the meter. fn activity_gate(&self, now: Instant) -> &'static str { @@ -1686,6 +1835,7 @@ impl Engine { test: false, dry: false, gate: Some(runner::Gate::new(places, vec![active, active_all])), + blocks: Arc::new(model::blocks(&integration)), }; let handle = self.handle.clone(); let ctrl = self.ctrl.clone(); @@ -1696,6 +1846,9 @@ impl Engine { let at_ms = unix_ms(); let (status, steps) = runner::run(&integration.handlers[idx].steps, ctx, host, env).await; + if status == RunStatus::StoppedQuietly { + return; + } let status = status_id(&status); if status == "failed" { if let Some(step) = steps @@ -1769,6 +1922,7 @@ impl Engine { test: true, dry: run.dry, gate: None, + blocks: Arc::new(model::blocks(&integration)), }; let host: Arc = self.host.clone(); let env = self.env.clone(); @@ -1896,7 +2050,7 @@ impl FlapGuard { fn status_id(status: &RunStatus) -> &'static str { match status { RunStatus::Ok => "ok", - RunStatus::Stopped => "stopped", + RunStatus::Stopped | RunStatus::StoppedQuietly => "stopped", RunStatus::Failed => "failed", } } @@ -1910,25 +2064,27 @@ fn forget_subject_messages(messages: &mut HashMap) { } fn make_env( - discord: Vec, + (discord, user_values): (Vec, Vec), phone: PhoneConnection, - store: &Arc, + (store, chat_room): (&Arc, &Arc), (discord_messages, discord_turns): (&DiscordMessages, &Arc), (timeline, handle): (&Arc>, &Arc), ) -> RunEnv { - let saver = store.clone(); + let dirty = store.dirty.clone(); // Weak: the env lives inside the engine the handle owns a channel to. let events = Arc::downgrade(handle); RunEnv { discord, phone, counters: store.counters.clone(), + viewers: store.viewers.clone(), + saved: store.saved.clone(), // Written with the run counts a moment later (`save_uses_if_due`): // a chat command counting every message must not rewrite the file // each time, on the engine's own thread. - counters_changed: Box::new(move || { - saver.counters_dirty.store(true, Ordering::Relaxed); - }), + data_changed: Box::new(move || dirty.store(true, Ordering::Relaxed)), + chat_room: chat_room.clone(), + user_values: user_values.into_iter().map(|v| (v.name, v.value)).collect(), discord_messages: discord_messages.clone(), discord_turns: discord_turns.clone(), timeline: timeline.clone(), @@ -2017,6 +2173,14 @@ fn chat_vars(msg: &ChatMessage, args: &[&str]) -> BTreeMap { .filter(|a| !a.is_empty()) .unwrap_or_else(|| msg.display_name.clone()), ), + // The same viewer as Twitch knows them: what viewer values go by. + ( + "target_login".to_string(), + args.first() + .map(|a| super::viewers::login_key(a)) + .filter(|a| !a.is_empty()) + .unwrap_or_else(|| msg.user_login.clone()), + ), ]); for (i, arg) in args.iter().take(9).enumerate() { vars.insert(format!("arg{}", i + 1), arg.to_string()); @@ -2052,12 +2216,15 @@ fn sample_vars(trigger: &Trigger) -> (BTreeMap, String) { ("user_login", "testviewer"), ("user_role", "mod"), ("target", "TestViewer"), + ("target_login", "testviewer"), ("message", &format!("{command} 30")), ("args", "30"), ("arg1", "30"), ]), format!("Test: {command}"), ), + // A block runs inside whatever started it: most often a command. + Trigger::Block { name } => (owned(runner::CHAT_VARS), format!("Test: block {name}")), Trigger::ChatMessage { pattern, .. } => ( owned(&[ ("user", "TestViewer"), @@ -2114,6 +2281,7 @@ pub fn text_matches(text: &str, pattern: &str, mode: MatchMode) -> bool { return false; } match mode { + MatchMode::Word => has_words(&text, &pattern), MatchMode::Contains => text.contains(&pattern), MatchMode::StartsWith => text.starts_with(&pattern), MatchMode::Exact => text.trim() == pattern, @@ -2121,6 +2289,18 @@ pub fn text_matches(text: &str, pattern: &str, mode: MatchMode) -> bool { } } +/// Whether `pattern` is in `text` as whole words: with no letter or digit +/// right before or after it. An apostrophe ends a word, so "nt" is not in +/// "don't". +fn has_words(text: &str, pattern: &str) -> bool { + let inside_a_word = |c: Option| c.is_some_and(char::is_alphanumeric); + text.match_indices(pattern).any(|(at, found)| { + let before = text[..at].chars().next_back(); + let after = text[at + found.len()..].chars().next(); + !inside_a_word(before) && !inside_a_word(after) + }) +} + /// `*` = any run of characters, `?` = one character. Iterative, so a /// hostile pattern cannot blow up the way naive backtracking would. fn wildcard(text: &str, pattern: &str) -> bool { @@ -2321,14 +2501,23 @@ mod tests { viewer_key("i", 1, "ana"), "per trigger too" ); - let now = Instant::now() + Duration::from_secs(60); - let used = Some(now - Duration::from_millis(5_000)); - assert!(viewer_cooling_down(used, 30_000, now)); - assert!( - !viewer_cooling_down(used, 5_000, now), - "over once it has passed" + let now = 1_000_000; + let used = Some(now - 5_000); + assert_eq!(cooldown_left(used, 30_000, now), Some(25_000)); + assert_eq!(cooldown_left(used, 5_000, now), None, "over once it passed"); + assert_eq!(cooldown_left(None, 30_000, now), None, "first time"); + assert_eq!( + cooldown_left(Some(now + 500), 30_000, now), + Some(30_000), + "clock set back" ); - assert!(!viewer_cooling_down(None, 30_000, now), "first time"); + } + + #[test] + fn a_cooldown_left_reads_like_chat_would_say_it() { + assert_eq!(cooldown_left_text(7_200), "8s"); + assert_eq!(cooldown_left_text(250_000), "4m 10s"); + assert_eq!(cooldown_left_text(18_720_000), "5h 12m"); } #[test] @@ -2337,6 +2526,23 @@ mod tests { assert!(text_matches(" GG ", "gg", MatchMode::Exact)); } + #[test] + fn a_word_match_never_fires_inside_another_word() { + for said in ["nt", "NT!", "wow, nt.", "(nt)", "that was nt though"] { + assert!(text_matches(said, "nt", MatchMode::Word), "{said}"); + } + for said in ["want", "don't", "isnt", "ntf", "front", "n t"] { + assert!(!text_matches(said, "nt", MatchMode::Word), "{said}"); + } + assert!(text_matches("NICE TRY lol", "nice try", MatchMode::Word)); + assert!(!text_matches("nice tryhard", "nice try", MatchMode::Word)); + assert!(text_matches("ñam ñam", "ñam", MatchMode::Word)); + assert!( + text_matches("want", "nt", MatchMode::Contains), + "anywhere still is" + ); + } + #[test] fn an_empty_pattern_never_matches() { assert!(!text_matches("gg", "", MatchMode::Contains)); @@ -2358,6 +2564,14 @@ mod tests { let v = chat_vars(&msg("!setdelay 45 now"), &["45", "now"]); assert_eq!(v["arg1"], "45"); assert_eq!(v["target"], "45"); + assert_eq!( + chat_vars(&msg("!give @Ana 5"), &["@Ana", "5"])["target_login"], + "ana" + ); + assert_eq!( + chat_vars(&msg("!coins"), &[])["target_login"], + msg("!coins").user_login + ); assert_eq!(v["arg2"], "now"); assert_eq!(v["args"], "45 now"); assert_eq!(v["user_role"], "viewer"); diff --git a/src/integrations/event.rs b/src/integrations/event.rs index 7ca5e6c..493de21 100644 --- a/src/integrations/event.rs +++ b/src/integrations/event.rs @@ -315,10 +315,18 @@ impl EventKind { EventKind::ObsMuteChanged => &[("obs_source", "Mic/Aux"), ("muted", "yes")], // `user` is "Anonymous" for an anonymous gift or cheer. `tier` // is 1, 2 or 3. - EventKind::TwitchFollow => &[("user", "Nat")], - EventKind::TwitchSub => &[("user", "Nat"), ("tier", "1"), ("gift", "no")], + // `user_login` is the same viewer as Twitch knows them: what + // viewer values go by. Blank for an anonymous gift or cheer. + EventKind::TwitchFollow => &[("user", "Nat"), ("user_login", "nat")], + EventKind::TwitchSub => &[ + ("user", "Nat"), + ("user_login", "nat"), + ("tier", "1"), + ("gift", "no"), + ], EventKind::TwitchResub => &[ ("user", "Nat"), + ("user_login", "nat"), ("tier", "1"), ("months", "13"), ("streak", "4"), @@ -326,16 +334,21 @@ impl EventKind { ], EventKind::TwitchGift => &[ ("user", "Nat"), + ("user_login", "nat"), ("count", "5"), ("tier", "1"), ("total", "42"), ], - EventKind::TwitchCheer => { - &[("user", "Nat"), ("bits", "500"), ("message", "Cheer500 gg")] - } - EventKind::TwitchRaid => &[("user", "Nat"), ("viewers", "42")], + EventKind::TwitchCheer => &[ + ("user", "Nat"), + ("user_login", "nat"), + ("bits", "500"), + ("message", "Cheer500 gg"), + ], + EventKind::TwitchRaid => &[("user", "Nat"), ("user_login", "nat"), ("viewers", "42")], EventKind::TwitchRedeem => &[ ("user", "Nat"), + ("user_login", "nat"), ("reward", "Hydrate"), ("cost", "500"), ("input", "with lemon"), diff --git a/src/integrations/host.rs b/src/integrations/host.rs index 0280d94..b2846d7 100644 --- a/src/integrations/host.rs +++ b/src/integrations/host.rs @@ -118,6 +118,8 @@ pub trait Host: Send + Sync { /// message being answered, when there is one. fn chat(&self, text: &str, as_bot: bool, reply_to: Option<&str>) -> Result<(), String>; fn marker(&self, description: &str) -> Result<(), String>; + /// Switch a channel points reward made here on or off, by its title. + fn reward(&self, title: &str, enabled: bool) -> Result<(), String>; fn clip(&self) -> Result; /// Run one of the named delay actions (`arm`, `cut`, ...). `ms` is the /// delay for `arm`. diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index 7c44bf0..1dd454c 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -27,6 +27,7 @@ pub mod alert_media; pub mod alerts; pub mod api; pub mod botcmd; +pub mod chat_room; pub mod clock; pub mod effects; pub mod engine; @@ -43,6 +44,7 @@ pub mod store; pub mod template; pub mod timeline; pub mod twitch; +pub mod viewers; pub mod ws; pub use engine::Handle; diff --git a/src/integrations/model.rs b/src/integrations/model.rs index 9b3e923..1752da0 100644 --- a/src/integrations/model.rs +++ b/src/integrations/model.rs @@ -109,7 +109,13 @@ pub enum Trigger { aliases: Vec, roles: Roles, user_cooldown_ms: u64, + /// What to answer a viewer who is still waiting that cooldown out + /// (`{cooldown_left}` says how long). Blank: say nothing. + cooldown_reply: String, }, + /// Steps other triggers of the same integration run by name, with a + /// "Run a block" step. Never starts on its own. + Block { name: String }, /// Any chat message that matches. ChatMessage { pattern: String, @@ -231,6 +237,10 @@ impl Roles { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MatchMode { + /// The pattern as whole words: "nt" is in "nt!" but not in "want" or + /// "don't". What a new trigger uses. + Word, + /// Anywhere, inside a word too. Contains, StartsWith, Exact, @@ -241,6 +251,7 @@ pub enum MatchMode { impl MatchMode { fn id(self) -> &'static str { match self { + MatchMode::Word => "word", MatchMode::Contains => "contains", MatchMode::StartsWith => "starts_with", MatchMode::Exact => "exact", @@ -250,6 +261,7 @@ impl MatchMode { fn from_id(id: &str) -> MatchMode { match id { + "word" => MatchMode::Word, "starts_with" => MatchMode::StartsWith, "exact" => MatchMode::Exact, "wildcard" => MatchMode::Wildcard, @@ -287,10 +299,21 @@ pub enum StepKind { /// Turn a light or plug on the local network on or off, or color it /// (see `lights`). Light, + /// Change the number a viewer holds (coins, points), or pay everyone + /// who chatted lately (see `viewers`). + Viewer, + /// Work out a number: a sum, a share, a random one. + Math, + /// Wait for the next chat message that fits, up to a time limit. + WaitChat, + /// Run one of this integration's blocks (`Trigger::Block`). + Call, + /// Switch a channel points reward made here on or off. + Reward, } impl StepKind { - pub const ALL: [StepKind; 20] = [ + pub const ALL: [StepKind; 25] = [ StepKind::Discord, StepKind::Chat, StepKind::Phone, @@ -311,6 +334,11 @@ impl StepKind { StepKind::Timeline, StepKind::Obs, StepKind::Light, + StepKind::Viewer, + StepKind::Math, + StepKind::WaitChat, + StepKind::Call, + StepKind::Reward, ]; pub fn id(self) -> &'static str { @@ -335,6 +363,11 @@ impl StepKind { StepKind::Timeline => "timeline", StepKind::Obs => "obs", StepKind::Light => "light", + StepKind::Viewer => "viewer", + StepKind::Math => "math", + StepKind::WaitChat => "wait_chat", + StepKind::Call => "call", + StepKind::Reward => "reward", } } @@ -368,7 +401,15 @@ impl StepKind { ("device", "pick the light or plug"), ("action", "pick what the light does"), ], - StepKind::WaitDelay | StepKind::Stop | StepKind::Marker | StepKind::Clip => &[], + StepKind::Viewer => &[("value", "name the viewer value, like coins")], + StepKind::Call => &[("block", "pick the block to run")], + StepKind::Reward => &[("title", "pick the channel points reward")], + StepKind::WaitDelay + | StepKind::Stop + | StepKind::Marker + | StepKind::Clip + | StepKind::Math + | StepKind::WaitChat => &[], } } @@ -473,6 +514,7 @@ impl Integration { validate_trigger(&handler.trigger, missing); validate_steps(&handler.steps, 1, &mut errors, missing); } + self.block_problems(&mut errors, &mut incomplete); errors.dedup(); incomplete.dedup(); Problems { @@ -481,6 +523,34 @@ impl Integration { } } + /// Blocks must be told apart, and a "Run a block" step must name one + /// that is there and on. + fn block_problems(&self, errors: &mut Vec, incomplete: &mut Vec) { + let names: Vec<&str> = self + .handlers + .iter() + .filter_map(|h| match &h.trigger { + Trigger::Block { name } => Some(name.trim()), + _ => None, + }) + .collect(); + if (1..names.len()).any(|i| names[..i].contains(&names[i])) { + errors.push("two blocks have the same name".to_string()); + } + let on = blocks(self); + for handler in self.handlers.iter().filter(|h| h.enabled) { + visit_steps(&handler.steps, &mut |step| { + let wanted = step.param("block").trim(); + let runs = step.kind == StepKind::Call && step.enabled && !wanted.is_empty(); + if runs && !on.contains_key(wanted) { + incomplete.push(format!( + "the block \"{wanted}\" doesn't exist, or is switched off" + )); + } + }); + } + } + /// Whether any step runs a program or writes a file. pub fn has_local_effects(&self) -> bool { let mut found = false; @@ -608,6 +678,9 @@ fn validate_trigger(trigger: &Trigger, errors: &mut Vec) { } } } + Trigger::Block { name } if !valid_block_name(name) => { + errors.push("name the block: letters, digits, spaces, - and _".to_string()); + } Trigger::ChatMessage { pattern, .. } if pattern.trim().is_empty() => { errors.push("the chat message trigger needs something to match".to_string()); } @@ -680,6 +753,25 @@ fn validate_activity(activity: &ChatActivity, errors: &mut Vec) { } } +/// A block's name: what "Run a block" steps pick it by. +fn valid_block_name(name: &str) -> bool { + let fits = |c: char| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_'); + !name.trim().is_empty() && name.len() <= MAX_ID_LEN && name.chars().all(fits) +} + +/// The steps of each switched-on block of `integration`, by name. +pub fn blocks(integration: &Integration) -> BTreeMap> { + integration + .handlers + .iter() + .filter(|h| h.enabled) + .filter_map(|h| match &h.trigger { + Trigger::Block { name } => Some((name.trim().to_string(), h.steps.clone())), + _ => None, + }) + .collect() +} + pub fn valid_command(c: &str) -> bool { c.len() >= 2 && c.len() <= 32 @@ -750,6 +842,19 @@ fn validate_steps( if step.kind == StepKind::Light { light_step_problems(step, missing, errors); } + // The value's name is part of `{viewer.}`: fixed, and plain. + let value = step.param("value").trim(); + if step.kind == StepKind::Viewer && !value.is_empty() && !super::viewers::valid_name(value) + { + errors.push("a viewer value's name is letters, digits and _, like coins".to_string()); + } + // Like an OBS source: chat never picks which reward is switched. + if step.kind == StepKind::Reward && step.param("title").contains('{') { + errors.push("a reward's name can't use variables".to_string()); + } + if step.kind == StepKind::Call && step.param("block").contains('{') { + errors.push("a block's name can't use variables".to_string()); + } // Where a program runs, a file is written or a request goes must be // fixed by the streamer, never filled in from chat or a web call: // otherwise a viewer's message could pick the program or the host. @@ -793,7 +898,12 @@ fn validate_steps( // The value's name is fixed: a viewer must not overwrite `{user}`. let named = match step.kind { StepKind::SetVar => step.param("name"), - StepKind::EditText | StepKind::Program | StepKind::Obs => step.param("save_as"), + StepKind::EditText + | StepKind::Program + | StepKind::Obs + | StepKind::Counter + | StepKind::Math + | StepKind::WaitChat => step.param("save_as"), _ => "", }; if named.contains('{') { @@ -983,8 +1093,10 @@ fn trigger_json(t: &Trigger) -> Value { aliases, roles, user_cooldown_ms, + cooldown_reply, } => json::obj([ ("type", json::str("chat_command")), + ("cooldown_reply", json::str(cooldown_reply)), ("command", json::str(command)), ( "aliases", @@ -993,6 +1105,9 @@ fn trigger_json(t: &Trigger) -> Value { ("roles", roles_json(*roles)), ("user_cooldown_ms", Value::Num(*user_cooldown_ms as f64)), ]), + Trigger::Block { name } => { + json::obj([("type", json::str("block")), ("name", json::str(name))]) + } Trigger::ChatMessage { pattern, mode, @@ -1112,6 +1227,10 @@ fn trigger_from_json(v: &Value) -> Result { .collect(), roles: roles_from_json(v.get("roles")), user_cooldown_ms: v.u64_or("user_cooldown_ms", 0), + cooldown_reply: v.str_or("cooldown_reply", "").trim().to_string(), + }), + "block" => Ok(Trigger::Block { + name: v.str_or("name", "").trim().to_string(), }), "chat_message" => Ok(Trigger::ChatMessage { pattern: v.str_or("pattern", "").to_string(), @@ -1214,6 +1333,76 @@ fn steps_from_json(v: Option<&Value>, depth: usize) -> Result, String> .collect() } +/// A value the streamer sets once and every integration reads as +/// `{my.}`: what a point is called, how often a game is won, a list +/// to pick from. Changing it changes every message and step that uses it, and +/// a recipe brings the ones it uses along. +#[derive(Clone, Debug, PartialEq)] +pub struct UserValue { + pub name: String, + /// What it is for, in the streamer's words: shown where it is edited. + pub label: String, + pub value: String, +} + +pub const MAX_USER_VALUES: usize = 100; +const MAX_USER_VALUE_LABEL: usize = 80; + +impl UserValue { + pub fn to_json(&self) -> Value { + json::obj([ + ("name", json::str(&self.name)), + ("label", json::str(&self.label)), + ("value", json::str(&self.value)), + ]) + } + + /// None when the name isn't one `{my.}` can hold, or the value + /// is too long to save. + pub fn from_json(v: &Value) -> Option { + let name = v.str_or("name", "").trim(); + let value = v.str_or("value", ""); + let fits = super::viewers::valid_name(name) && value.len() <= MAX_PARAM_LEN; + fits.then(|| UserValue { + name: name.to_string(), + label: v + .str_or("label", "") + .trim() + .chars() + .take(MAX_USER_VALUE_LABEL) + .collect(), + value: value.to_string(), + }) + } +} + +/// The `{my.}` names `integrations` use anywhere: in a step, or in +/// what a command answers during its cooldown. +pub fn user_values_used(integrations: &[Integration]) -> Vec { + let mut used: Vec = Vec::new(); + let mut note = |text: &str| { + // `$my.name` is how a counting word (`{n#$my.coin#$my.coins}`) reads one. + let after = |marker| text.split(marker).skip(1).collect::>(); + for name in after("{my.").into_iter().chain(after("$my.")) { + let end = name + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(name.len()); + if end > 0 && !used.iter().any(|known| known == &name[..end]) { + used.push(name[..end].to_string()); + } + } + }; + for handler in integrations.iter().flat_map(|i| &i.handlers) { + if let Trigger::ChatCommand { cooldown_reply, .. } = &handler.trigger { + note(cooldown_reply); + } + visit_steps(&handler.steps, &mut |step| { + step.params.values().for_each(|value| note(value)); + }); + } + used +} + /// A saved Discord webhook the integrations can post to. #[derive(Clone, Debug, PartialEq)] pub struct DiscordChannel { @@ -1296,6 +1485,7 @@ mod tests { aliases: vec!["!c".into()], roles: Roles::MODS, user_cooldown_ms: 30_000, + cooldown_reply: "Wait {cooldown_left}.".into(), }, steps: vec![Step::new(StepKind::Clip, &[])], }, @@ -1313,6 +1503,88 @@ mod tests { } } + #[test] + fn blocks_are_named_apart_and_must_exist_to_be_run() { + let block = |name: &str| Handler { + enabled: true, + trigger: Trigger::Block { name: name.into() }, + steps: vec![Step::new(StepKind::Stop, &[])], + }; + let runs = |name: &str| Handler { + enabled: true, + trigger: Trigger::Timer { + every_ms: 60_000, + only_live: true, + }, + steps: vec![Step::new(StepKind::Call, &[("block", name)])], + }; + let with = |handlers: Vec| Integration { + handlers, + ..sample() + }; + assert!(with(vec![block("check bet"), runs("check bet")]) + .validate() + .is_empty()); + let missing = with(vec![block("check bet"), runs("pay out")]).problems(); + assert!(missing.invalid.is_empty()); + assert!( + missing.incomplete[0].contains("pay out"), + "{:?}", + missing.incomplete + ); + let twice = with(vec![block("a"), block("a")]).problems(); + assert_eq!(twice.invalid, ["two blocks have the same name"]); + assert!(!with(vec![block("{user}")]).problems().incomplete.is_empty()); + let back = Integration::from_json(&with(vec![block("a")]).to_json()).unwrap(); + assert_eq!( + back.handlers[0].trigger, + Trigger::Block { name: "a".into() } + ); + } + + #[test] + fn the_values_integrations_read_are_found_wherever_they_are() { + let mut i = sample(); + i.handlers[0].steps = vec![Step { + then: vec![Step::new( + StepKind::Chat, + &[("text", "{n#$my.coin#$my.coins}")], + )], + ..Step::new( + StepKind::If, + &[("left", "{my.flip_chance}"), ("op", "chance")], + ) + }]; + i.handlers[1].trigger = Trigger::ChatCommand { + command: "!daily".into(), + aliases: vec![], + roles: Roles::EVERYONE, + user_cooldown_ms: 60_000, + cooldown_reply: "No more {my.coins} for {cooldown_left}. {army.size}".into(), + }; + assert_eq!(user_values_used(&[i]), ["flip_chance", "coin", "coins"]); + let kept = UserValue::from_json(&json::obj([ + ("name", json::str("coins")), + ("label", json::str("What coins are called")), + ("value", json::str("gems")), + ])); + assert_eq!(kept.unwrap().value, "gems"); + assert!(UserValue::from_json(&json::obj([("name", json::str("my coins"))])).is_none()); + } + + #[test] + fn a_viewer_value_needs_a_plain_fixed_name() { + let with = |value: &str| { + let mut i = sample(); + i.handlers[0].steps = vec![Step::new(StepKind::Viewer, &[("value", value)])]; + i.problems() + }; + assert!(with("coins").invalid.is_empty()); + assert!(!with("coins_{user}").invalid.is_empty()); + assert!(!with("my coins").invalid.is_empty()); + assert!(!with("").incomplete.is_empty()); + } + #[test] fn json_round_trip_is_lossless() { let original = sample(); @@ -1339,6 +1611,7 @@ mod tests { aliases: vec![], roles: Roles::EVERYONE, user_cooldown_ms: 0, + cooldown_reply: String::new(), }; bad.handlers[1].enabled = true; let errors = bad.validate(); diff --git a/src/integrations/presets.rs b/src/integrations/presets.rs index 728ab49..9cb4f0c 100644 --- a/src/integrations/presets.rs +++ b/src/integrations/presets.rs @@ -324,6 +324,7 @@ fn command(command: &str, roles: Roles, user_cooldown_ms: u64, steps: Vec) aliases: Vec::new(), roles, user_cooldown_ms, + cooldown_reply: String::new(), }, steps, } diff --git a/src/integrations/recipe.rs b/src/integrations/recipe.rs index 3b0c237..804a7cb 100644 --- a/src/integrations/recipe.rs +++ b/src/integrations/recipe.rs @@ -15,12 +15,18 @@ //! chooses whether to use the theme; an upload that didn't come along //! falls back to the theme sound, or no picture. //! +//! - The sharer's own values (`{my.name}`) that the recipe's integrations +//! use come along. A value the importer already has under that name is +//! left as they set it. +//! //! Import cleans exactly what export does, so a recipe written by hand //! can't bring in a web address, a program or a hotkey the user never set. use super::alert_media::{self, Kind, REF_PREFIX}; use super::alerts::{is_web_link, Look, THEME_SOUND}; -use super::model::{visit_steps, Integration, Step, StepKind, Trigger}; +use super::model::{ + user_values_used, visit_steps, Integration, Step, StepKind, Trigger, UserValue, MAX_USER_VALUES, +}; use crate::json::{self, Value}; use std::collections::BTreeMap; use std::path::Path; @@ -40,6 +46,8 @@ pub struct Recipe { pub name: String, pub integrations: Vec, pub alerts: Option, + /// The `{my.name}` values its integrations read, as the sharer set them. + pub values: Vec, } /// What alerts in a recipe bring: the sharer's theme and their uploads. @@ -68,12 +76,13 @@ impl Recipe { } /// The recipe text. With `look`, alerts among `integrations` bring that -/// theme and the uploads (from `media_folder`) they play or show. +/// theme and the uploads (from `media_folder`) they play or show. Of +/// `values`, the ones `integrations` read come along. pub fn export( name: &str, integrations: &[Integration], - look: Option<&Look>, - media_folder: &Path, + (look, media_folder): (Option<&Look>, &Path), + values: &[UserValue], ) -> String { let cleaned: Vec = integrations .iter() @@ -87,6 +96,15 @@ pub fn export( ("name".to_string(), json::str(name)), ("integrations".to_string(), Value::Arr(cleaned)), ]; + let used = user_values_used(integrations); + let shared: Vec = values + .iter() + .filter(|v| used.contains(&v.name)) + .map(UserValue::to_json) + .collect(); + if !shared.is_empty() { + doc.push(("values".to_string(), Value::Arr(shared))); + } if let Some(look) = look.filter(|_| integrations.iter().any(shows_alerts)) { doc.push(( "alerts".to_string(), @@ -301,9 +319,24 @@ pub fn parse(text: &str, mut new_id: impl FnMut() -> String) -> Result) -> Vec { + let mut values: Vec = Vec::new(); + for value in v.and_then(Value::as_array).unwrap_or(&[]) { + let Some(value) = UserValue::from_json(value) else { + continue; + }; + if values.len() < MAX_USER_VALUES && !values.iter().any(|v| v.name == value.name) { + values.push(value); + } + } + values +} + /// The theme and uploads a recipe brings. Every upload must be a sound or /// picture this app would keep itself, within the same limits. fn parse_alerts(v: &Value) -> Result { @@ -490,6 +523,28 @@ mod tests { /// An alert's picture or sound linked from the web would load from that /// site on the importer's stream: it falls back as a missing upload does. + #[test] + fn a_recipe_brings_the_values_its_integrations_read() { + let value = |name: &str, value: &str| UserValue { + name: name.into(), + label: format!("What {name} is"), + value: value.into(), + }; + let mut i = presets::build("socials", "i1".into(), "").unwrap(); + i.handlers[0].steps = vec![Step::new( + StepKind::Chat, + &[("text", "You have {n#$my.coin#$my.coins}")], + )]; + let mine = [ + value("coin", "gem"), + value("coins", "gems"), + value("unused", "x"), + ]; + let text = export("x", &[i], (None, Path::new("")), &mine); + let recipe = parse(&text, || "fresh".to_string()).unwrap(); + assert_eq!(recipe.values, mine[..2]); + } + #[test] fn alerts_linked_from_the_web_stay_behind() { let mut steps = vec![Step::new( @@ -581,7 +636,7 @@ mod tests { css: ".card{letter-spacing:1px}".into(), ..Look::default() }; - let text = export("Alerts", &[alert], Some(&look), &theirs.0); + let text = export("Alerts", &[alert], (Some(&look), &theirs.0), &[]); let mut recipe = parse(&text, || "fresh1234567890ab".into()).unwrap(); let shared = recipe.alerts.take().unwrap(); assert_eq!(shared.look, look); @@ -615,7 +670,7 @@ mod tests { )]); assert!(parse_alerts(&forged).is_err()); let chat = presets::build("delay_command", "c1".into(), "").unwrap(); - let text = export("x", &[chat], Some(&Look::default()), Path::new("")); + let text = export("x", &[chat], (Some(&Look::default()), Path::new("")), &[]); assert!(parse(&text, || "fresh1234567890ab".into()) .unwrap() .alerts @@ -634,7 +689,7 @@ mod tests { #[test] fn export_hides_connections_and_import_starts_off() { let alert = presets::build("crash_alert", "a1".into(), "my-private-channel").unwrap(); - let text = export("Crash kit", &[alert], None, Path::new("")); + let text = export("Crash kit", &[alert], (None, Path::new("")), &[]); assert!(text.starts_with(PREFIX)); let decoded = String::from_utf8(base64url_decode(&text[PREFIX.len()..]).unwrap()).unwrap(); assert!(!decoded.contains("my-private-channel")); @@ -675,7 +730,7 @@ mod tests { &[("path", r"C:\Users\oriol\stream.txt"), ("text", "x")], ), ]; - let text = export("x", &[i], None, Path::new("")); + let text = export("x", &[i], (None, Path::new("")), &[]); let decoded = String::from_utf8(base64url_decode(&text[PREFIX.len()..]).unwrap()).unwrap(); for secret in ["secret-key", "s3cret", "oriol"] { assert!(!decoded.contains(secret), "{secret} leaked"); @@ -688,7 +743,7 @@ mod tests { i.handlers[0].trigger = Trigger::Webhook { token: "secret-token-1234567890".into(), }; - let text = export("x", &[i], None, Path::new("")); + let text = export("x", &[i], (None, Path::new("")), &[]); let decoded = String::from_utf8(base64url_decode(&text[PREFIX.len()..]).unwrap()).unwrap(); assert!(!decoded.contains("secret-token")); let recipe = parse(&text, || "fresh1234567890ab".into()).unwrap(); @@ -707,7 +762,7 @@ mod tests { token: String::new(), only_live: false, }; - let text = export("x", &[i], None, Path::new("")); + let text = export("x", &[i], (None, Path::new("")), &[]); let decoded = String::from_utf8(base64url_decode(&text[PREFIX.len()..]).unwrap()).unwrap(); assert!(!decoded.contains("Launchpad") && !decoded.contains("Ctrl+Alt+K")); assert!( @@ -720,7 +775,7 @@ mod tests { fn a_shared_light_keeps_what_each_state_sends_and_buttons_get_an_address() { let light = presets::build("on_air_light", "l".into(), "").unwrap(); let highlight = presets::build("highlight", "h".into(), "").unwrap(); - let text = export("studio", &[light, highlight], None, Path::new("")); + let text = export("studio", &[light, highlight], (None, Path::new("")), &[]); let mut n = 0; let recipe = parse(&text, || { n += 1; @@ -750,7 +805,7 @@ mod tests { &[("url", "https://x.example/"), ("body", "key=secret")], ), ]; - let recipe = parse(&export("x", &[i], None, Path::new("")), || { + let recipe = parse(&export("x", &[i], (None, Path::new("")), &[]), || { "fresh1234567890ab".into() }) .unwrap(); diff --git a/src/integrations/runner.rs b/src/integrations/runner.rs index 4788ec1..6451f93 100644 --- a/src/integrations/runner.rs +++ b/src/integrations/runner.rs @@ -14,6 +14,7 @@ //! included, and says what each would have been. use super::alerts; +use super::chat_room::{self, ChatRoom}; use super::clock; use super::event::{Event, EventKind}; use super::host::{ @@ -24,6 +25,7 @@ use super::model::{DiscordChannel, PhoneConnection, Step, StepKind}; use super::obsws; use super::template::{self, Vars}; use super::timeline::{self as tl, Timeline}; +use super::viewers::{self, Change, ViewerValues}; use crate::json; use std::collections::{BTreeMap, HashMap}; use std::future::Future; @@ -48,6 +50,18 @@ const MAX_CHAT_LEN: usize = 500; /// Counters are all saved in one file, rewritten on every change. const MAX_COUNTERS: usize = 5000; const MAX_COUNTER_NAME: usize = 100; +/// Texts kept between runs (`{saved.name}`), and how long each may be. +const MAX_SAVED: usize = 200; +const MAX_SAVED_LEN: usize = 500; +/// How long a "Wait for chat" step waits when it names no time, and at most. +const WAIT_CHAT_SECONDS: u64 = 30; +const MAX_WAIT_CHAT_SECONDS: u64 = 300; +/// How far back "everyone who chatted lately" looks when a step names no +/// time, in minutes. +const ACTIVE_MINUTES: u64 = 10; +/// Blocks running blocks: deep enough for any real use, and an end to two +/// blocks that run each other. +const MAX_CALL_DEPTH: usize = 8; /// How long a "Show on stream" card stays up when the step names no time. const OVERLAY_SECONDS: u64 = 6; /// A card's color when none (or a broken one) is set: the InstantClone blue. @@ -60,8 +74,16 @@ pub struct RunEnv { pub discord: Vec, pub phone: PhoneConnection, pub counters: Arc>>, - /// Called after a counter changes, to save it. - pub counters_changed: Box, + /// What each viewer holds (`{viewer.coins}`). + pub viewers: Arc>, + /// Texts kept between runs (`{saved.name}`). + pub saved: Arc>>, + /// Called after any of those three changes, to save it. + pub data_changed: Box, + /// Who chatted lately, and the messages steps are waiting for. + pub chat_room: Arc, + /// The streamer's own values (`{my.name}`), by name. + pub user_values: BTreeMap, /// The last Discord message each integration posted per channel (key /// `integration:channel`), so a later step can edit it. In memory only: /// after a restart the next update simply posts a new message. @@ -87,6 +109,8 @@ pub struct RunContext { pub dry: bool, /// The run's place in the engine's run limits; None for tests. pub gate: Option, + /// The integration's blocks by name, for "Run a block" steps. + pub blocks: Arc>>, } /// A run's place in the engine's limits on runs going at once. A run that @@ -187,6 +211,9 @@ pub enum RunStatus { Ok, /// A `stop` step ended it on purpose. Stopped, + /// A `stop` step that asked to leave no trace ended it: a timer that + /// rolls a die every minute shouldn't fill the history with misses. + StoppedQuietly, /// At least one step failed; the rest still ran. Failed, } @@ -203,14 +230,89 @@ struct Runner { started: Instant, log: Vec, failed: bool, + /// What lasts between runs. A test works on its own copies, so it can + /// count, pay and read back without changing anything real. + kept: Kept, + /// A `stop` step asked for the run to stay out of the history. + quiet: bool, + call_depth: usize, +} + +/// The values that last between runs: counters, what viewers hold, and +/// kept texts. +#[derive(Clone)] +struct Kept { + counters: Arc>>, + viewers: Arc>, + saved: Arc>>, +} + +impl Kept { + fn real(env: &RunEnv) -> Kept { + Kept { + counters: env.counters.clone(), + viewers: env.viewers.clone(), + saved: env.saved.clone(), + } + } + + /// A test's own copies, with the values it was given to start from: + /// `counter.wins`, `viewer.coins` and `target.coins` among a test's + /// values set those, instead of being plain variables. + fn for_test(env: &RunEnv, vars: &mut BTreeMap) -> Kept { + let mut counters = env.counters.lock().clone(); + let mut viewers = env.viewers.lock().clone(); + let given: Vec = vars.keys().filter(|n| n.contains('.')).cloned().collect(); + for name in given { + let Ok(amount) = vars[&name].trim().parse::() else { + continue; + }; + match name.split_once('.') { + Some(("counter", counter)) => { + counters.insert(counter.to_string(), amount); + } + Some((who @ ("viewer" | "target"), value)) if viewers::valid_name(value) => { + let Some(login) = login_of(vars, who) else { + continue; + }; + let _ = viewers.apply(value, &login, Change::Set(amount)); + } + _ => continue, + } + vars.remove(&name); + } + let shared_saved = env.saved.lock().clone(); + Kept { + counters: Arc::new(crate::sync::Mutex::new(counters)), + viewers: Arc::new(crate::sync::Mutex::new(viewers)), + saved: Arc::new(crate::sync::Mutex::new(shared_saved)), + } + } +} + +/// The login a step means by "the viewer" (who started the run) or "the +/// target" (who their command named). Twitch events name the viewer too. +fn login_of(vars: &BTreeMap, who: &str) -> Option { + let (login, shown) = match who { + "target" => ("target_login", "target"), + _ => ("user_login", "user"), + }; + vars.get(login) + .or_else(|| vars.get(shown)) + .map(|name| viewers::login_key(name)) + .filter(|login| !login.is_empty()) } pub async fn run( steps: &[Step], - ctx: RunContext, + mut ctx: RunContext, host: Arc, env: Arc, ) -> (RunStatus, Vec) { + let kept = match ctx.test { + true => Kept::for_test(&env, &mut ctx.vars), + false => Kept::real(&env), + }; let mut runner = Runner { host, env, @@ -218,10 +320,14 @@ pub async fn run( started: Instant::now(), log: Vec::new(), failed: false, + kept, + quiet: false, + call_depth: 0, }; let flow = runner.steps(steps).await; let status = match (flow, runner.failed) { (_, true) => RunStatus::Failed, + (Flow::Stop, false) if runner.quiet => RunStatus::StoppedQuietly, (Flow::Stop, false) => RunStatus::Stopped, (Flow::Continue, false) => RunStatus::Ok, }; @@ -282,7 +388,8 @@ impl Runner { fn lookup<'a>(&'a self, live: &'a LiveState) -> Lookup<'a> { Lookup { vars: &self.ctx.vars, - counters: &self.env.counters, + kept: &self.kept, + user_values: &self.env.user_values, timeline: &self.env.timeline, live, } @@ -292,6 +399,13 @@ impl Runner { self.ctx.vars.insert(name.into(), value.into()); } + /// A kept value changed: have it saved. A test's copies never are. + fn data_changed(&self) { + if !self.ctx.test { + (self.env.data_changed)(); + } + } + /// Run a blocking effect off the async thread. async fn blocking( &self, @@ -306,7 +420,9 @@ impl Runner { async fn step(&mut self, step: &Step) -> Flow { match step.kind { StepKind::If => return self.check(step).await, + StepKind::Call => return self.call(step).await, StepKind::Stop => { + self.quiet = step.param("quiet") == "yes"; self.record("Stop", StepStatus::Ok, ""); return Flow::Stop; } @@ -324,10 +440,18 @@ impl Runner { StepKind::SetVar => { let name = self.fill(step, "name"); let value = self.fill(step, "value"); - self.record(format!("Remember {name}"), StepStatus::Ok, value.clone()); + if step.param("keep") == "yes" { + self.keep(&name, &value); + } else { + self.record(format!("Remember {name}"), StepStatus::Ok, value.clone()); + } self.set(name, value); } StepKind::Counter => self.counter(step), + StepKind::Viewer => self.viewer(step), + StepKind::Math => self.math(step), + StepKind::WaitChat => self.wait_chat(step).await, + StepKind::Reward => self.reward(step).await, StepKind::Overlay => self.overlay(step), StepKind::EditText => self.edit_text(step), StepKind::Timeline => self.timeline(step), @@ -1077,7 +1201,7 @@ impl Runner { let by = self.fill(step, "by").trim().parse::().unwrap_or(1); let op = step.param("op"); let value = { - let mut counters = self.env.counters.lock(); + let mut counters = self.kept.counters.lock(); if counters.len() >= MAX_COUNTERS && !counters.contains_key(&name) { drop(counters); self.record( @@ -1094,28 +1218,357 @@ impl Runner { "reset" => 0, _ => current.saturating_add(by), }; - if !self.ctx.test { - counters.insert(name.clone(), next); - } + counters.insert(name.clone(), next); next }; - if !self.ctx.test { - (self.env.counters_changed)(); + self.data_changed(); + // A name filled in from chat (`wins_{user_login}`) can't be written + // into a later step: `save_as` gives its value a fixed name. + let save_as = step.param("save_as").trim(); + if !save_as.is_empty() { + self.set(save_as, value.to_string()); } - self.set(format!("counter.{name}"), value.to_string()); self.record(format!("Counter {name}"), StepStatus::Ok, value.to_string()); } + + /// "Remember a value" told to keep it: saved for later runs and streams + /// as `{saved.name}`. + fn keep(&mut self, name: &str, value: &str) { + let label = format!("Keep {name}"); + let value: String = value.chars().take(MAX_SAVED_LEN).collect(); + { + let mut saved = self.kept.saved.lock(); + if saved.len() >= MAX_SAVED && !saved.contains_key(name) { + drop(saved); + let why = format!("there are already {MAX_SAVED} kept values"); + return self.record(label, StepStatus::Failed, why); + } + saved.insert(name.to_string(), value.clone()); + } + self.data_changed(); + self.record(label, StepStatus::Ok, value); + } + + /// Change what a viewer holds, or pay everyone who chatted lately. + fn viewer(&mut self, step: &Step) { + let value = step.param("value").trim().to_string(); + let typed = self.fill(step, "by"); + let typed = typed.trim(); + let amount = match typed { + "" => Some(1), + text => text.parse::().ok(), + }; + let op = step.param("op"); + if step.param("who") == "active" { + return self.pay_active(step, &value, amount); + } + // "named": a viewer the step itself names, like whoever answered a + // "Wait for chat" step (`{reply.user_login}`). + let login = match step.param("who") { + "named" => { + Some(viewers::login_key(&self.fill(step, "login"))).filter(|l| !l.is_empty()) + } + who => login_of(&self.ctx.vars, who), + }; + let Some(login) = login else { + let why = "there is no viewer to change it for"; + return self.record(format!("Viewer {value}"), StepStatus::Failed, why); + }; + let label = format!("{login}'s {value}"); + let change = match (op, amount) { + ("reset", _) => Change::Set(0), + // A bet typed as "banana" just isn't paid: the next check says so. + ("spend", amount) => Change::Spend(amount.unwrap_or(0)), + (_, None) => { + let why = format!("\"{typed}\" isn't a whole number"); + return self.record(label, StepStatus::Failed, why); + } + ("subtract", Some(by)) => Change::Subtract(by), + ("set", Some(to)) => Change::Set(to), + (_, Some(by)) => Change::Add(by), + }; + let done = self.kept.viewers.lock().apply(&value, &login, change); + match done { + Ok((now, applied)) => { + if op == "spend" { + self.set("paid", if applied { "yes" } else { "no" }); + } + self.data_changed(); + let detail = match applied { + true => now.to_string(), + false => format!("{now}: not enough to pay \"{typed}\""), + }; + self.record(label, StepStatus::Ok, detail); + } + Err(why) => self.record(label, StepStatus::Failed, why), + } + } + + /// Add to everyone who chatted in the last `minutes`: the viewers who + /// are there. + fn pay_active(&mut self, step: &Step, value: &str, amount: Option) { + let label = format!("Active viewers' {value}"); + let Some(amount) = amount else { + let why = "the amount isn't a whole number"; + return self.record(label, StepStatus::Failed, why); + }; + let minutes = self + .fill(step, "minutes") + .trim() + .parse::() + .unwrap_or(ACTIVE_MINUTES) + .clamp(1, chat_room::MAX_ACTIVE.as_secs() / 60); + let within = Duration::from_secs(minutes * 60); + let logins = self.env.chat_room.active(within, Instant::now()); + let failed = { + let mut viewers = self.kept.viewers.lock(); + logins + .iter() + .find_map(|login| viewers.apply(value, login, Change::Add(amount)).err()) + }; + self.set("active.count", logins.len().to_string()); + if !logins.is_empty() { + self.data_changed(); + } + match failed { + Some(why) => self.record(label, StepStatus::Failed, why), + None => { + let detail = format!("{amount:+} to {} who chatted", logins.len()); + self.record(label, StepStatus::Ok, detail); + } + } + } + + fn math(&mut self, step: &Step) { + let name = save_name(step, "result"); + let label = format!("Maths → {name}"); + let (a, b) = (self.fill(step, "a"), self.fill(step, "b")); + let answer = match (whole(&a), whole(&b)) { + (Some(a), Some(b)) => sum(step.param("op"), a, b), + _ => Err(format!( + "\"{}\" and \"{}\" must be whole numbers", + a.trim(), + b.trim() + )), + }; + match answer { + Ok(answer) => { + self.record(label, StepStatus::Ok, answer.to_string()); + self.set(name, answer.to_string()); + } + Err(why) => { + self.set(name, "0"); + self.record(label, StepStatus::Failed, why); + } + } + } + + /// Wait for the next chat message that starts with the step's text, + /// from anyone or from one viewer. The run gives its place back + /// meanwhile, like any wait. + async fn wait_chat(&mut self, step: &Step) { + const LABEL: &str = "Wait for chat"; + let name = save_name(step, "reply"); + let text = self.fill(step, "text").trim().to_string(); + let seconds = self + .fill(step, "seconds") + .trim() + .parse::() + .unwrap_or(WAIT_CHAT_SECONDS) + .clamp(1, MAX_WAIT_CHAT_SECONDS); + let from = match step.param("from") { + "" => None, + who => login_of(&self.ctx.vars, who), + }; + if !step.param("from").is_empty() && from.is_none() { + self.set(format!("{name}.ok"), "no"); + let why = "this trigger names nobody to wait for"; + return self.record(LABEL, StepStatus::Failed, why); + } + if self.ctx.test { + let login = from.unwrap_or_else(|| "testviewer".to_string()); + self.heard(&name, &login, &login, &text); + let detail = + format!("would wait up to {seconds}s for \"{text}\" (a test is answered at once)"); + return self.record(LABEL, StepStatus::Skipped, detail); + } + let answer = match self.env.chat_room.wait(&text, from) { + Ok(answer) => answer, + Err(why) => { + self.set(format!("{name}.ok"), "no"); + return self.record(LABEL, StepStatus::Failed, why); + } + }; + if let Some(gate) = &mut self.ctx.gate { + gate.give_back(); + } + let heard = tokio::time::timeout(Duration::from_secs(seconds), answer).await; + if let Some(gate) = &mut self.ctx.gate { + gate.take_again().await; + } + match heard { + Ok(Ok(heard)) => { + self.heard(&name, &heard.user, &heard.user_login, &heard.message); + let detail = format!("{}: {}", heard.user, heard.message); + self.record(LABEL, StepStatus::Ok, detail); + } + _ => { + self.set(format!("{name}.ok"), "no"); + let detail = format!("nobody answered in {seconds}s"); + self.record(LABEL, StepStatus::Ok, detail); + } + } + } + + /// Switch a channel points reward on or off, so viewers can only + /// redeem it when it makes sense (not while a round is already running). + async fn reward(&mut self, step: &Step) { + let title = step.param("title").trim().to_string(); + let enabled = step.param("action") != "off"; + let label = format!("Reward {title} {}", if enabled { "on" } else { "off" }); + if self.ctx.test { + return self.record( + label, + StepStatus::Skipped, + "tests never change your Twitch rewards", + ); + } + match self.blocking(move |h| h.reward(&title, enabled)).await { + Ok(()) => self.record(label, StepStatus::Ok, ""), + Err(e) => self.record(label, StepStatus::Failed, e), + } + } + + /// What a "Wait for chat" step hands the next steps. + fn heard(&mut self, name: &str, user: &str, login: &str, message: &str) { + self.set(format!("{name}.ok"), "yes"); + self.set(format!("{name}.user"), user); + self.set(format!("{name}.user_login"), login); + self.set(format!("{name}.message"), message); + let arg1 = message.split_whitespace().nth(1).unwrap_or(""); + self.set(format!("{name}.arg1"), arg1); + } + + /// Run one of the integration's blocks here, with this run's values. A + /// Stop inside it stops the whole run. + async fn call(&mut self, step: &Step) -> Flow { + let name = step.param("block").trim().to_string(); + let label = format!("Block {name}"); + let blocks = self.ctx.blocks.clone(); + let Some(steps) = blocks.get(&name) else { + let why = "that block doesn't exist, or is switched off"; + self.record(label, StepStatus::Failed, why); + return Flow::Continue; + }; + if self.call_depth >= MAX_CALL_DEPTH { + self.record(label, StepStatus::Failed, "blocks run each other too deep"); + return Flow::Continue; + } + self.record(label, StepStatus::Ok, ""); + self.call_depth += 1; + let flow = self.steps(steps).await; + self.call_depth -= 1; + flow + } +} + +/// The name a step saves its answer under: its own, or `default`. +fn save_name(step: &Step, default: &str) -> String { + match step.param("save_as").trim() { + "" => default.to_string(), + name => name.to_string(), + } +} + +/// A whole number as typed; blank is 0. +fn whole(text: &str) -> Option { + match text.trim() { + "" => Some(0), + text => text.parse().ok(), + } +} + +/// What a Maths step works out. Whole numbers only, and never past what +/// one holds: a sum too big stops at the biggest number instead. +pub fn sum(op: &str, a: i64, b: i64) -> Result { + let fit = |n: i128| n.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64; + Ok(match op { + "subtract" => a.saturating_sub(b), + "multiply" => a.saturating_mul(b), + "divide" if b == 0 => return Err("can't divide by 0".to_string()), + "divide" => fit(i128::from(a) / i128::from(b)), + // `a` percent of `b`. + "percent" => fit(i128::from(a) * i128::from(b) / 100), + "min" => a.min(b), + "max" => a.max(b), + "random" => random_between(a.min(b), a.max(b)), + _ => a.saturating_add(b), + }) +} + +/// A whole number from `low` to `high`, both included. +fn random_between(low: i64, high: i64) -> i64 { + let span = (i128::from(high) - i128::from(low) + 1) as u128; + let mut bytes = [0u8; 16]; + crate::crypto::os_random(&mut bytes); + (i128::from(low) + (u128::from_le_bytes(bytes) % span) as i128) as i64 +} + +/// True `percent` times in a hundred: 0.1 is one time in a thousand. +fn chance(percent: &str) -> bool { + let Ok(percent) = percent.trim().trim_end_matches('%').parse::() else { + return false; + }; + let mut bytes = [0u8; 4]; + crate::crypto::os_random(&mut bytes); + f64::from(u32::from_le_bytes(bytes)) / (f64::from(u32::MAX) + 1.0) * 100.0 < percent } /// Where template values come from during a run, in priority order: the -/// run's own variables, saved counters, then the live stream state. +/// run's own variables, what is kept between runs (counters, viewer +/// values, kept texts), then the live stream state. struct Lookup<'a> { vars: &'a BTreeMap, - counters: &'a crate::sync::Mutex>, + kept: &'a Kept, + user_values: &'a BTreeMap, timeline: &'a crate::sync::Mutex, live: &'a LiveState, } +impl Lookup<'_> { + /// `{viewer.coins}` and `{target.coins}`: what that viewer holds. + /// `.rank` after it is their place (0 with nothing). `{top.coins}` is + /// the leaderboard, on one line. + fn viewer_value(&self, name: &str) -> Option { + let (who, rest) = name.split_once('.')?; + if !matches!(who, "viewer" | "target" | "top") { + return None; + } + let (value, rank) = match rest.strip_suffix(".rank") { + Some(value) => (value, true), + None => (rest, false), + }; + if !viewers::valid_name(value) { + return None; + } + let viewers = self.kept.viewers.lock(); + if who == "top" { + let places: Vec = viewers + .top(value, viewers::TOP) + .iter() + .enumerate() + .map(|(i, (login, amount))| format!("{}. {login} ({amount})", i + 1)) + .collect(); + return (!rank).then(|| places.join(", ")); + } + let login = login_of(self.vars, who).unwrap_or_default(); + Some(match rank { + true => viewers.rank(value, &login).to_string(), + false => viewers.get(value, &login).to_string(), + }) + } +} + impl Vars for Lookup<'_> { fn lookup(&self, name: &str) -> Option { if let Some(v) = self.vars.get(name) { @@ -1129,7 +1582,8 @@ impl Vars for Lookup<'_> { } if let Some(counter) = name.strip_prefix("counter.") { return Some( - self.counters + self.kept + .counters .lock() .get(counter) .copied() @@ -1137,6 +1591,22 @@ impl Vars for Lookup<'_> { .to_string(), ); } + if let Some(found) = self.viewer_value(name) { + return Some(found); + } + if let Some(mine) = name.strip_prefix("my.") { + return Some(self.user_values.get(mine).cloned().unwrap_or_default()); + } + if let Some(saved) = name.strip_prefix("saved.") { + return Some( + self.kept + .saved + .lock() + .get(saved) + .cloned() + .unwrap_or_default(), + ); + } // `{response.json.user.name}` reads a field of a saved JSON body; // `{body.x}` does the same for a web call's body. if let Some((base, path)) = name.split_once(".json.") { @@ -1205,6 +1675,7 @@ pub const CHAT_VARS: &[(&str, &str)] = &[ ("user_login", "nat"), ("user_role", "mod"), ("target", "Nat"), + ("target_login", "nat"), ("message", "!delay"), ("args", "30"), ("arg1", "30"), @@ -1342,6 +1813,7 @@ pub fn compare(left: &str, op: &str, right: &str) -> bool { "greater" => numbers().is_some_and(|(a, b)| a > b), "less" => numbers().is_some_and(|(a, b)| a < b), "whole_number" => is_whole_number(&l), + "chance" => chance(&l), _ => false, } } @@ -1788,6 +2260,10 @@ mod tests { .push(format!("light {device} {command:?}")); Ok(None) } + fn reward(&self, title: &str, enabled: bool) -> Result<(), String> { + self.calls.lock().push(format!("reward {title} {enabled}")); + Ok(()) + } } fn env() -> Arc { @@ -1802,7 +2278,15 @@ mod tests { topic: "my-topic".into(), }, counters: Arc::new(Mutex::new(BTreeMap::new())), - counters_changed: Box::new(|| {}), + viewers: Default::default(), + saved: Default::default(), + data_changed: Box::new(|| {}), + chat_room: Default::default(), + user_values: BTreeMap::from([ + ("coin".to_string(), "gem".to_string()), + ("coins".to_string(), "gems".to_string()), + ("flip_chance".to_string(), "100".to_string()), + ]), discord_messages: Arc::new(Mutex::new(HashMap::new())), discord_turns: Arc::new(DiscordTurns::default()), timeline: Arc::new(Mutex::new(Timeline::default())), @@ -2165,6 +2649,7 @@ mod tests { test, dry: false, gate: None, + blocks: Default::default(), } } @@ -2619,6 +3104,331 @@ mod tests { assert!(host.calls.lock()[1].ends_with("Crashes: 2")); } + fn viewer_step(params: &[(&str, &str)]) -> Step { + let mut all = vec![("value", "coins")]; + all.extend_from_slice(params); + Step::new(StepKind::Viewer, &all) + } + + /// Runs `steps` as ana, who named bo; returns what the File step wrote. + async fn as_ana(env: &Arc, steps: &[Step], test: bool) -> String { + let host = Arc::new(FakeHost::default()); + let who = [ + ("user", "Ana"), + ("user_login", "ana"), + ("target_login", "bo"), + ]; + run(steps, ctx(&who, test), host.clone(), env.clone()).await; + let calls = host.calls.lock(); + calls.last().cloned().unwrap_or_default() + } + + fn says(text: &str) -> Step { + Step::new(StepKind::File, &[("path", "c.txt"), ("text", text)]) + } + + #[tokio::test] + async fn a_spend_pays_only_what_the_viewer_has() { + let env = env(); + let steps = [ + viewer_step(&[("by", "100")]), + viewer_step(&[("op", "spend"), ("by", "30")]), + says("{paid} {viewer.coins}"), + viewer_step(&[("op", "spend"), ("by", "500")]), + says("{paid} {viewer.coins}"), + viewer_step(&[("op", "spend"), ("by", "banana")]), + says("{paid} {viewer.coins} rank {viewer.coins.rank}"), + ]; + let host = Arc::new(FakeHost::default()); + let who = ctx(&[("user_login", "ana")], false); + let (status, _) = run(&steps, who, host.clone(), env.clone()).await; + assert_eq!( + status, + RunStatus::Ok, + "a bet that can't be paid is no failure" + ); + let wrote: Vec = host.calls.lock().iter().map(|c| c.to_string()).collect(); + assert!(wrote[0].ends_with("yes 70"), "{wrote:?}"); + assert!(wrote[1].ends_with("no 70"), "{wrote:?}"); + assert!(wrote[2].ends_with("no 70 rank 1"), "{wrote:?}"); + assert_eq!(env.viewers.lock().get("coins", "ana"), 70); + } + + #[tokio::test] + async fn the_target_and_the_leaderboard_are_read_by_name() { + let env = env(); + let steps = [ + viewer_step(&[("op", "set"), ("by", "40")]), + viewer_step(&[("who", "target"), ("by", "90")]), + says("{target.coins} | {top.coins} | {viewer.coins.rank}"), + ]; + let wrote = as_ana(&env, &steps, false).await; + assert!( + wrote.ends_with("90 | 1. bo (90), 2. ana (40) | 2"), + "{wrote}" + ); + } + + #[tokio::test] + async fn a_reward_step_switches_it_and_a_test_leaves_twitch_alone() { + let steps = [ + Step::new(StepKind::Reward, &[("title", "Hydrate"), ("action", "off")]), + Step::new(StepKind::Reward, &[("title", "Hydrate")]), + ]; + let host = Arc::new(FakeHost::default()); + go(&host, &steps, ctx(&[], false)).await; + let calls: Vec = host.calls.lock().iter().map(|c| c.to_string()).collect(); + assert_eq!(calls, ["reward Hydrate false", "reward Hydrate true"]); + let host = Arc::new(FakeHost::default()); + let (_, log) = go(&host, &steps, ctx(&[], true)).await; + assert!(host.calls.lock().is_empty()); + assert_eq!(log[0].status, StepStatus::Skipped); + } + + #[tokio::test] + async fn a_step_can_name_the_viewer_itself() { + let env = env(); + let steps = [ + viewer_step(&[("who", "named"), ("login", "@{winner}"), ("by", "25")]), + viewer_step(&[("who", "named"), ("login", "{nobody}"), ("by", "25")]), + ]; + let host = Arc::new(FakeHost::default()); + let (status, _) = run(&steps, ctx(&[("winner", "Cy")], false), host, env.clone()).await; + assert_eq!(env.viewers.lock().get("coins", "cy"), 25); + assert_eq!(status, RunStatus::Failed, "a blank name is nobody"); + } + + #[tokio::test] + async fn everyone_who_chatted_lately_is_paid() { + let env = env(); + for login in ["ana", "bo"] { + let heard = chat_room::Heard { + user: login.to_string(), + user_login: login.to_string(), + message: "hi".to_string(), + }; + env.chat_room.heard(&heard, Instant::now()); + } + let steps = [ + viewer_step(&[("who", "active"), ("by", "10")]), + says("{active.count}"), + ]; + let wrote = as_ana(&env, &steps, false).await; + assert!(wrote.ends_with('2'), "{wrote}"); + assert_eq!(env.viewers.lock().get("coins", "bo"), 10); + } + + #[tokio::test] + async fn a_test_run_starts_from_given_values_and_saves_nothing() { + let env = env(); + env.counters.lock().insert("pot".to_string(), 5); + let steps = [ + viewer_step(&[("op", "spend"), ("by", "50")]), + Step::new(StepKind::Counter, &[("name", "pot"), ("by", "50")]), + Step::new( + StepKind::SetVar, + &[("name", "winner"), ("value", "Ana"), ("keep", "yes")], + ), + says("{paid} {viewer.coins} {counter.pot} {saved.winner}"), + ]; + let given = [ + ("user_login", "ana"), + ("viewer.coins", "200"), + ("counter.pot", "100"), + ]; + let host = Arc::new(FakeHost::default()); + let (_, log) = run(&steps, ctx(&given, true), host, env.clone()).await; + let said = &log.last().unwrap().detail; + assert!(said.contains("yes 150 150 Ana"), "{said}"); + assert_eq!(env.viewers.lock().get("coins", "ana"), 0); + assert_eq!(env.counters.lock().get("pot"), Some(&5)); + assert!(env.saved.lock().is_empty()); + } + + #[tokio::test] + async fn the_streamers_own_values_fill_messages_checks_and_counts() { + let env = env(); + let steps = [ + viewer_step(&[("by", "1")]), + says("{viewer.coins#$my.coin#$my.coins} / {my.coins} / [{my.nothing}]"), + Step { + then: vec![viewer_step(&[("by", "4")])], + ..Step::new( + StepKind::If, + &[("left", "{my.flip_chance}"), ("op", "chance")], + ) + }, + says("{viewer.coins#$my.coin#$my.coins}"), + ]; + let host = Arc::new(FakeHost::default()); + run( + &steps, + ctx(&[("user_login", "ana")], false), + host.clone(), + env, + ) + .await; + let wrote = host.calls.lock(); + assert!(wrote[0].ends_with("1 gem / gems / []"), "{wrote:?}"); + assert!(wrote[1].ends_with("5 gems"), "{wrote:?}"); + } + + #[tokio::test] + async fn a_kept_value_is_there_on_the_next_run() { + let env = env(); + let keep = [Step::new( + StepKind::SetVar, + &[("name", "winner"), ("value", "{user}"), ("keep", "yes")], + )]; + as_ana(&env, &keep, false).await; + let wrote = as_ana(&env, &[says("Last: {saved.winner}")], false).await; + assert!(wrote.ends_with("Last: Ana"), "{wrote}"); + } + + #[test] + fn maths_stays_whole_and_in_range() { + assert_eq!(sum("", 2, 3), Ok(5)); + assert_eq!(sum("subtract", 2, 3), Ok(-1)); + assert_eq!(sum("multiply", 50, 3), Ok(150)); + assert_eq!(sum("multiply", i64::MAX, 2), Ok(i64::MAX)); + assert_eq!(sum("divide", 7, 2), Ok(3)); + assert_eq!(sum("divide", i64::MIN, -1), Ok(i64::MAX)); + assert!(sum("divide", 7, 0).is_err()); + assert_eq!(sum("percent", 15, 200), Ok(30)); + assert_eq!(sum("min", 4, 9), Ok(4)); + assert_eq!(sum("max", 4, 9), Ok(9)); + for _ in 0..200 { + let roll = sum("random", 6, 1).unwrap(); + assert!((1..=6).contains(&roll), "{roll}"); + } + assert_eq!(sum("random", i64::MIN, i64::MIN), Ok(i64::MIN)); + } + + #[test] + fn a_chance_check_passes_about_that_often() { + assert!(!compare("0", "chance", "")); + assert!(compare("100", "chance", "")); + assert!(!compare("often", "chance", "")); + let hits = (0..4000).filter(|_| compare("25%", "chance", "")).count(); + assert!((800..1200).contains(&hits), "{hits} of 4000 at 25%"); + } + + #[tokio::test] + async fn a_maths_step_saves_its_answer() { + let steps = [ + Step::new( + StepKind::Math, + &[ + ("a", "{arg1}"), + ("op", "multiply"), + ("b", "3"), + ("save_as", "prize"), + ], + ), + says("{prize}"), + Step::new(StepKind::Math, &[("a", "ten"), ("b", "3")]), + ]; + let host = Arc::new(FakeHost::default()); + let (status, _) = go(&host, &steps, ctx(&[("arg1", "50")], false)).await; + assert!(host.calls.lock()[0].ends_with("150")); + assert_eq!(status, RunStatus::Failed, "\"ten\" is no number"); + } + + #[tokio::test] + async fn a_wait_for_chat_gets_the_answer_or_gives_up() { + let env = env(); + let wait = |seconds| { + Step::new( + StepKind::WaitChat, + &[ + ("text", "!accept"), + ("from", "target"), + ("seconds", seconds), + ], + ) + }; + let steps = [wait("30"), says("{reply.ok} {reply.user} {reply.arg1}")]; + let room = env.chat_room.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + let said = |login: &str, message: &str| chat_room::Heard { + user: login.to_uppercase(), + user_login: login.to_string(), + message: message.to_string(), + }; + room.heard(&said("cy", "!accept"), Instant::now()); + room.heard(&said("bo", "!accept now"), Instant::now()); + }); + let wrote = as_ana(&env, &steps, false).await; + assert!(wrote.ends_with("yes BO now"), "{wrote}"); + let steps = [wait("1"), says("{reply.ok}")]; + let wrote = as_ana(&env, &steps, false).await; + assert!(wrote.ends_with("no"), "{wrote}"); + } + + #[tokio::test] + async fn a_block_runs_in_place_and_its_stop_ends_the_run() { + let call = |block| Step::new(StepKind::Call, &[("block", block)]); + let blocks = BTreeMap::from([ + ( + "greet".to_string(), + vec![Step::new( + StepKind::SetVar, + &[("name", "hello"), ("value", "hi {user}")], + )], + ), + ( + "leave".to_string(), + vec![Step::new(StepKind::Stop, &[("quiet", "yes")])], + ), + ("loop".to_string(), vec![call("loop")]), + ]); + let with_blocks = || RunContext { + blocks: Arc::new(blocks.clone()), + ..ctx(&[("user", "Ana")], false) + }; + let host = Arc::new(FakeHost::default()); + let steps = [call("greet"), says("{hello}"), call("leave"), says("never")]; + let (status, _) = go(&host, &steps, with_blocks()).await; + assert_eq!(status, RunStatus::StoppedQuietly); + assert_eq!(host.calls.lock().len(), 1); + assert!(host.calls.lock()[0].ends_with("hi Ana")); + let (status, _) = go(&host, &[call("loop")], with_blocks()).await; + assert_eq!(status, RunStatus::Failed, "a block that runs itself ends"); + let (status, _) = go(&host, &[call("gone")], with_blocks()).await; + assert_eq!(status, RunStatus::Failed); + } + + #[tokio::test] + async fn a_counter_named_from_chat_is_read_under_a_fixed_name() { + let host = Arc::new(FakeHost::default()); + let env = env(); + env.counters.lock().insert("coins_ana".to_string(), 40); + let steps = [ + Step::new( + StepKind::Counter, + &[ + ("name", "coins_{user_login}"), + ("by", "0"), + ("save_as", "coins"), + ], + ), + Step::new( + StepKind::File, + &[("path", "c.txt"), ("text", "Has {coins}")], + ), + ]; + run( + &steps, + ctx(&[("user_login", "ana")], false), + host.clone(), + env.clone(), + ) + .await; + assert_eq!(env.counters.lock().get("coins_ana"), Some(&40)); + assert!(host.calls.lock()[0].ends_with("Has 40")); + } + #[tokio::test] async fn waiting_for_the_delay_follows_it() { let host = Arc::new(FakeHost { @@ -2715,6 +3525,9 @@ mod tests { fn light(&self, _: &str, _: lights::Command) -> Result, String> { Ok(None) } + fn reward(&self, _: &str, _: bool) -> Result<(), String> { + Ok(()) + } } let steps = [Step::new( StepKind::Discord, diff --git a/src/integrations/store.rs b/src/integrations/store.rs index 950cc25..3df6b23 100644 --- a/src/integrations/store.rs +++ b/src/integrations/store.rs @@ -1,5 +1,7 @@ //! What integrations keep between runs, in its own file next to the config: -//! the Twitch logins, the counters and how often each integration ran. +//! the Twitch logins, the counters, what each viewer holds, saved texts, +//! when viewers last used a command with a long cooldown, and how often +//! each integration ran. //! //! Kept apart from the settings on purpose. Tokens change on their own //! every few hours and counters on every crash; routing those through the @@ -7,6 +9,7 @@ //! saves. This file has one writer (the integrations engine), is written //! atomically, and never leaves the machine. +use super::viewers::ViewerValues; use crate::json::{self, Value}; use crate::sync::Mutex; use std::collections::BTreeMap; @@ -58,8 +61,17 @@ pub struct Store { path: PathBuf, pub accounts: Mutex, pub counters: Arc>>, - /// A counter changed since the file was last written. - pub counters_dirty: std::sync::atomic::AtomicBool, + /// A number per viewer under each name (`coins`): see `viewers`. + pub viewers: Arc>, + /// Texts a "Remember a value" step was told to keep (`{saved.name}`). + pub saved: Arc>>, + /// When each viewer last used each chat command, in Unix milliseconds, + /// by `engine::viewer_key`. Saved so a daily bonus stays daily across a + /// restart. + pub cooldowns: Mutex>, + /// A counter, viewer value, saved text or cooldown worth keeping + /// changed since the file was last written. + pub dirty: Arc, /// Runs per integration id, for `{uses}`. pub uses: Mutex>, /// Serialises writes so two savers never interleave. @@ -87,13 +99,20 @@ impl Store { .collect(), _ => BTreeMap::new(), }; - let uses = match doc.get("uses") { + let whole_numbers = |key: &str| match doc.get(key) { Some(Value::Obj(fields)) => fields .iter() .filter_map(|(k, v)| Some((k.clone(), v.as_f64()? as u64))) .collect(), _ => BTreeMap::new(), }; + let saved = match doc.get("saved") { + Some(Value::Obj(fields)) => fields + .iter() + .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))) + .collect(), + _ => BTreeMap::new(), + }; Store { path, accounts: Mutex::new(Accounts { @@ -101,8 +120,11 @@ impl Store { bot: account("twitch.bot"), }), counters: Arc::new(Mutex::new(counters)), - counters_dirty: Default::default(), - uses: Mutex::new(uses), + viewers: Arc::new(Mutex::new(ViewerValues::from_json(doc.get("viewers")))), + saved: Arc::new(Mutex::new(saved)), + cooldowns: Mutex::new(whole_numbers("cooldowns")), + dirty: Default::default(), + uses: Mutex::new(whole_numbers("uses")), write_lock: Mutex::new(()), } } @@ -134,14 +156,28 @@ impl Store { .map(|(k, v)| (k.clone(), Value::Num(*v as f64))) .collect(), ); - let uses = Value::Obj( - self.uses + let whole_numbers = |map: &BTreeMap| { + Value::Obj( + map.iter() + .map(|(k, v)| (k.clone(), Value::Num(*v as f64))) + .collect(), + ) + }; + let saved = Value::Obj( + self.saved .lock() .iter() - .map(|(k, v)| (k.clone(), Value::Num(*v as f64))) + .map(|(k, v)| (k.clone(), json::str(v))) .collect(), ); - json::obj([("twitch", twitch), ("counters", counters), ("uses", uses)]) + json::obj([ + ("twitch", twitch), + ("counters", counters), + ("viewers", self.viewers.lock().to_json()), + ("saved", saved), + ("cooldowns", whole_numbers(&self.cooldowns.lock())), + ("uses", whole_numbers(&self.uses.lock())), + ]) }; // On disk before the rename: after a power cut the file is the old // one or the new one, never a truncated one. @@ -172,6 +208,10 @@ mod tests { }); store.counters.lock().insert("crashes".into(), 3); store.uses.lock().insert("i1".into(), 41); + let paid = super::super::viewers::Change::Add(250); + store.viewers.lock().apply("coins", "ana", paid).unwrap(); + store.saved.lock().insert("winner".into(), "Ana".into()); + store.cooldowns.lock().insert("i1#0#ana".into(), 99); store.save().unwrap(); let back = Store::open(&dir); @@ -179,6 +219,9 @@ mod tests { assert!(back.accounts.lock().bot.is_none()); assert_eq!(back.counters.lock().get("crashes"), Some(&3)); assert_eq!(back.uses.lock().get("i1"), Some(&41)); + assert_eq!(back.viewers.lock().get("coins", "ana"), 250); + assert_eq!(back.saved.lock().get("winner").unwrap(), "Ana"); + assert_eq!(back.cooldowns.lock().get("i1#0#ana"), Some(&99)); } #[test] diff --git a/src/integrations/template.rs b/src/integrations/template.rs index 786aa26..9771819 100644 --- a/src/integrations/template.rs +++ b/src/integrations/template.rs @@ -1,6 +1,8 @@ //! Message templates: `{name}` inserts a value, `{name|text}` inserts //! `text` instead when the value is empty or 0, `{name#one#many}` inserts -//! the value and the word that fits its count ("1 viewer", "42 viewers"), +//! the value and the word that fits its count ("1 viewer", "42 viewers"; a +//! word written `$name` is that variable's value, so `{n#$my.coin#$my.coins}` +//! counts in whatever the streamer calls a coin), //! `{name?yes:no}` writes `yes` when the value is yes and `no` otherwise, //! and `{{name}}` writes a literal `{name}`. //! @@ -57,7 +59,12 @@ pub fn render_escaped(template: &str, vars: &dyn Vars, escape: &dyn Fn(&str) -> let word = if count == "1" { one } else { many }; out.push_str(&escape(count)); out.push(' '); - out.push_str(word); + match word.strip_prefix('$') { + Some(name) => { + out.push_str(&escape(&vars.lookup(name).unwrap_or_default())) + } + None => out.push_str(word), + } } Form::Choice(yes, no) => out.push_str(if is_yes(&value) { yes } else { no }), _ => out.push_str(&escape(&value)), diff --git a/src/integrations/twitch/auth.rs b/src/integrations/twitch/auth.rs index d38c21a..008b148 100644 --- a/src/integrations/twitch/auth.rs +++ b/src/integrations/twitch/auth.rs @@ -11,13 +11,15 @@ use crate::json::{self, Value}; -/// Chat, VOD markers and clips for the streamer's account, and reading -/// the channel's follows, subs, bits, redemptions and ad breaks for -/// Twitch event triggers (see `eventsub`). All read-only but chat, -/// markers and clips. +/// Chat, VOD markers and clips for the streamer's account, reading the +/// channel's follows, subs, bits and ad breaks for Twitch event triggers +/// (see `eventsub`), and channel points: hearing redemptions, and making +/// and switching the rewards the streamer sets up here. A login from +/// before rewards could be managed still hears redemptions; managing +/// them asks for a new login. pub const MAIN_SCOPES: &str = "chat:read chat:edit channel:manage:broadcast clips:edit \ - moderator:read:followers channel:read:subscriptions bits:read channel:read:redemptions \ - channel:read:ads"; + moderator:read:followers channel:read:subscriptions bits:read \ + channel:read:redemptions channel:manage:redemptions channel:read:ads"; /// A bot account only reads and posts chat. pub const BOT_SCOPES: &str = "chat:read chat:edit"; diff --git a/src/integrations/twitch/eventsub.rs b/src/integrations/twitch/eventsub.rs index dc50cdf..ccc6c0a 100644 --- a/src/integrations/twitch/eventsub.rs +++ b/src/integrations/twitch/eventsub.rs @@ -519,13 +519,17 @@ pub fn to_event(kind_type: &str, e: &Value) -> Option { }; let yes_no = |b: bool| if b { "yes" } else { "no" }; Some(match kind_type { - "channel.follow" => Event::new(EventKind::TwitchFollow).with("user", user("user_name")), + "channel.follow" => Event::new(EventKind::TwitchFollow) + .with("user", user("user_name")) + .with("user_login", e.str_or("user_login", "")), "channel.subscribe" => Event::new(EventKind::TwitchSub) .with("user", user("user_name")) + .with("user_login", e.str_or("user_login", "")) .with("tier", tier()) .with("gift", yes_no(e.bool_or("is_gift", false))), "channel.subscription.message" => Event::new(EventKind::TwitchResub) .with("user", user("user_name")) + .with("user_login", e.str_or("user_login", "")) .with("tier", tier()) .with("months", number("cumulative_months")) .with("streak", number("streak_months")) @@ -535,19 +539,23 @@ pub fn to_event(kind_type: &str, e: &Value) -> Option { ), "channel.subscription.gift" => Event::new(EventKind::TwitchGift) .with("user", user("user_name")) + .with("user_login", e.str_or("user_login", "")) .with("count", number("total")) .with("tier", tier()) .with("total", number("cumulative_total")), "channel.cheer" => Event::new(EventKind::TwitchCheer) .with("user", user("user_name")) + .with("user_login", e.str_or("user_login", "")) .with("bits", number("bits")) .with("message", e.str_or("message", "")), "channel.raid" => Event::new(EventKind::TwitchRaid) .with("user", user("from_broadcaster_user_name")) + .with("user_login", e.str_or("from_broadcaster_user_login", "")) .with("viewers", number("viewers")), "channel.channel_points_custom_reward_redemption.add" => { Event::new(EventKind::TwitchRedeem) .with("user", user("user_name")) + .with("user_login", e.str_or("user_login", "")) .with( "reward", e.path("reward.title").and_then(Value::as_str).unwrap_or(""), diff --git a/src/integrations/twitch/helix.rs b/src/integrations/twitch/helix.rs index cb2fcae..3cb53f4 100644 --- a/src/integrations/twitch/helix.rs +++ b/src/integrations/twitch/helix.rs @@ -1,5 +1,5 @@ -//! The Helix API calls integrations use: VOD markers, clips, and -//! subscribing to Twitch events. +//! The Helix API calls integrations use: VOD markers, clips, subscribing +//! to Twitch events, and the channel points rewards made here. //! Blocking; run off the async thread. use super::auth::read_body; @@ -86,14 +86,173 @@ pub fn subscribe_event( } } +/// A channel points reward this app made. Twitch lets an app list and +/// change only the rewards it created itself, never the streamer's others. +#[derive(Clone, Debug, PartialEq)] +pub struct Reward { + pub id: String, + pub title: String, + pub cost: u64, + pub enabled: bool, + /// What viewers read under the title. + pub prompt: String, + /// The viewer types a message with it (`{input}` in the trigger). + pub wants_input: bool, +} + +/// What a reward is made with, or changed to: each part that is given. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RewardEdit { + pub title: Option, + pub cost: Option, + pub prompt: Option, + pub wants_input: Option, + pub enabled: Option, +} + +impl RewardEdit { + fn to_json(&self) -> String { + let mut fields = Vec::new(); + if let Some(title) = &self.title { + fields.push(("title".to_string(), json::str(title))); + } + if let Some(cost) = self.cost { + fields.push(("cost".to_string(), Value::Num(cost as f64))); + } + if let Some(prompt) = &self.prompt { + fields.push(("prompt".to_string(), json::str(prompt))); + } + if let Some(wants_input) = self.wants_input { + let key = "is_user_input_required".to_string(); + fields.push((key, Value::Bool(wants_input))); + } + if let Some(enabled) = self.enabled { + fields.push(("is_enabled".to_string(), Value::Bool(enabled))); + } + Value::Obj(fields).to_json() + } +} + +/// The longest title and description Twitch takes for a reward. +pub const MAX_REWARD_TITLE: usize = 45; +pub const MAX_REWARD_PROMPT: usize = 200; + +fn rewards_url(auth: &Auth) -> String { + format!( + "{API}/channel_points/custom_rewards?broadcaster_id={}", + super::auth::url_encode(auth.user_id) + ) +} + +/// The rewards this app made on the channel. +pub fn rewards(auth: &Auth) -> Result, String> { + let url = format!("{}&only_manageable_rewards=true", rewards_url(auth)); + let (status, text) = send(auth, "GET", &url, "")?; + match status { + 200 => Ok(parse_rewards(&text)), + _ => Err(explain_rewards(status, &text, "list your rewards")), + } +} + +/// Make a reward viewers can redeem. `spec` needs a title and a cost. +pub fn create_reward(auth: &Auth, spec: &RewardEdit) -> Result { + let (status, text) = send(auth, "POST", &rewards_url(auth), &spec.to_json())?; + let made = parse_rewards(&text).into_iter().next(); + match (status, made) { + (200, Some(reward)) => Ok(reward), + _ => Err(explain_rewards(status, &text, "make the reward")), + } +} + +/// Change the parts of a reward that `edit` gives: its name, cost, +/// description, whether it asks for a message, whether it is on. Off, +/// viewers no longer see it. +pub fn update_reward(auth: &Auth, id: &str, edit: &RewardEdit) -> Result<(), String> { + let url = format!("{}&id={}", rewards_url(auth), super::auth::url_encode(id)); + let (status, text) = send(auth, "PATCH", &url, &edit.to_json())?; + match status { + 200 => Ok(()), + _ => Err(explain_rewards(status, &text, "change the reward")), + } +} + +pub fn delete_reward(auth: &Auth, id: &str) -> Result<(), String> { + let url = format!("{}&id={}", rewards_url(auth), super::auth::url_encode(id)); + let (status, text) = send(auth, "DELETE", &url, "")?; + match status { + 204 | 200 => Ok(()), + _ => Err(explain_rewards(status, &text, "delete the reward")), + } +} + +/// The rewards in a Twitch answer; one that is missing a field is skipped. +fn parse_rewards(body: &str) -> Vec { + let doc = json::parse(body).unwrap_or(Value::Null); + let list = doc.get("data").and_then(Value::as_array).unwrap_or(&[]); + list.iter() + .filter_map(|r| { + Some(Reward { + id: r.get("id")?.as_str()?.to_string(), + title: r.get("title")?.as_str()?.to_string(), + cost: r.get("cost").and_then(Value::as_f64).unwrap_or(0.0) as u64, + enabled: r.bool_or("is_enabled", true), + prompt: r.str_or("prompt", "").to_string(), + wants_input: r.bool_or("is_user_input_required", false), + }) + }) + .collect() +} + +/// Why Twitch said no to a rewards call, in the streamer's terms. A login +/// from before this app managed rewards lacks the permission (401), and a +/// channel that isn't Affiliate or Partner has no channel points (403). +fn explain_rewards(status: u16, body: &str, what: &str) -> String { + match status { + 401 => format!( + "Twitch refused to {what}: log out of Twitch here and in again, so InstantClone may manage rewards" + ), + 403 => format!( + "Twitch refused to {what}: channel points need an Affiliate or Partner channel, and an app can only change rewards it made" + ), + _ => explain(status, body, what), + } +} + fn post(auth: &Auth, url: &str, body: &str) -> Result<(u16, String), String> { - let resp = crate::https::https_agent_with_timeout(super::HTTP_TIMEOUT) - .post(url) - .header("Client-Id", auth.client_id) - .header("Authorization", format!("Bearer {}", auth.token)) - .header("Content-Type", "application/json") - .send(body) - .map_err(|e| e.to_string())?; + send(auth, "POST", url, body) +} + +/// One Helix call. `method` is GET, POST, PATCH or DELETE; the first and +/// last send no body. +fn send(auth: &Auth, method: &str, url: &str, body: &str) -> Result<(u16, String), String> { + let agent = crate::https::https_agent_with_timeout(super::HTTP_TIMEOUT); + let token = format!("Bearer {}", auth.token); + let resp = match method { + "GET" | "DELETE" => { + let request = if method == "GET" { + agent.get(url) + } else { + agent.delete(url) + }; + request + .header("Client-Id", auth.client_id) + .header("Authorization", &token) + .call() + } + _ => { + let request = if method == "PATCH" { + agent.patch(url) + } else { + agent.post(url) + }; + request + .header("Client-Id", auth.client_id) + .header("Authorization", &token) + .header("Content-Type", "application/json") + .send(body) + } + } + .map_err(|e| e.to_string())?; let status = resp.status().as_u16(); Ok((status, read_body(resp))) } @@ -109,3 +268,66 @@ fn explain(status: u16, body: &str, what: &str) -> String { (_, true) => format!("Twitch couldn't {what} (HTTP {status})"), } } + +#[cfg(test)] +mod reward_tests { + use super::*; + + #[test] + fn rewards_are_read_from_what_twitch_answers() { + let body = r#"{"data":[ + {"id":"r1","title":"Hydrate","cost":500,"is_enabled":true,"is_paused":false}, + {"id":"r2","title":"Buy coins","cost":1000,"is_enabled":false,"prompt":"10 points a coin","is_user_input_required":true}, + {"title":"no id"}]}"#; + let found = parse_rewards(body); + assert_eq!(found.len(), 2); + assert_eq!(found[0].title, "Hydrate"); + assert_eq!((found[1].cost, found[1].enabled), (1000, false)); + assert_eq!( + (found[1].prompt.as_str(), found[1].wants_input), + ("10 points a coin", true) + ); + assert_eq!( + (found[0].prompt.as_str(), found[0].wants_input), + ("", false) + ); + assert!(parse_rewards("not json").is_empty()); + } + + #[test] + fn an_edit_sends_only_what_changed() { + let switch = RewardEdit { + enabled: Some(false), + ..RewardEdit::default() + }; + assert_eq!(switch.to_json(), r#"{"is_enabled":false}"#); + let renamed = RewardEdit { + title: Some("Hydrate".into()), + cost: Some(300), + prompt: Some(String::new()), + wants_input: Some(true), + enabled: None, + }; + assert_eq!( + renamed.to_json(), + r#"{"title":"Hydrate","cost":300,"prompt":"","is_user_input_required":true}"# + ); + } + + #[test] + fn a_refusal_says_what_the_streamer_can_do_about_it() { + let old_login = explain_rewards(401, r#"{"message":"Missing scope"}"#, "make the reward"); + assert!( + old_login.contains("log out of Twitch here and in again"), + "{old_login}" + ); + let no_points = explain_rewards(403, "{}", "make the reward"); + assert!(no_points.contains("Affiliate or Partner"), "{no_points}"); + let twice = explain_rewards( + 400, + r#"{"message":"CREATE_CUSTOM_REWARD_DUPLICATE_REWARD"}"#, + "make the reward", + ); + assert!(twice.contains("DUPLICATE"), "{twice}"); + } +} diff --git a/src/integrations/twitch/mod.rs b/src/integrations/twitch/mod.rs index 907990a..72abc0a 100644 --- a/src/integrations/twitch/mod.rs +++ b/src/integrations/twitch/mod.rs @@ -662,6 +662,43 @@ impl Twitch { self.helix_call(helix::create_clip) } + /// The channel points rewards this app made on the channel. + pub fn rewards(&self) -> Result, String> { + self.helix_call(helix::rewards) + } + + pub fn create_reward(&self, spec: &helix::RewardEdit) -> Result { + self.helix_call(|a| helix::create_reward(a, spec)) + } + + pub fn update_reward(&self, id: &str, edit: &helix::RewardEdit) -> Result<(), String> { + self.helix_call(|a| helix::update_reward(a, id, edit)) + } + + pub fn delete_reward(&self, id: &str) -> Result<(), String> { + self.helix_call(|a| helix::delete_reward(a, id)) + } + + /// Switch the reward called `title` (any case) on or off. + pub fn set_reward_named(&self, title: &str, enabled: bool) -> Result<(), String> { + let wanted = title.trim().to_lowercase(); + let rewards = self.rewards()?; + let found = rewards.iter().find(|r| r.title.to_lowercase() == wanted); + match found { + Some(reward) => { + let switch = helix::RewardEdit { + enabled: Some(enabled), + ..helix::RewardEdit::default() + }; + self.update_reward(&reward.id, &switch) + } + None => Err(format!( + "no reward called \"{}\" was made here: make it under Channel points first", + title.trim() + )), + } + } + pub fn status_json(&self) -> Value { let conn_json = |which: Which| { let account = self.account(which); diff --git a/src/integrations/viewers.rs b/src/integrations/viewers.rs new file mode 100644 index 0000000..0dade2e --- /dev/null +++ b/src/integrations/viewers.rs @@ -0,0 +1,227 @@ +//! Viewer values: a number per viewer under a name the streamer picks +//! (`coins`, `points`), kept between runs and streams. Counters hold one +//! number each; these hold one per person, so a step can pay "the viewer" +//! and `{top.coins}` can rank them. +//! +//! Viewers are known by their Twitch login. A value at 0 isn't stored: a +//! viewer who spent everything costs nothing to keep. + +use crate::json::Value; +use std::collections::BTreeMap; + +/// Different values (`coins`, `xp`...) kept at once. +pub const MAX_VALUES: usize = 20; +/// Viewers one value holds. All of them are saved in one file. +pub const MAX_VIEWERS: usize = 50_000; +pub const MAX_NAME: usize = 32; +/// How many `{top.}` lists. +pub const TOP: usize = 5; + +/// What a step does to one viewer's value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Change { + Add(i64), + /// Never below 0: a fine takes what is there. + Subtract(i64), + Set(i64), + /// Subtract only when the viewer has that much (and it is 1 or more). + Spend(i64), +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ViewerValues { + /// Value name, then login. + values: BTreeMap>, +} + +/// A value's name as it is stored: letters, digits and `_`, so it can sit +/// in `{viewer.}`. +pub fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= MAX_NAME + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +/// A login as typed in chat (`@Ana`) as the key viewers are stored under. +pub fn login_key(typed: &str) -> String { + typed.trim().trim_start_matches('@').to_lowercase() +} + +impl ViewerValues { + pub fn get(&self, value: &str, login: &str) -> i64 { + self.values + .get(value) + .and_then(|viewers| viewers.get(login)) + .copied() + .unwrap_or(0) + } + + /// Apply `change`. Returns the new amount and whether it was applied: + /// only a `Spend` the viewer can't afford isn't. + pub fn apply( + &mut self, + value: &str, + login: &str, + change: Change, + ) -> Result<(i64, bool), String> { + let current = self.get(value, login); + let next = match change { + Change::Add(by) => current.saturating_add(by), + Change::Subtract(by) => current.saturating_sub(by).max(0), + Change::Set(to) => to, + Change::Spend(cost) if cost < 1 || cost > current => return Ok((current, false)), + Change::Spend(cost) => current - cost, + }; + self.put(value, login, next)?; + Ok((next, true)) + } + + fn put(&mut self, value: &str, login: &str, amount: i64) -> Result<(), String> { + if amount == 0 { + if let Some(viewers) = self.values.get_mut(value) { + viewers.remove(login); + } + return Ok(()); + } + if !self.values.contains_key(value) && self.values.len() >= MAX_VALUES { + return Err(format!("there are already {MAX_VALUES} viewer values")); + } + let viewers = self.values.entry(value.to_string()).or_default(); + if viewers.len() >= MAX_VIEWERS && !viewers.contains_key(login) { + return Err(format!("{value} already holds {MAX_VIEWERS} viewers")); + } + viewers.insert(login.to_string(), amount); + Ok(()) + } + + /// The viewers with the most, highest first; ties by name. + pub fn top(&self, value: &str, count: usize) -> Vec<(String, i64)> { + let mut all: Vec<(&String, &i64)> = self.values.get(value).into_iter().flatten().collect(); + all.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0))); + all.into_iter() + .take(count) + .map(|(login, amount)| (login.clone(), *amount)) + .collect() + } + + /// A viewer's place, 1 for the most; 0 when they hold nothing. + pub fn rank(&self, value: &str, login: &str) -> usize { + let mine = self.get(value, login); + if mine <= 0 { + return 0; + } + let ahead = self + .values + .get(value) + .map_or(0, |viewers| viewers.values().filter(|v| **v > mine).count()); + ahead + 1 + } + + /// Each value's name with how many viewers hold some. + pub fn names(&self) -> Vec<(String, usize)> { + self.values + .iter() + .filter(|(_, viewers)| !viewers.is_empty()) + .map(|(name, viewers)| (name.clone(), viewers.len())) + .collect() + } + + /// Forget a whole value. True when there was one. + pub fn clear(&mut self, value: &str) -> bool { + self.values.remove(value).is_some_and(|v| !v.is_empty()) + } + + pub fn to_json(&self) -> Value { + let amounts = |viewers: &BTreeMap| { + Value::Obj( + viewers + .iter() + .map(|(login, amount)| (login.clone(), Value::Num(*amount as f64))) + .collect(), + ) + }; + Value::Obj( + self.values + .iter() + .filter(|(_, viewers)| !viewers.is_empty()) + .map(|(name, viewers)| (name.clone(), amounts(viewers))) + .collect(), + ) + } + + /// Read what `to_json` wrote, dropping anything that doesn't fit. + pub fn from_json(v: Option<&Value>) -> ViewerValues { + let Some(Value::Obj(fields)) = v else { + return ViewerValues::default(); + }; + let mut out = ViewerValues::default(); + for (name, viewers) in fields.iter().filter(|(name, _)| valid_name(name)) { + let Value::Obj(viewers) = viewers else { + continue; + }; + for (login, amount) in viewers { + if let Some(amount) = amount.as_f64() { + let _ = out.put(name, login, amount as i64); + } + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_spend_only_takes_what_the_viewer_has() { + let mut v = ViewerValues::default(); + v.apply("coins", "ana", Change::Add(100)).unwrap(); + assert_eq!( + v.apply("coins", "ana", Change::Spend(150)).unwrap(), + (100, false) + ); + assert_eq!( + v.apply("coins", "ana", Change::Spend(-5)).unwrap(), + (100, false) + ); + assert_eq!( + v.apply("coins", "ana", Change::Spend(100)).unwrap(), + (0, true) + ); + assert_eq!(v.get("coins", "ana"), 0); + assert!(v.names().is_empty(), "a value at 0 isn't kept"); + } + + #[test] + fn a_fine_never_leaves_a_viewer_in_debt() { + let mut v = ViewerValues::default(); + v.apply("coins", "ana", Change::Add(10)).unwrap(); + assert_eq!( + v.apply("coins", "ana", Change::Subtract(25)).unwrap(), + (0, true) + ); + } + + #[test] + fn the_top_and_ranks_order_by_amount() { + let mut v = ViewerValues::default(); + for (login, coins) in [("ana", 50), ("bo", 900), ("cy", 50), ("di", 300)] { + v.apply("coins", login, Change::Set(coins)).unwrap(); + } + let top: Vec = v.top("coins", 3).into_iter().map(|(l, _)| l).collect(); + assert_eq!(top, ["bo", "di", "ana"]); + assert_eq!(v.rank("coins", "bo"), 1); + assert_eq!(v.rank("coins", "cy"), 3, "ties share a place"); + assert_eq!(v.rank("coins", "nobody"), 0); + } + + #[test] + fn values_survive_the_file() { + let mut v = ViewerValues::default(); + v.apply("coins", "ana", Change::Add(7)).unwrap(); + v.apply("xp", "bo", Change::Add(3)).unwrap(); + assert_eq!(ViewerValues::from_json(Some(&v.to_json())), v); + assert_eq!(login_key(" @Ana "), "ana"); + } +} diff --git a/src/web.rs b/src/web.rs index 55612d9..1e96ab2 100644 --- a/src/web.rs +++ b/src/web.rs @@ -3213,6 +3213,7 @@ async fn post_config_reset( next.configured = prev.configured; // Integrations and their connections are the user's own work too. next.integrations = prev.integrations; + next.user_values = prev.user_values; next.discord_channels = prev.discord_channels; next.phone = prev.phone; next.integrations_migrated = prev.integrations_migrated; diff --git a/web/index.html b/web/index.html index 9a935ff..b4890d0 100644 --- a/web/index.html +++ b/web/index.html @@ -3336,6 +3336,64 @@ .ig-cond-quick{display:flex;gap:6px} .ig-cond-join{align-self:center;margin:6px 0;padding:1px 10px;border-radius:999px;font-size:10.5px;font-weight:700;letter-spacing:.08em; text-transform:uppercase;color:var(--fg-3);background:var(--surface-3)} +.ig-reward{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--line)} +.ig-reward-t{display:flex;flex-direction:column;flex:1 1 0;min-width:0} +.ig-fires-row{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px} +.ig-fires{padding:2px 9px;border-radius:999px;border:1px solid var(--line);font-size:12px;opacity:.6} +.ig-fires.yes{border-color:var(--live);color:var(--live);opacity:1} +.ig-val-body{gap:0} +.ig-val-tabs{margin-bottom:14px} +.ig-val-find{margin-bottom:6px} +.ig-val-group{margin-top:14px} +.ig-val-group > .ic-label{margin-bottom:2px} +.ig-val{border-bottom:1px solid var(--line)} +.ig-val-main{display:flex;align-items:center;gap:12px;padding:10px 0;min-height:56px} +.ig-val.wide .ig-val-main{flex-wrap:wrap;align-items:flex-start} +.ig-val-what{display:flex;flex-direction:column;gap:2px;flex:1 1 0;min-width:0} +.ig-val-what b{overflow-wrap:anywhere} +.ig-val-meta{font-size:12px;opacity:.6} +.ig-val-meta code{font-size:11.5px} +.ig-val-edit{flex:0 1 46%;min-width:0;display:flex;flex-direction:column;gap:4px} +.ig-val.wide .ig-val-what{flex:1 1 60%} +.ig-val.wide .ig-val-edit{order:3;flex:1 1 100%} +.ig-val-edit textarea.ic-input{resize:vertical;min-height:72px;line-height:1.45} +.ig-val-num{max-width:140px;margin-left:auto;text-align:right} +.ig-val-chance{display:flex;align-items:center;gap:10px} +.ig-val-chance input[type=range]{flex:1 1 0;min-width:0;accent-color:var(--accent)} +.ig-val-unit{display:flex;align-items:center;gap:6px;flex:0 0 96px} +.ig-val-unit .ic-input{text-align:right;min-width:0} +.ig-val-more{display:flex;flex-direction:column;gap:10px;padding:4px 0 14px;align-items:flex-start} +.ig-val-more .dff{width:100%;margin:0} +.ig-val-name{display:flex;align-items:center;gap:2px;opacity:.85} +.ig-val-name .ic-input{flex:1 1 0;min-width:0} +.ig-val-empty{padding:18px 0} +.ig-value-list{margin:8px 0 0;padding-left:20px;max-height:220px;overflow:auto} +.ig-value-list li{padding:2px 0;overflow-wrap:anywhere} +.ig-picks{display:flex;flex-direction:column;gap:4px;margin:8px 0} +.ig-pick{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:10px;border:1px solid var(--line);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer} +.ig-pick:hover{border-color:var(--accent)} +.ig-pick.on{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent)} +.ig-pick-box{display:grid;place-items:center;flex:0 0 18px;height:18px;border-radius:5px;border:1px solid var(--line)} +.ig-pick.on .ig-pick-box{background:var(--accent);border-color:var(--accent);color:#06121a} +.ig-pick-box svg{width:13px;height:13px} +.ig-pick-t{flex:1 1 0;min-width:0;overflow-wrap:anywhere} +.ig-switch{flex:0 0 34px;height:20px;border-radius:999px;border:0;padding:2px;background:var(--line);cursor:pointer} +.ig-switch span{display:block;width:16px;height:16px;border-radius:50%;background:var(--fg);opacity:.7} +.ig-switch.on{background:var(--accent)} +.ig-switch.on span{margin-left:14px;background:#06121a;opacity:1} +.ig-reward.off .ig-reward-t{opacity:.55} +.ig-reward-wait{padding:14px 0} +.ig-reward-form{display:flex;flex-direction:column;gap:8px;padding:12px;margin:6px 0;border:1px solid var(--line);border-radius:12px} +.ig-reward-form-row{display:flex;gap:8px} +.ig-reward-form-row > .ic-input{flex:1 1 0;min-width:0} +.ig-reward-cost{display:flex;align-items:center;gap:6px;flex:0 0 120px} +.ig-reward-cost .ic-input{min-width:0;text-align:right} +.ig-reward-form-foot{display:flex;justify-content:flex-end;gap:8px} +.ig-holders-list{margin:6px 0 0;padding-left:22px} +.ig-holders-list li{padding:2px 0} +.ig-holders-list b{margin-left:8px;font-variant-numeric:tabular-nums} +.ig-holders-row{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap} +.ig-holders-row .ic-input{flex:1 1 90px;min-width:0} .ig-cond-add{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;margin-top:8px;padding:5px 10px 5px 8px;border-radius:8px; border:1px dashed var(--line-2);background:none;color:var(--fg-3);font:inherit;font-size:12.5px;cursor:pointer; transition:color .15s var(--ease-out),border-color .15s var(--ease-out)} diff --git a/web/integrations.js b/web/integrations.js index 0393254..a9c8b5b 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -112,6 +112,11 @@ const KINDS = { edit_text: {label:'Edit text', tag:'VALUE', c:'#93c5fd', icon:'type', hint:'Reshapes text: a line, a part, a rounded number, a random pick.'}, timeline: {label:'Timeline', tag:'LOG', c:'#5ac8fa', icon:'list', text:'text', hint:'Adds a line, a chapter or a highlight to this stream\'s timeline, at its place in the VOD.'}, light: {label:'Smart light', tag:'LIGHT', c:'#fde68a', icon:'bulb', hint:'Turns a light or plug on your network on or off, or colors it. Found by a scan: no hub or account.'}, + viewer: {label:'Viewer value', tag:'VALUE', c:'#fbbf24', icon:'gem', hint:'Gives, takes or spends what a viewer holds: coins, points. Kept between streams.'}, + math: {label:'Maths', tag:'VALUE', c:'#93c5fd', icon:'hash', hint:'Works out a number: a sum, a share, a random one.'}, + wait_chat: {label:'Wait for chat', tag:'WAIT', c:'#c3cad1', icon:'bubble', hint:'Waits for someone to type something, up to a time limit.'}, + call: {label:'Run a block', tag:'FLOW', c:'#f0a53a', icon:'layers', hint:'Runs a block of steps this integration keeps under a name.'}, + reward: {label:'Channel points reward', tag:'TWITCH', c:'#a78bfa', icon:'gift', hint:'Switches a channel points reward on or off, so viewers can only redeem it when it makes sense.'}, obs: {label:'OBS', tag:'OBS', c:'#86efac', icon:'layers', hint:'Switches a scene, shows or hides a source, records, saves a replay, mutes, switches a filter, plays media or presses an OBS hotkey.'}, }; // The icon a catalog module wears: what it is about, not how it sends. @@ -169,10 +174,10 @@ const hotkeyLabel = name => name.replace(/^OBSBasic\./, '').replace(/[._]/g, ' ' const OPS = { is:'is', is_not:'is not', contains:'contains', not_contains:'does not contain', starts_with:'starts with', greater:'is more than', less:'is less than', empty:'is empty', not_empty:'is not empty', - whole_number:'is a whole number', + whole_number:'is a whole number', chance:'% chance (0.1 is one in a thousand)', }; // Comparisons that need no value to compare with. -const OPS_ALONE = ['empty', 'not_empty', 'whole_number']; +const OPS_ALONE = ['empty', 'not_empty', 'whole_number', 'chance']; // An If holds up to this many checks: left, op, right, then left2… // Mirrors model::MAX_CONDITIONS. const MAX_CONDITIONS = 4; @@ -271,6 +276,7 @@ function triggerLabel(t){ } case 'chat_command': return t.command || '!command'; case 'chat_message': return 'Chat message'; + case 'block': return `Block: ${t.name || 'not named'}`; case 'timer': return `Every ${Math.round((t.every_ms||0)/60000)} min`; case 'webhook': return 'Web call'; case 'shortcut': return shortcutLabel(t) || 'A button'; @@ -503,7 +509,8 @@ function varsFor(handler, upto){ // A countdown sample should count down from now. if (e) own.push(...e.vars.map(v => v.name === 'hold_ends_at' ? {name:v.name, sample:String(Math.floor(Date.now() / 1000) + 120)} : v)); } - if (t && (t.type === 'chat_command' || t.type === 'chat_message')){ + // A block runs inside whatever started it: most often a command. + if (t && (t.type === 'chat_command' || t.type === 'chat_message' || t.type === 'block')){ const args = SAMPLE_ARGS.get(handler); own.push(...d.vars.chat.map(v => args == null ? v : {name:v.name, sample:{args, arg1:args.split(' ')[0], target:args.split(' ')[0], message:`${t.command || ''} ${args}`.trim()}[v.name] ?? v.sample})); @@ -512,7 +519,7 @@ function varsFor(handler, upto){ if (t && t.type === 'chat_activity') own.push(...d.vars.activity); if (t && t.type === 'scene') own.push(...d.vars.scene); const made = []; - const known = () => { const m = {}; own.concat(made, d.vars.global).forEach(v => { if (!(v.name in m)) m[v.name] = v.sample; }); return m; }; + const known = () => { const m = {}; own.concat(made, d.vars.global, myValues()).forEach(v => { if (!(v.name in m)) m[v.name] = v.sample; }); return m; }; const outputs = s => { if (s.type === 'http'){ const n = (s.params.save_as || '').trim() || 'response'; @@ -544,7 +551,16 @@ function varsFor(handler, upto){ if (s.type === 'clip') made.push({name:'clip.url', sample:'https://clips.twitch.tv/BraveSnipe'}, {name:'clip.id', sample:'BraveSnipe'}, {name:'clip.ok', sample:'yes'}, {name:'clip.error', sample:''}); if (s.type === 'counter' && fixed(s.params.name)) made.push({name:'counter.' + s.params.name, sample:'2'}); + if (s.type === 'counter' && (s.params.save_as || '').trim() && fixed(s.params.save_as.trim())) made.push({name:s.params.save_as.trim(), sample:'2'}); if (s.type === 'set_var' && fixed(s.params.name)) made.push({name:s.params.name, sample:s.params.value || ''}); + if (s.type === 'viewer' && s.params.op === 'spend') made.push({name:'paid', sample:'yes'}); + if (s.type === 'viewer' && s.params.who === 'active') made.push({name:'active.count', sample:'12'}); + if (s.type === 'math' && fixed((s.params.save_as || '').trim() || 'result')) made.push({name:(s.params.save_as || '').trim() || 'result', sample:'100'}); + if (s.type === 'wait_chat' && fixed((s.params.save_as || '').trim() || 'reply')){ + const n = (s.params.save_as || '').trim() || 'reply'; + made.push({name:n + '.ok', sample:'yes'}, {name:n + '.user', sample:'Nat'}, {name:n + '.user_login', sample:'nat'}, + {name:n + '.message', sample:s.params.text || '!accept'}, {name:n + '.arg1', sample:''}); + } if (s.type === 'edit_text' && fixed((s.params.save_as || '').trim() || 'text')) made.push({name:(s.params.save_as || '').trim() || 'text', sample:editTextSample(s, known())}); }; // Steps in run order up to `upto`: switched-off ones never run, and a @@ -570,9 +586,14 @@ function varsFor(handler, upto){ visit(handler && handler.steps); const seen = new Set(); return own.map(v => Object.assign({group:'trigger'}, v)) - .concat(made.map(v => Object.assign({group:'steps'}, v)), d.vars.global.map(v => Object.assign({group:'live'}, v))) + .concat(made.map(v => Object.assign({group:'steps'}, v)), myValues().map(v => Object.assign({group:'mine'}, v)), + d.vars.global.map(v => Object.assign({group:'live'}, v))) .filter(v => !seen.has(v.name) && seen.add(v.name)); } +// The streamer's own values as variables: {my.}, each with what it holds now. +function myValues(){ + return (S.data.values || []).map(v => ({name:'my.' + v.name, sample:v.value})); +} function sampleMap(handler){ const m = {}; varsFor(handler).forEach(v => { m[v.name] = v.sample; }); @@ -592,9 +613,10 @@ function unknownVars(handler, step){ const t = (handler && handler.trigger) || {}; // Families no list can hold: any counter (counters outlive a run), any // field of a web answer or of what a web call sent, any word of a command. - const prefixes = ['counter.'].concat(names.filter(n => n.endsWith('.status')).map(n => n.slice(0, -6))); + // Viewer values, the leaderboard and kept texts are read by name too. + const prefixes = ['counter.', 'viewer.', 'target.', 'top.', 'saved.'].concat(names.filter(n => n.endsWith('.status')).map(n => n.slice(0, -6))); if (t.type === 'webhook' || t.type === 'shortcut') prefixes.push('body.', 'query.'); - const chat = t.type === 'chat_command' || t.type === 'chat_message'; + const chat = t.type === 'chat_command' || t.type === 'chat_message' || t.type === 'block'; return [...used].filter(n => !names.includes(n) && !prefixes.some(p => n.startsWith(p)) && !(chat && /^arg[1-9]$/.test(n))) .map(name => ({name, hint:closestName(name, names)})); } @@ -645,7 +667,10 @@ function renderSample(text, vars, escape){ const forms = tail.slice(1).split('#'); if (forms.length !== 2) return all; const count = v.trim() || '0'; - return (escape ? escape(count) : count) + ' ' + (count === '1' ? forms[0] : forms[1]); + // A word written $name is that variable's value: {n#$my.coin#$my.coins}. + const word = count === '1' ? forms[0] : forms[1]; + const filled = word[0] === '$' ? (vars[word.slice(1)] == null ? '' : String(vars[word.slice(1)])) : word; + return (escape ? escape(count) : count) + ' ' + (word[0] === '$' && escape ? escape(filled) : filled); } // {name?yes:no}: one text or the other, by whether the value is yes. if (tail && tail[0] === '?'){ @@ -685,6 +710,39 @@ function editText(op, input, a, b){ return input; } // Mirrors runner::compare, so previews follow checks like a run does. +// How a chat message trigger can match, each with what it means in a line. +const MATCH_MODES = { + word:{label:'Has the word', means:'Fires when the message has it as its own word or phrase. Not when it is only part of a longer word.'}, + contains:{label:'Contains these letters', means:'Fires whenever these letters appear, in the middle of another word too.'}, + starts_with:{label:'Starts with', means:'Fires when the message begins with it.'}, + exact:{label:'Is exactly', means:'Fires when the whole message is just this.'}, + wildcard:{label:'Wildcard (* and ?)', means:'* stands for anything, ? for one character, and the whole message has to fit: gg* is any message starting with gg.'}, +}; +// Whether a chat message fires a trigger. Mirrors engine::text_matches. +function textMatches(text, pattern, mode){ + const said = String(text).toLowerCase(), want = String(pattern).trim().toLowerCase(); + if (!want) return false; + const literal = want.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + switch (mode){ + // No letter or digit right before or after it: an apostrophe ends a word. + case 'word': return new RegExp(`(?${tries.map(said => { + const fires = textMatches(said, pattern, mode); + return `${fires ? '✓' : '✗'} ${esc(said)}`; + }).join('')}`; +} function compareSample(left, op, right){ const l = String(left).trim().toLowerCase(), r = String(right).trim().toLowerCase(); const blank = l === '' || l === '0' || l === 'no'; @@ -700,6 +758,8 @@ function compareSample(left, op, right){ case 'greater': return nums && +l > +r; case 'less': return nums && +l < +r; case 'whole_number': return /^[0-9]+$/.test(l); + // A preview can't roll: it shows the branch that runs most often. + case 'chance': return parseFloat(l) >= 50; } return false; } @@ -895,7 +955,7 @@ function tokensHtml(vars, act, extra, foldLive){ }; const chip = v => ``; const head = v => { - if (!v.step) return {trigger:'From what happened', live:'Always there'}[v.group] || ''; + if (!v.step) return {trigger:'From what happened', mine:'Your values', live:'Always there'}[v.group] || ''; const name = answerName(v.step); return `${esc(stepSource(v.step))}${name ? `${esc(name)}` : ''}`; }; @@ -1912,6 +1972,7 @@ function render(){
Still experimental: they work, but how they look and how you set them up may still change. Found a bug or have an idea? Report it
${empty ? '' : ''} +
@@ -3676,6 +3737,9 @@ function stepMissing(s){ if (s.type === 'discord' && p.style === 'card' && !['title', 'text', 'fields', 'image', 'above'].some(k => (p[k] || '').trim())) return 'give the card a title or some text'; if (s.type === 'if' && conditionRows(s).some(r => !r.left.trim())) return 'say what it checks'; if ((s.type === 'set_var' || s.type === 'counter') && !(p.name || '').trim()) return 'name the value'; + if (s.type === 'viewer' && !/^[A-Za-z0-9_]{1,32}$/.test((p.value || '').trim())) return 'name the value: letters, digits and _'; + if (s.type === 'call' && !(p.block || '').trim()) return 'pick the block'; + if (s.type === 'reward' && !(p.title || '').trim()) return 'pick the reward'; if (s.type === 'wait' && !/\{|^\d+$/.test((p.ms || '').trim())) return 'set how long to wait'; if ([p.scene, p.source, p.filter, p.hotkey, s.type === 'light' ? p.device : '', s.type === 'program' || s.type === 'file' ? p.path : ''].some(v => /\{/.test(v || ''))) return 'names here can\'t use values'; if (s.type === 'light'){ @@ -4340,12 +4404,153 @@ function insertAtCursor(field, text){ // ---------------------------------------------------------------- builder +// Opens a value's name in a note, as the notes write them. +const MONO = ''; +// What a Maths step can do with its two numbers. Mirrors runner::sum. +const MATH_OPS = {'':'Plus', subtract:'Minus', multiply:'Times', divide:'Divided by', percent:'Percent of', + min:'Or, if smaller,', max:'Or, if bigger,', random:'To (a random number between them)'}; +// The channel points rewards InstantClone made on the channel: +// [{id, title, cost, enabled, prompt, wants_input}], null until read. +// `error` is why Twitch said no; `editing` is the reward whose form is +// open (its id, or 'new'). +const Rewards = {list:null, error:'', loading:false, editing:null}; +// Redeem triggers set to "only the ones I pick" with nothing picked yet: +// an empty filter means any reward, so the choice is remembered here. +const REWARD_ONLY = new WeakSet(); +// A reward's title as a filter pattern. A comma would split the list of +// patterns, so ? (any one character) stands in for it. +const rewardPattern = title => title.replace(/,/g, '?'); +const samePattern = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase(); +// A redeem trigger's reward filter: which of the rewards made here it +// names, and whatever else was typed (rewards made on Twitch itself). +function rewardChoice(t){ + const parts = ((t.filters || {}).reward || '').split(',').map(x => x.trim()).filter(Boolean); + const made = Rewards.list || []; + return { + any:!parts.length && !REWARD_ONLY.has(t), + picked:made.filter(r => parts.some(x => samePattern(x, rewardPattern(r.title)))).map(r => r.id), + other:parts.filter(x => !made.some(r => samePattern(x, rewardPattern(r.title)))), + }; +} +function setRewardChoice(t, ids, other){ + const titles = (Rewards.list || []).filter(r => ids.includes(r.id)).map(r => rewardPattern(r.title)); + t.filters = t.filters || {}; + t.filters.reward = titles.concat(other).join(', '); +} +// Which rewards start a redeem trigger: any, or the ones ticked. +function rewardPickHtml(t){ + const c = rewardChoice(t), made = Rewards.list || []; + const scope = `
${[['any', 'Any reward'], ['only', 'Only the ones I pick']].map(([id, label]) => + ``).join('')}${SEG_IND}
`; + if (c.any) return `
${scope}
Every redemption on your channel runs these steps.
`; + const rows = made.map(r => { + const on = c.picked.includes(r.id); + return ``; + }).join(''); + const nothing = !c.picked.length && !c.other.length; + return `
${scope} +
${rows || (Rewards.list ? '
You have made no rewards here yet. Make one below, or name one you made on Twitch.
' : '')}
+ +
${nothing ? 'Nothing picked yet: until you pick one, every reward starts this.' : 'Names you type match ignoring case. A * stands for anything.'}
`; +} +// One reward's form, to make it or change it. +function rewardFormHtml(r){ + return `
+
+ pts
+ + +
+
`; +} +// The rewards made here: a switch, an edit and a delete each, and a way to +// make one. Read from Twitch the first time it is shown. +function rewardsHtml(){ + if (!Rewards.list && !Rewards.error && !Rewards.loading){ + Rewards.loading = true; + queueMicrotask(() => loadRewards()); + } + const row = r => Rewards.editing === r.id ? rewardFormHtml(r) : `
+ + ${esc(r.title)}${esc(String(r.cost))} pts${r.wants_input ? ' · asks for a message' : ''}${r.prompt ? ' · ' + esc(r.prompt) : ''} + +
`; + const relogin = /log out of Twitch/.test(Rewards.error); + const body = Rewards.error ? `
${svg('warn')}${esc(Rewards.error)}
+
${relogin ? '' : ''} +
` + : !Rewards.list ? '
Asking Twitch for your rewards…
' + : `${Rewards.list.map(row).join('') || '
None yet. Make one and it is on your channel right away.
'} + ${Rewards.editing === 'new' ? rewardFormHtml({}) : ``}`; + return `
${body} +
Made and changed here, live on your channel at once. Twitch only lets an app change rewards it made, so ones you made on Twitch itself are not listed.
`; +} +// Ask the app for the rewards (with `change`: after making that change), +// then redraw. True when Twitch took it. +async function loadRewards(change){ + const r = await api('/integrations/rewards', change); + Rewards.loading = false; + const refused = !r || r.ok === false; + // A change Twitch refused leaves the list as it was, with a word why. + if (refused && change && Rewards.list) toast((r && r.error) || 'Twitch did not answer', 'err', 7000); + else Rewards.error = refused ? ((r && r.error) || 'Twitch did not answer') : ''; + if (!refused) Rewards.list = r.rewards || []; + const o = OWNERS[Modal.kind]; + if (o && o.render) o.render(); + return !refused; +} +// A reward's new name, wherever the integration being built names the old +// one: its redeem triggers and its reward steps. +function renameReward(d, was, now){ + const follow = steps => (steps || []).forEach(s => { + if (s.type === 'reward' && s.params.title === was) s.params.title = now; + follow(s.then); follow(s.else); + }); + d.handlers.forEach(h => { + follow(h.steps); + const f = h.trigger.filters; + if (h.trigger.event === 'twitch_redeem' && f && f.reward){ + f.reward = f.reward.split(',').map(x => samePattern(x, rewardPattern(was)) ? rewardPattern(now) : x.trim()).join(', '); + } + }); +} +// The names of an integration's blocks, for "Run a block" steps. +const blockNames = d => (d.handlers || []).filter(h => h.trigger.type === 'block' && h.trigger.name).map(h => h.trigger.name); +// Who holds each viewer value, once asked for: [{name, holders, top:[{login, amount}]}]. +const Holders = {values:null}; +// A viewer value's holders in its step's editor: the ones with the most, +// and a way to fix one viewer by hand or start everyone over. +function holdersHtml(value){ + if (!/^[A-Za-z0-9_]{1,32}$/.test(value)) return ''; + const mine = (Holders.values || []).find(v => v.name === value); + const list = !Holders.values ? '' : !mine ? '
Nobody holds any yet.
' + : `
${plural(mine.holders, 'viewer')} hold some. The most:
+
    ${mine.top.slice(0, 10).map(h => `
  1. ${esc(h.login)}${esc(String(h.amount))}
  2. `).join('')}
`; + return `
${list} +
+ +
+
+ ${mine ? '' : ''}
`; +} +// Ask the app for the holders (with `change`: after making that change), then redraw. +async function loadHolders(change){ + const r = await api('/integrations/viewers', change); + if (!r || r.ok === false){ toast((r && r.error) || 'That didn\'t work', 'err'); return; } + Holders.values = r.values || []; + const o = OWNERS[Modal.kind]; + if (o && o.render) o.render(); +} // The step editor: any trigger, any steps, checks with then/otherwise. // Selection and insertion points are paths like [2,'then',0]. const PALETTE = [ ['Send', [['discord', 'Discord'], ['chat', 'Twitch chat'], ['phone', 'Phone'], ['http', 'Web request']]], - ['Flow', [['if', 'If / otherwise'], ['wait', 'Wait'], ['wait_delay', 'Wait for the delay'], ['stop', 'Stop']]], - ['Stream', [['overlay', 'Show on stream'], ['delay_action', 'Delay action'], ['obs', 'OBS'], ['marker', 'VOD marker'], ['clip', 'Clip'], ['timeline', 'Timeline']]], + ['Flow', [['if', 'If / otherwise'], ['wait', 'Wait'], ['wait_delay', 'Wait for the delay'], ['wait_chat', 'Wait for chat'], ['call', 'Run a block'], ['stop', 'Stop']]], + ['Viewers', [['viewer', 'Viewer value'], ['math', 'Maths']]], + ['Stream', [['overlay', 'Show on stream'], ['reward', 'Channel points reward'], ['delay_action', 'Delay action'], ['obs', 'OBS'], ['marker', 'VOD marker'], ['clip', 'Clip'], ['timeline', 'Timeline']]], ['Home', [['light', 'Smart light']]], ['Anything', [['edit_text', 'Edit text'], ['set_var', 'Remember a value'], ['counter', 'Counter'], ['program', 'Run a program'], ['file', 'Write a file']]], ]; @@ -4368,6 +4573,8 @@ const TRIGGERS = [ eg:'Ctrl+Alt+C clips and posts the link'}, {id:'scene', icon:'layers', label:'An OBS scene', hint:'When OBS switches scene', eg:'The game scene turns the delay on'}, {id:'webhook', icon:'link', label:'A web call', hint:'Scripts and apps that send data', eg:'n8n sends a message to post'}, + {id:'block', icon:'layers', label:'A block of steps', hint:'Steps your other triggers run by name', + eg:'Check the bet once, use it in every game'}, ]; // Why a trigger tile can't be picked right now, or ''. function triggerBlocked(k){ @@ -4409,7 +4616,7 @@ const EVENT_INFO = { twitch_gift:{icon:'gift', desc:'Someone gifts subs to your community.'}, twitch_cheer:{icon:'gem', desc:'Bits in chat, with the message.'}, twitch_raid:{icon:'users', desc:'Another channel raids you, with its viewers.'}, - twitch_redeem:{icon:'bolt', desc:'A channel point reward is redeemed. Pick one in "Only if".'}, + twitch_redeem:{icon:'bolt', desc:'A viewer redeems a channel points reward: any, or the ones you pick.'}, twitch_ad:{icon:'tv', desc:'An ad break starts, run by you or by Twitch.'}, }; // Whether an event comes from Twitch's event feed (follows, subs, raids...). @@ -4642,6 +4849,12 @@ const Builder = { for (let n = 0; n < path.length - 1; n += 2) list = list[path[n]][path[n + 1]]; return {list, index:path[path.length - 1]}; }, + // Whether `path` leads to a step of this trigger. + has(path){ + let list = this.handler().steps; + for (let n = 0; list && n < path.length - 1; n += 2) list = (list[path[n]] || {})[path[n + 1]]; + return !!(list && list[path[path.length - 1]]); + }, listAt(path){ if (!path.length) return this.handler().steps; const s = this.stepAt(path.slice(0, -1)); @@ -4829,7 +5042,49 @@ const Builder = { case 'wait_delay': return '

Waits until the moment this started has reached your viewers. Only InstantClone can do this: it knows your delay.

' + duration('extra_ms', 'Then wait a bit more', 's', 0) + field('follow', 'If the delay changes meanwhile', {select:[['', 'Follow it'], ['no', 'Keep the delay from the start']]}); case 'if': return this.ifFields(s, note); - case 'stop': return note('Ends this run here.'); + case 'stop': return field('quiet', 'In the history', {select:[['', 'This run shows'], ['yes', 'This run is left out']]}) + + note('Ends this run here. Leave it out of the history when it stops most of the time, like a timer that rolls for a rare drop.'); + case 'viewer': { + const everyone = s.params.who === 'active'; + return field('value', 'Value (letters, digits and _)', {mono:true, fixed:true, ph:'coins'}) + + field('who', 'Whose', {select:[['', 'The viewer who started this'], ['target', 'Who they named (@name)'], ['named', 'A viewer this step names'], ['active', 'Everyone who chatted lately']]}) + + (s.params.who === 'named' ? field('login', 'Viewer', {mono:true, ph:'{reply.user_login}'}) : '') + + (everyone ? field('minutes', 'Chatted in the last (minutes)', {mono:true, ph:'10'}) + : field('op', 'Do', {select:[['', 'Give'], ['spend', 'Spend: only if they have enough'], ['subtract', 'Take: never below 0'], ['set', 'Set to'], ['reset', 'Reset to 0']]})) + + (s.params.op === 'reset' && !everyone ? '' : field('by', 'Amount', {mono:true, ph:'1'})) + + note(s.params.op === 'spend' && !everyone ? `Follow it with an If on ${MONO}{paid}
: yes when it was taken, no when they hold too little (or the amount isn't a whole number from 1 up).` + : `One number per viewer, kept between streams. Read it as ${MONO}{viewer.${esc(s.params.value || 'coins')}} or ${MONO}{target.${esc(s.params.value || 'coins')}}, add ${MONO}.rank for their place, and ${MONO}{top.${esc(s.params.value || 'coins')}} for the five who hold the most.`) + + holdersHtml((s.params.value || '').trim()); + } + case 'math': return field('a', 'Number', {mono:true, ph:'{arg1}'}) + + field('op', 'Do', {select:Object.entries(MATH_OPS)}) + + field('b', 'And', {mono:true, ph:'2'}) + + field('save_as', 'Save it as', {mono:true, fixed:true, ph:'result'}) + + note(`Whole numbers only. Later steps use it as ${MONO}{${esc((s.params.save_as || '').trim() || 'result')}}.`); + case 'wait_chat': { + const n = esc((s.params.save_as || '').trim() || 'reply'); + return field('text', 'A message that starts with (empty: any message)', {mono:true, ph:'!accept'}) + + field('from', 'From', {select:[['', 'Anyone'], ['viewer', 'The viewer who started this'], ['target', 'Who they named (@name)']]}) + + field('seconds', 'Wait up to (seconds, 300 at most)', {mono:true, ph:'30'}) + + field('save_as', 'Save the answer as', {mono:true, fixed:true, ph:'reply'}) + + note(`${MONO}{${n}.ok} is yes when someone answered in time, no otherwise. Then ${MONO}{${n}.user}, ${MONO}{${n}.user_login}, ${MONO}{${n}.message} and ${MONO}{${n}.arg1} (its second word) say who and what.`); + } + case 'reward': { + const made = (Rewards.list || []).map(r => r.title); + // A reward typed before the list was read, or made elsewhere since, still shows. + const titles = made.includes(s.params.title) || !s.params.title ? made : [s.params.title].concat(made); + return (titles.length ? field('title', 'Reward', {select:[['', 'Pick a reward']].concat(titles.map(t => [t, t]))}) + : field('title', 'Reward (its exact name)', {fixed:true, ph:'Hydrate'})) + + field('action', 'Switch it', {select:[['off', 'Off: viewers no longer see it'], ['', 'On: viewers can redeem it']]}) + + note('For a reward that should only be there some of the time: off while a round is running, on again when it is over.') + + rewardsHtml(); + } + case 'call': { + const blocks = blockNames(this.d); + return (blocks.length ? field('block', 'Block', {select:[['', 'Pick a block']].concat(blocks.map(b => [b, b]))}) + : note('No block yet. Add a trigger of the kind "A block of steps", name it and give it steps.')) + + note('Its steps run here with this run\'s values, and what they save stays for the next steps. A Stop inside the block stops this run too.'); + } case 'delay_action': return field('action', 'Does', {select:Object.entries(DELAY_ACTIONS)}) + (s.params.action === 'arm' ? field('seconds', 'Delay in whole seconds (0 turns it off, blank uses your Default delay), or a value like {arg1}', {mono:true, ph:'30'}) + note('Starts or changes the delay. A delay the buffer can\'t hold fails this step.') : '') @@ -4844,7 +5099,10 @@ const Builder = { + note(`Later steps can use {${esc((s.params.save_as || '').trim() || 'program')}.output} (what it printed), .code (how it ended) and .ok (yes when the code is 0). It runs with no window; one still running after 10 s is stopped.`) : '') + warnHtml('Runs on your PC. Recipes you import can never add this without asking you first.'); case 'file': return (localLocked() ? warnHtml(esc(LOCAL_LOCK_TEXT)) : '') + field('path', 'File (a fixed path, no variables)', {mono:true, fixed:true, ph:'C:\\Stream\\status.txt'}) + field('text', 'Text', {area:true}) + field('mode', 'Each time', {select:[['', 'Replace the text'], ['append', 'Add a line']]}); - case 'set_var': return field('name', 'Name', {mono:true, fixed:true, ph:'winner'}) + field('value', 'Value', {ph:'{user}'}) + note('Later steps use it as {name}. Lasts for this run.'); + case 'set_var': return field('name', 'Name', {mono:true, fixed:true, ph:'winner'}) + field('value', 'Value', {ph:'{user}'}) + + field('keep', 'Keep it', {select:[['', 'For this run'], ['yes', 'Between runs and streams']]}) + + note(s.params.keep === 'yes' ? `Later steps use it as ${MONO}{${esc(s.params.name || 'name')}}; any other run reads it as ${MONO}{saved.${esc(s.params.name || 'name')}}.` + : 'Later steps use it as {name}. Lasts for this run.'); case 'overlay': return field('title', 'Small title', {ph:'Clip'}) + field('text', 'Text', {area:true, ph:'{user} just clipped that'}) + field('seconds', 'On screen for', {select:[['3', '3 s'], ['6', '6 s'], ['10', '10 s'], ['15', '15 s'], ['30', '30 s']] .concat(['', '3', '6', '10', '15', '30'].includes(s.params.seconds || '') ? [] : [[s.params.seconds, s.params.seconds + ' s']])}) @@ -4866,7 +5124,8 @@ const Builder = { + note(`Later steps use it as {${esc((s.params.save_as || '').trim() || 'text')}}.`); } case 'counter': return field('name', 'Name', {mono:true, ph:'crashes'}) + field('op', 'Do', {select:[['', 'Add'], ['subtract', 'Subtract'], ['set', 'Set to'], ['reset', 'Reset to 0']]}) - + field('by', 'By', {mono:true, ph:'1'}) + note(`Kept between runs. Use it anywhere as {counter.${esc(s.params.name || 'name')}}.`); + + field('by', 'By', {mono:true, ph:'1'}) + field('save_as', 'Also save it as (optional)', {mono:true, fixed:true, ph:'coins'}) + + note(`Kept between runs. Use it anywhere as {counter.${esc(s.params.name || 'name')}}. When the name holds a value like {user_login}, save it under a fixed name to use it in later steps.`); case 'obs': return this.obsFields(s, field, note); case 'light': return lightPickerHtml(s.params) + lightControlsHtml(s.params, name => `data-bind="p" data-name="${name}"`) + note('The device is fixed here, so chat can never pick it. Color and brightness may use values: {arg1} turns "!light blue" blue. A dry test never touches your lights.'); @@ -4981,9 +5240,12 @@ const Builder = { ${svg(info.icon)}${esc(current ? current.label : t.event)}${esc(info.desc)} Change`; body += twitchEventStatusHtml(t); + // Channel points: the rewards made here are managed where they are used. + if (t.event === 'twitch_redeem') body += rewardPickHtml(t) + rewardsHtml(); if (isObsEvent(t)) body += '
Heard from OBS through its WebSocket server: switch it on once in OBS, Tools › WebSocket Server Settings.
'; body += paramsHtml(t, 't-'); - const filters = Editor.filterVars(t); + // Which reward is picked above, from a list: not typed here. + const filters = Editor.filterVars(t).filter(v => !(t.event === 'twitch_redeem' && v.name === 'reward')); if (filters.length){ body += `
Only if
${filters.map(v => { const cur = (t.filters || {})[v.name] || ''; @@ -5002,10 +5264,19 @@ const Builder = { body = `${commandClashHtml(this.d)}
${rolesHtml(t, 't-role')}
-
${durInput('t-ucd-dur', t.user_cooldown_ms || 0, 's')}
`; +
${durInput('t-ucd-dur', t.user_cooldown_ms || 0, 's')}
+ ${t.user_cooldown_ms ? `
+
Each viewer hears it once per wait. Waits of 10 minutes or more are remembered when the app restarts.
` : ''}`; + } else if (t.type === 'block'){ + body = `
+
It never starts on its own: a "Run a block" step in another trigger runs it, with that run's values. A Stop inside it stops that run.
`; } else if (t.type === 'chat_message'){ - body = `
-
+ const mode = t.mode || 'contains'; + body = `
+
+
${MATCH_MODES[mode].means}
${matchExamplesHtml(t.pattern || '', mode)}
+
+ ${(this.tryText || '').trim() && (t.pattern || '').trim() ? `
${textMatches(this.tryText, t.pattern, mode) ? 'This message fires it' : 'This message does not fire it'}
` : ''}
${rolesHtml(t, 't-role')}
`; } else if (t.type === 'timer'){ body = `
${durInput('t-every-dur', t.every_ms || 600000, 'm')}
@@ -5040,12 +5311,22 @@ const Builder = { else if (b === 'b-cooldown-dur'){ const ms = durationOf(0, 86400000); if (ms == null) return; this.d.cooldown_ms = ms; } else if (b === 'h-enabled') h.enabled = el.checked; else if (b === 't-filter'){ t.filters = t.filters || {}; t.filters[el.dataset.name] = el.value.trim(); } + else if (b === 't-reward-other'){ + setRewardChoice(t, rewardChoice(t).picked, el.value.split(',').map(x => x.trim()).filter(Boolean)); + // Typing a name must not redraw the field under the caret. + this.dirty = true; + return; + } else if (b === 't-command') t.command = el.value.trim().toLowerCase(); else if (b === 't-aliases') t.aliases = el.value.split(/[\s,]+/).map(x => x.trim().toLowerCase()).filter(Boolean); else if (b === 't-role'){ t.roles = t.roles || {}; t.roles[el.dataset.name] = el.checked; } else if (b === 't-ucd-dur'){ const ms = durationOf(0, 86400000); if (ms == null) return; t.user_cooldown_ms = ms; } else if (b === 't-pattern') t.pattern = el.value; + else if (b === 't-cd-reply') t.cooldown_reply = el.value.trim(); + else if (b === 't-block-name') this.renameBlock(t, el.value.trim()); else if (b === 't-mode') t.mode = el.value; + // Trying a message changes nothing that is saved. + else if (b === 't-try'){ this.tryText = el.value; this.render(); return; } else if (b === 't-every-dur'){ const ms = durationOf(60000, 7 * 86400000); if (ms == null) return; t.every_ms = ms; } else if (b === 't-live') t.only_live = el.checked; else if (b === 't-param'){ if (!paramInput(t, el)) return; } @@ -5071,6 +5352,16 @@ const Builder = { else if (!quietInput(this.d, el)) return; this.changed(); }, + // A block's new name, in the block and in every step that runs it. + renameBlock(t, name){ + const was = t.name; + const follow = steps => (steps || []).forEach(s => { + if (s.type === 'call' && was && s.params.block === was) s.params.block = name; + follow(s.then); follow(s.else); + }); + this.d.handlers.forEach(h => follow(h.steps)); + t.name = name; + }, changed(){ this.dirty = true; this.edits = (this.edits || 0) + 1; @@ -5098,6 +5389,19 @@ const Builder = { this.changed(); return true; } + case 'b-reward-scope': { + const t = this.handler().trigger; + if (el.dataset.v === 'any'){ REWARD_ONLY.delete(t); setRewardChoice(t, [], []); } + else REWARD_ONLY.add(t); + this.changed(); + return true; + } + case 'b-reward-pick': { + const t = this.handler().trigger, c = rewardChoice(t), id = el.dataset.id; + setRewardChoice(t, c.picked.includes(id) ? c.picked.filter(x => x !== id) : c.picked.concat(id), c.other); + this.changed(); + return true; + } case 'b-cond-add': { const s = selected(); if (s){ const rows = conditionRows(s); if (rows.length < MAX_CONDITIONS) setConditions(s, rows.concat({left:'', op:'is', right:''})); } @@ -5129,7 +5433,10 @@ const Builder = { return true; } case 'b-sel': { - this.sel = el.dataset.path === 'trigger' ? 'trigger' : this.resolve(el.dataset.path); + const picked = el.dataset.path === 'trigger' ? 'trigger' : this.resolve(el.dataset.path); + // A step still fading out after a switch of trigger isn't this trigger's. + if (Array.isArray(picked) && !this.has(picked)) return true; + this.sel = picked; // New blocks land right after the step picked. this.after = Array.isArray(this.sel) ? this.sel : null; this.render(); @@ -5437,8 +5744,9 @@ const Builder = { function newTrigger(type){ switch (type){ - case 'chat_command': return {type, command:'!command', aliases:[], roles:{everyone:true, subs:true, vips:true, mods:true}, user_cooldown_ms:30000}; - case 'chat_message': return {type, pattern:'', mode:'contains', roles:{everyone:true, subs:true, vips:true, mods:true}}; + case 'chat_command': return {type, command:'!command', aliases:[], roles:{everyone:true, subs:true, vips:true, mods:true}, user_cooldown_ms:30000, cooldown_reply:''}; + case 'block': return {type, name:''}; + case 'chat_message': return {type, pattern:'', mode:'word', roles:{everyone:true, subs:true, vips:true, mods:true}}; case 'timer': return {type, every_ms:600000, only_live:true}; case 'webhook': return {type, token:randomToken()}; case 'shortcut': return {type, hotkey:'', midi:'', token:'', only_live:false}; @@ -5465,6 +5773,8 @@ function newStep(kind, trigger){ overlay:{title:'', text:'', seconds:'6'}, edit_text:{input:'', op:'first_line', a:'', b:'', save_as:'text'}, obs:{action:'scene', scene:'', source:'', text:'', filter:'', hotkey:''}, light:{device:'', name:'', plug:'', action:'color', color:'red', brightness:''}, + viewer:{value:'coins', who:'', op:'', by:'10'}, math:{a:'', op:'multiply', b:'2', save_as:'result'}, + wait_chat:{text:'', from:'', seconds:'30', save_as:'reply'}, call:{block:''}, reward:{title:'', action:'off'}, }[kind] || {}; return {type:kind, params:clone(p), then:[], else:[]}; } @@ -5476,7 +5786,8 @@ function triggerSentence(t){ return triggerLabel(t) + (tune ? ` · ${tune.k.toLowerCase()} ${tune.v}` : '') + (f.length ? ' · only if ' + f.join(', ') : ''); } case 'chat_command': return `Someone types ${t.command || '!command'} · ${rolesLabel(t.roles)}`; - case 'chat_message': return `A chat message ${t.mode === 'exact' ? 'is' : t.mode === 'starts_with' ? 'starts with' : 'matches'} "${t.pattern || ''}"`; + case 'chat_message': return `A chat message ${{exact:'is', starts_with:'starts with', word:'has the word', contains:'contains the letters'}[t.mode] || 'matches'} "${t.pattern || ''}"`; + case 'block': return t.name ? `A "Run a block" step runs ${t.name}` : 'A block of steps (not named yet)'; case 'timer': return `Every ${fmtDur(t.every_ms || 0)}${t.only_live !== false ? ' while streaming' : ''}`; case 'webhook': return 'Something calls its secret address'; case 'shortcut': return (shortcutLabel(t) ? `You press ${shortcutLabel(t)}` : 'You press a button (not set yet)') + (t.only_live ? ' while streaming' : ''); @@ -5786,14 +6097,25 @@ function stepSentence(s){ const one = r => `${highlightVars(r.left || '…')} ${esc(OPS[r.op] || r.op || '')}${OPS_ALONE.includes(r.op) ? '' : ' ' + highlightVars(r.right || '')}`; return 'If ' + conditionRows(s).map(one).join(p.match === 'any' ? ' or ' : ' and '); } - case 'stop': return 'Stop here'; + case 'stop': return p.quiet === 'yes' ? 'Stop here, out of the history' : 'Stop here'; case 'delay_action': return p.action === 'arm' && p.seconds && Number(p.seconds) <= 0 ? 'Turn the delay off' : p.action === 'arm' && p.seconds ? `Set a ${highlightVars(p.seconds)} s delay` : esc(DELAY_ACTIONS[p.action] || 'Delay action'); case 'marker': return `VOD marker ${q(p.description)}`; case 'clip': return 'Create a clip'; case 'program': return `Run ${p.path ? esc(p.path.split(/[\\/]/).pop()) : 'a program you pick'} ${highlightVars(p.args || '')}${p.wait === 'yes' ? ', and keep its answer' : ''}`; case 'file': return `${p.mode === 'append' ? 'Add to' : 'Write'} ${p.path ? esc(p.path.split(/[\\/]/).pop()) : 'a file you pick'} ${q(p.text)}`; - case 'set_var': return `Remember ${esc(p.name || '…')} = ${highlightVars(p.value || '')}`; + case 'set_var': return `${p.keep === 'yes' ? 'Keep' : 'Remember'} ${esc(p.name || '…')} = ${highlightVars(p.value || '')}`; + case 'viewer': { + const whose = {target:'who they named', active:'everyone who chatted', named:highlightVars(p.login || '…')}[p.who] || 'the viewer'; + const amount = highlightVars(p.by || '1'), value = esc(p.value || '…'); + if (p.who === 'active') return `Give ${amount} ${value} to ${whose}`; + return {spend:`Spend ${amount} ${value} of ${whose}, if they have it`, subtract:`Take ${amount} ${value} from ${whose}`, + set:`Set ${value} of ${whose} to ${amount}`, reset:`Reset ${value} of ${whose}`}[p.op] || `Give ${amount} ${value} to ${whose}`; + } + case 'math': return `${highlightVars(p.a || '0')} ${esc((MATH_OPS[p.op || ''] || '').toLowerCase())} ${highlightVars(p.b || '0')} → {${esc((p.save_as || '').trim() || 'result')}}`; + case 'wait_chat': return `Wait up to ${highlightVars(p.seconds || '30')} s for ${p.text ? q(p.text) : 'a message'} from ${{viewer:'the viewer', target:'who they named'}[p.from] || 'anyone'}`; + case 'call': return `Run the block ${esc(p.block || '…')}`; + case 'reward': return `Switch the reward ${q(p.title || '…')} ${p.action === 'off' ? 'off' : 'on'}`; case 'overlay': return `On stream ${q(p.text)}`; case 'edit_text': return `${esc((EDIT_OPS[p.op] || EDIT_OPS.first_line)[0])}: ${highlightVars(p.input || '…')} → {${esc((p.save_as || '').trim() || 'text')}}`; case 'counter': return `Counter ${esc(p.name || '…')} ${p.op === 'reset' ? 'reset' : p.op === 'set' ? '= ' + esc(p.by || '0') : (p.op === 'subtract' ? '-' : '+') + esc(p.by || '1')}`; @@ -6699,6 +7021,157 @@ function recipeWarningHtml(p){ return warnHtml('Read the red lines before adding it. Recipes arrive switched off: nothing runs until you open one and switch it on.'); } +// Your values: things set once ({my.name}) that any message or step reads, +// so a coin's name or a game's odds change in one place; and, on its own +// tab, what viewers hold. Each row shows what the value is for and one +// field to change it; its name and label hide behind the pencil, since +// renaming is rare and changing the value is the point. +const Values = { + list:[], tab:'values', find:'', more:null, saved:'', + open(){ + this.list = clone(S.data.values || []); + this.saved = JSON.stringify(this.clean()); + this.tab = 'values'; + this.find = ''; + this.more = null; + Modal.open('values', 860); + Modal.onClose = () => !this.dirty() || (Modal.guard(), false); + this.render(); + // Who holds what, without being asked: this is where it is managed. + loadHolders(); + }, + // What Save would send: rows left wholly empty were never meant to be one. + clean(){ + return this.list.filter(v => v.name || v.label || v.value).map(v => ({name:v.name.trim(), label:v.label.trim(), value:v.value})); + }, + dirty(){ return JSON.stringify(this.clean()) !== this.saved; }, + // How a value is best edited, read off what it holds and what it is for. + kindOf(v){ + if (/\n/.test(v.value) || v.value.length > 60 || / \| /.test(v.value)) return 'list'; + if (!/^-?\d+(\.\d+)?$/.test(v.value.trim())) return 'text'; + return /chance|percent|%/i.test(v.name + ' ' + v.label) ? 'chance' : 'number'; + }, + // How many integrations read {my.} somewhere. + usedIn(name){ + if (!name) return 0; + const reads = new RegExp(`[{$]my\\.${name}(?![A-Za-z0-9_])`); + return S.data.integrations.filter(i => reads.test(JSON.stringify(i))).length; + }, + editorHtml(v, n){ + const at = `data-n="${n}"`; + switch (this.kindOf(v)){ + case 'chance': return ` + %`; + case 'number': return ``; + case 'list': { + const lines = v.value.split('\n').filter(x => x.trim()).length; + return ` +
${/\n/.test(v.value) ? `${plural(lines, 'line')}: one is picked each time.` : 'One per line, or separated by |.'}
`; + } + } + return ``; + }, + rowHtml(v, n){ + const kind = this.kindOf(v), open = this.more === n, used = this.usedIn(v.name.trim()); + const meta = !v.name ? 'Give it a name to use it' : `{my.${esc(v.name)}} · ${used ? 'used in ' + plural(used, 'integration') : 'not used yet'}`; + return `
+
${esc(v.label || v.name || 'New value')}${meta}
+
${this.editorHtml(v, n)}
+
+ ${open ? `
+
{my.} + ${used ? `
${plural(used, 'integration')} read${used === 1 ? 's' : ''} it by this name: renaming it here leaves them reading nothing.
` : ''}
+
` : ''}
`; + }, + valuesHtml(){ + const find = this.find.trim().toLowerCase(); + const shown = this.list.map((v, n) => ({v, n})).filter(({v, n}) => n === this.more || !find || [v.name, v.label, v.value].some(x => x.toLowerCase().includes(find))); + const groups = [['text', 'Words'], ['number', 'Amounts'], ['chance', 'Chances'], ['list', 'Lists a step picks from']] + .map(([kind, title]) => ({title, rows:shown.filter(({v}) => this.kindOf(v) === kind)})).filter(g => g.rows.length); + return `${this.list.length > 6 ? `` : ''} + ${groups.map(g => `
${g.title}
${g.rows.map(({v, n}) => this.rowHtml(v, n)).join('')}
`).join('')} + ${!this.list.length ? '
Nothing yet. A recipe you import brings its own values; or add one and write it as {my.name} wherever a value fits.
' + : !shown.length ? '
Nothing matches.
' : ''} + +
Use one in any message or step as {my.name}. In a counting word, write it with a $: {viewer.coins#$my.coin#$my.coins} says "1 coin" or "5 coins" in your words.
`; + }, + heldHtml(){ + const held = (Holders.values || []).map(v => holdersHtml(v.name)).join(''); + return !Holders.values ? '
Asking the app who holds what…
' + : held || '
Nothing yet. A "Viewer value" step gives viewers coins or points, and they show up here.
'; + }, + render(){ + if (Modal.kind !== 'values') return; + const dirty = this.dirty(), holders = (Holders.values || []).reduce((sum, v) => sum + v.holders, 0); + const tabs = [['values', `Values · ${this.clean().length}`], ['held', `What viewers hold${holders ? ' · ' + holders : ''}`]]; + Modal.body(`${modalHead('steps', 'var(--accent)', 'Your values', 'Set something once and use it in any message or step. Change it here and everything that uses it follows.')} +
+
${tabs.map(([id, label]) => ``).join('')}${SEG_IND}
+ ${this.tab === 'held' ? this.heldHtml() : this.valuesHtml()} +
+
${dirty ? 'Changes not saved yet' : ''}
+
`); + }, + input(el){ + const b = el.dataset.bind, v = this.list[+el.dataset.n]; + if (b === 'v-find') this.find = el.value; + else if (!v) return; + else if (b === 'v-value') v.value = el.value; + else if (b === 'v-name'){ v.name = el.value.trim(); v.named = true; } + else if (b === 'v-label'){ + v.label = el.value; + // Until a name is typed, it follows the label: "!daily gives" is daily_gives. + if (!v.named && !this.usedIn(v.name)) v.name = el.value.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 32); + } + this.render(); + }, + // Why the list can't be saved as it is, with the row to look at; or null. + problem(){ + const names = new Set(); + for (const [n, v] of this.list.entries()){ + if (!v.name && !v.label && !v.value) continue; + if (!/^[A-Za-z0-9_]{1,32}$/.test(v.name.trim())) return {n, why:`"${v.label || v.name || 'A value'}" needs a name: letters, digits and _`}; + if (names.has(v.name.trim())) return {n, why:`Two values are named ${v.name.trim()}`}; + names.add(v.name.trim()); + } + return null; + }, + async act(a, el){ + const n = +el.dataset.n; + if (a === 'v-tab'){ this.tab = el.dataset.v; this.render(); return true; } + if (a === 'v-more'){ this.more = this.more === n ? null : n; this.render(); return true; } + if (a === 'v-add'){ + this.list.push({name:'', label:'', value:''}); + this.more = this.list.length - 1; + this.find = ''; + this.render(); + focusField('v-label'); + return true; + } + if (a === 'v-del'){ + if (this.usedIn(this.list[n].name) && !armConfirm(el, 'Still in use. Delete?')) return true; + this.list.splice(n, 1); + this.more = null; + this.render(); + return true; + } + if (a === 'v-save'){ + const wrong = this.problem(); + if (wrong){ this.more = wrong.n; this.find = ''; this.render(); toast(wrong.why, 'err', 6000); return true; } + await busy(el, async () => { + const values = this.clean(); + const r = await api('/integrations/values', {values}); + if (!r || r.ok === false){ toast((r && r.error) || 'That didn\'t save', 'err', 6000); return; } + this.saved = JSON.stringify(values); + if (Modal.kind === 'values') Modal.close(true); + await load(); + toast('Saved', 'ok'); + }); + return true; + } + return false; + }, +}; const Recipes = { preview:null, text:'', back:null, // Which "See every step" and "Raw settings" rows are open. @@ -6730,6 +7203,9 @@ const Recipes = {
${recipeWarningHtml(p)} ${p.alerts ? this.alertsHtml(p) : ''} + ${(p.values || []).length ? `
Values it brings
+
You change these later under Your values. One you already set stays as you set it.
+
    ${p.values.map(v => `
  • ${esc(v.label || v.name)} ${esc(v.mine == null ? v.value : v.mine)}${v.mine == null ? '' : ' (yours, kept)'}
  • `).join('')}
` : ''} ${p.uses_discord ? (channels.length ? `
` : warnHtml('It posts to Discord: add a Discord channel first.')) : ''} ${p.local_effects && !localLocked() ? `` : ''} @@ -6857,7 +7333,7 @@ const Recipes = { // ---------------------------------------------------------------- events -const OWNERS = {editor:Editor, builder:Builder, connections:Conn, alerts:AlertsLook, import:Recipes, share:Recipes, catalog:Catalog}; +const OWNERS = {editor:Editor, builder:Builder, connections:Conn, alerts:AlertsLook, import:Recipes, share:Recipes, catalog:Catalog, values:Values}; async function onClick(e){ const el = e.target.closest('[data-act]'); @@ -6920,6 +7396,7 @@ async function onClick(e){ case 'catalog': Catalog.open(); break; case 'build': Builder.open(null); break; case 'import': Recipes.openImport(); break; + case 'values': Values.open(); break; case 'share': Recipes.openShare(); break; case 'conn': Conn.open(el.dataset.tab); break; case 'retry': busy(el, load); break; @@ -6931,6 +7408,47 @@ async function onClick(e){ case 'view': S.view = el.dataset.v; store.set('ig-view', S.view); render(); break; case 'copy': copyText(el); break; case 'twitch-app': openTwitchAppSettings(); break; + case 'rewards-load': Rewards.error = ''; busy(el, () => loadRewards()); break; + case 'rewards-new': case 'rewards-edit': case 'rewards-cancel': { + const row = el.closest('.ig-reward'); + Rewards.editing = a === 'rewards-new' ? 'new' : a === 'rewards-edit' ? row.dataset.id : null; + if (OWNERS[Modal.kind] && OWNERS[Modal.kind].render) OWNERS[Modal.kind].render(); + const first = q('.ig-reward-form [data-r="title"]'); + if (first && a !== 'rewards-cancel') first.focus(); + break; + } + case 'rewards-save': { + const form = el.closest('.ig-reward-form'), typed = name => form.querySelector(`[data-r="${name}"]`); + const title = typed('title').value.trim(), cost = typed('cost').value.trim(); + if (!title || !/^[1-9]\d*$/.test(cost)){ toast('Give it a name and a cost in points', 'err'); break; } + const change = {title, cost:+cost, prompt:typed('prompt').value.trim(), wants_input:typed('wants_input').checked}; + if (form.dataset.id) change.id = form.dataset.id; + const was = form.dataset.was; + busy(el, async () => { + if (!await loadRewards(change)) return; + Rewards.editing = null; + // What this integration said about the old name follows the new one. + if (was && was !== title && Modal.kind === 'builder'){ renameReward(Builder.d, was, title); Builder.changed(); } + else if (OWNERS[Modal.kind] && OWNERS[Modal.kind].render) OWNERS[Modal.kind].render(); + }); + break; + } + case 'rewards-switch': busy(el, () => loadRewards({id:el.closest('.ig-reward').dataset.id, enabled:!!el.dataset.on})); break; + case 'rewards-delete': + if (!armConfirm(el, 'Delete?')) break; + busy(el, () => loadRewards({id:el.closest('.ig-reward').dataset.id, delete:true})); + break; + case 'holders-load': busy(el, () => loadHolders()); break; + case 'holders-set': { + const box = el.closest('.ig-holders'), field = name => box.querySelector(`[data-h="${name}"]`).value.trim(); + if (!field('login') || !/^-?\d+$/.test(field('amount'))){ toast('Type a viewer and a whole number', 'err'); break; } + busy(el, () => loadHolders({value:box.dataset.value, login:field('login'), amount:+field('amount')})); + break; + } + case 'holders-clear': + if (!armConfirm(el, 'Everyone back to 0. Sure?')) break; + busy(el, () => loadHolders({value:el.closest('.ig-holders').dataset.value, clear:true})); + break; case 'light-scan': busy(el, async () => { const r = await api('/integrations/lights/scan', {}); @@ -6999,6 +7517,7 @@ function onInput(e){ if (Modal.kind === 'editor') Editor.input(el); else if (Modal.kind === 'builder') Builder.input(el); else if (Modal.kind === 'alerts') AlertsLook.input(el); + else if (Modal.kind === 'values') Values.input(el); // Pasted or dropped, not typed: typing would preview half a recipe. else if (Modal.kind === 'import' && el.dataset.bind === 'r-text' && /^insertFrom(Paste|Drop)$/.test(e.inputType || '')) Recipes.pasted(el); } diff --git a/web/tests/integrations.test.mjs b/web/tests/integrations.test.mjs index 3914662..c98d5d5 100644 --- a/web/tests/integrations.test.mjs +++ b/web/tests/integrations.test.mjs @@ -407,3 +407,63 @@ test('an alert card takes its color, or the app\'s blue', () => { assert.equal(call('cardColor', value, vars), color, String(value)); } }); + +test('viewer values, maths, chat answers and blocks read like what they do', () => { + const sentence = (type, params) => call('stepSentence', { type, params }).replace(/<[^>]+>/g, ''); + assert.equal(sentence('viewer', { value: 'coins', op: 'spend', by: '{bet}' }), 'Spend {bet} coins of the viewer, if they have it'); + assert.equal(sentence('viewer', { value: 'coins', who: 'target', by: '50' }), 'Give 50 coins to who they named'); + assert.equal(sentence('viewer', { value: 'coins', who: 'active', by: '10' }), 'Give 10 coins to everyone who chatted'); + assert.equal(sentence('math', { a: '{bet}', op: 'multiply', b: '2', save_as: 'prize' }), '{bet} times 2 → {prize}'); + assert.equal(sentence('wait_chat', { text: '!accept', from: 'target', seconds: '30' }), 'Wait up to 30 s for “!accept” from who they named'); + assert.equal(sentence('call', { block: 'take the bet' }), 'Run the block take the bet'); + assert.equal(sentence('stop', { quiet: 'yes' }), 'Stop here, out of the history'); + assert.equal(sentence('set_var', { name: 'winner', value: 'x', keep: 'yes' }), 'Keep winner = x'); + assert.deepEqual(plain(call('blockNames', { handlers: [{ trigger: { type: 'block', name: 'take the bet' } }, { trigger: { type: 'timer' } }] })), ['take the bet']); +}); + +test('what the new steps hand on is known, and kept values are read by name', () => { + given(); + const spend = { type: 'viewer', params: { value: 'coins', op: 'spend', by: '{arg1}' } }; + const double = { type: 'math', params: { a: '{arg1}', op: 'multiply', b: '2', save_as: 'prize' } }; + const wait = { type: 'wait_chat', params: { text: '!accept', save_as: 'answer' } }; + const reply = { type: 'chat', params: { text: '{paid} {prize} {answer.ok} {answer.user} {viewer.coins} {target.coins.rank} {top.coins} {saved.winner} {reply.ok}' } }; + const handler = { trigger: { type: 'chat_command', command: '!bet' }, steps: [spend, double, wait, reply] }; + assert.deepEqual(plain(call('unknownVars', handler, reply)), [{ name: 'reply.ok', hint: '' }], 'only the answer nothing saved under that name'); + // A block is run from a command: it reads what the command brought. + const block = { trigger: { type: 'block', name: 'take the bet' }, steps: [spend] }; + assert.deepEqual(plain(call('unknownVars', block, { type: 'chat', params: { text: '{user} {arg1}' } })), []); +}); + +test('a chance check previews its likelier side', () => { + assert.equal(call('compareSample', '75', 'chance', ''), true); + assert.equal(call('compareSample', '3.3', 'chance', ''), false); +}); + +test('your own values fill previews, counting words included', () => { + given({ values: [{ name: 'coin', label: 'One coin', value: 'gem' }, { name: 'coins', label: 'Several coins', value: 'gems' }] }); + const vars = { 'viewer.coins': '1', 'my.coin': 'gem', 'my.coins': 'gems' }; + assert.equal(call('renderSample', '{viewer.coins#$my.coin#$my.coins} / {my.coins}', vars), '1 gem / gems'); + assert.equal(call('renderSample', '{viewer.coins#$my.coin#$my.coins}', { ...vars, 'viewer.coins': '7' }), '7 gems'); + assert.equal(call('renderSample', '{n#$nothing#coins}', { n: '1' }), '1 ', 'a name nothing fills counts in nothing'); + const reply = { type: 'chat', params: { text: '{my.coins} {my.cions}' } }; + const handler = { trigger: { type: 'timer' }, steps: [reply] }; + assert.deepEqual(plain(call('unknownVars', handler, reply)), [{ name: 'my.cions', hint: 'my.coins' }], 'a typo in a value name is caught'); +}); + +test('a chat message matches the way the app will match it', () => { + // The cases of engine.rs: a_word_match_never_fires_inside_another_word, wildcards_match_like_file_globs, matching_ignores_case. + for (const said of ['nt', 'NT!', 'wow, nt.', '(nt)', 'that was nt though']) assert.equal(call('textMatches', said, 'nt', 'word'), true, said); + for (const said of ['want', "don't", 'isnt', 'ntf', 'front', 'n t']) assert.equal(call('textMatches', said, 'nt', 'word'), false, said); + assert.equal(call('textMatches', 'NICE TRY lol', 'nice try', 'word'), true); + assert.equal(call('textMatches', 'nice tryhard', 'nice try', 'word'), false); + assert.equal(call('textMatches', 'ñam ñam', 'ñam', 'word'), true); + assert.equal(call('textMatches', 'want', 'nt', 'contains'), true); + assert.equal(call('textMatches', 'POG champ', 'pog', 'starts_with'), true); + assert.equal(call('textMatches', ' GG ', 'gg', 'exact'), true); + assert.equal(call('textMatches', 'gg', '', 'contains'), false); + for (const [said, pattern, fits] of [['gg wp everyone', 'gg*', true], ['clip it', 'clip ??', true], ['abc', '*b*', true], ['abc', 'a?', false], ['a.c', 'a.c', true], ['abc', 'a.c', false]]) { + assert.equal(call('textMatches', said, pattern, 'wildcard'), fits, `${said} / ${pattern}`); + } + const examples = call('matchExamplesHtml', 'nt', 'word').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + assert.equal(examples, '✓ nt ✓ NT! ✓ oh nt yes ✗ nting ✗ rent ✓ nt and more'); +});