feat(macros): IMBA-7 voice-recorded command sequences (VoiceAttack-style)
Simpler than AHK keyboard hooks: record what the user SAID, replay each phrase
through the normal dispatch path. Works for any command jarvis already knows.
Flow:
"Запиши макрос работа" → start_recording("работа")
"Открой браузер" → buffered into the recording
"Запусти спотифай" → buffered
"Режим работа" → buffered
"Сохрани макрос" → save_recording() → persist
...later...
"Запусти макрос работа" → replay("работа") → fires each phrase in order
Core (crates/jarvis-core/src/macros.rs)
- Macro {name, steps, created_at, last_run} persisted at
<APP_CONFIG_DIR>/macros.json with atomic write-through.
- Recording state in-memory: RwLock<Option<RecordingState>>.
- start_recording / record_step / save_recording / cancel_recording.
- list / get / delete / mark_run.
- replay(name) spawns a thread that fires each step through a callback,
with 800ms inter-step delay. Caller returns immediately.
- is_macro_control filter prevents recording the meta-commands themselves
(no infinite recursion when "запиши макрос" runs during a recording).
- 3 unit tests (55 total).
Wiring (crates/jarvis-app/src/main.rs)
- macros::init() before listener starts.
- macros::set_replay_callback(move |phrase| text_cmd_tx.send(phrase)) — each
replay step gets queued as a synthetic text command, processed by the same
dispatcher the GUI uses. No special-case code path.
Recording hook (crates/jarvis-app/src/app.rs::execute_command)
- On successful command execution → call macros::record_step(text).
- Failed commands are NOT recorded (would fail on replay too).
- is_macro_control filter inside record_step skips meta-commands.
Lua API (crates/jarvis-core/src/lua/api/macros.rs)
- jarvis.macros.{start, save, cancel, replay, list, delete,
is_recording, recording_name}
Voice pack (resources/commands/macros/, 6 commands)
- "запиши макрос NAME" → macros.start_recording
- "сохрани макрос" → macros.save
- "отмени макрос" → macros.cancel
- "запусти макрос NAME" → macros.replay
- "какие у меня макросы" → macros.list
- "удали макрос NAME" → macros.delete
Build: cargo build --release -p jarvis-app green. 55/55 tests pass.
This commit is contained in:
parent
d63399f4c9
commit
dbb4a6b888
14 changed files with 594 additions and 2 deletions
|
|
@ -464,6 +464,11 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
|
||||||
info!("Command executed successfully");
|
info!("Command executed successfully");
|
||||||
// voices::play_ok();
|
// voices::play_ok();
|
||||||
voices::play_random_from(cmd_config.get_sounds(&i18n::get_language()).as_slice());
|
voices::play_random_from(cmd_config.get_sounds(&i18n::get_language()).as_slice());
|
||||||
|
|
||||||
|
// IMBA-7: if macro recording is active, capture this phrase.
|
||||||
|
// Skipped for macro-control commands themselves (filter inside record_step).
|
||||||
|
jarvis_core::macros::record_step(text);
|
||||||
|
|
||||||
ipc::send(IpcEvent::CommandExecuted {
|
ipc::send(IpcEvent::CommandExecuted {
|
||||||
id: cmd_config.id.clone(),
|
id: cmd_config.id.clone(),
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,11 @@ fn main() -> Result<(), String> {
|
||||||
jarvis_core::scheduler::start_background();
|
jarvis_core::scheduler::start_background();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eprintln!("[jarvis-app] step: macros::init");
|
||||||
|
if let Err(e) = jarvis_core::macros::init() {
|
||||||
|
warn!("Macros init failed: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
eprintln!("[jarvis-app] step: recorder::init");
|
eprintln!("[jarvis-app] step: recorder::init");
|
||||||
if recorder::init().is_err() {
|
if recorder::init().is_err() {
|
||||||
notify_mic_problem();
|
notify_mic_problem();
|
||||||
|
|
@ -153,9 +158,18 @@ fn main() -> Result<(), String> {
|
||||||
info!("Initializing IPC...");
|
info!("Initializing IPC...");
|
||||||
ipc::init();
|
ipc::init();
|
||||||
|
|
||||||
// channel for text commands (manually written in the GUI)
|
// channel for text commands (manually written in the GUI, OR fed by macro replay)
|
||||||
let (text_cmd_tx, text_cmd_rx) = mpsc::channel::<String>();
|
let (text_cmd_tx, text_cmd_rx) = mpsc::channel::<String>();
|
||||||
|
|
||||||
|
// Wire macro replay through the same text-command channel: each step of a
|
||||||
|
// replayed macro becomes a synthetic text command, processed in order.
|
||||||
|
let macro_tx = text_cmd_tx.clone();
|
||||||
|
jarvis_core::macros::set_replay_callback(Box::new(move |phrase: &str| {
|
||||||
|
if let Err(e) = macro_tx.send(phrase.to_string()) {
|
||||||
|
warn!("Macro replay: failed to enqueue '{}': {}", phrase, e);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
ipc::set_action_handler(move |action| {
|
ipc::set_action_handler(move |action| {
|
||||||
match action {
|
match action {
|
||||||
IpcAction::Stop => {
|
IpcAction::Stop => {
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ pub mod profiles;
|
||||||
|
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
|
|
||||||
|
pub mod macros;
|
||||||
|
|
||||||
#[cfg(feature = "llm")]
|
#[cfg(feature = "llm")]
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,4 @@ pub mod vision;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod cmd;
|
pub mod cmd;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod macros;
|
||||||
70
crates/jarvis-core/src/lua/api/macros.rs
Normal file
70
crates/jarvis-core/src/lua/api/macros.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
//! Lua bindings for the macro recorder.
|
||||||
|
//!
|
||||||
|
//! Usage from Lua:
|
||||||
|
//! jarvis.macros.start("работа") -- begin recording
|
||||||
|
//! jarvis.macros.save() -- persist + stop
|
||||||
|
//! jarvis.macros.cancel() -- abort without saving
|
||||||
|
//! jarvis.macros.replay("работа") -- play back; returns step count or nil
|
||||||
|
//! jarvis.macros.list() -- array of {name, steps_count, last_run}
|
||||||
|
//! jarvis.macros.delete(name) -- removes
|
||||||
|
//! jarvis.macros.is_recording() -- bool
|
||||||
|
//! jarvis.macros.recording_name() -- string or nil
|
||||||
|
|
||||||
|
use mlua::{Lua, Table, Value};
|
||||||
|
|
||||||
|
use crate::macros;
|
||||||
|
|
||||||
|
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
|
||||||
|
let m = lua.create_table()?;
|
||||||
|
|
||||||
|
m.set("start", lua.create_function(|_, name: String| {
|
||||||
|
macros::start_recording(&name).map_err(mlua::Error::external)
|
||||||
|
})?)?;
|
||||||
|
|
||||||
|
m.set("save", lua.create_function(|_, ()| {
|
||||||
|
macros::save_recording().map_err(mlua::Error::external)
|
||||||
|
})?)?;
|
||||||
|
|
||||||
|
m.set("cancel", lua.create_function(|_, ()| {
|
||||||
|
Ok(macros::cancel_recording())
|
||||||
|
})?)?;
|
||||||
|
|
||||||
|
m.set("is_recording", lua.create_function(|_, ()| {
|
||||||
|
Ok(macros::is_recording())
|
||||||
|
})?)?;
|
||||||
|
|
||||||
|
m.set("recording_name", lua.create_function(|lua, ()| {
|
||||||
|
match macros::recording_name() {
|
||||||
|
Some(n) => Ok(Value::String(lua.create_string(n)?)),
|
||||||
|
None => Ok(Value::Nil),
|
||||||
|
}
|
||||||
|
})?)?;
|
||||||
|
|
||||||
|
m.set("replay", lua.create_function(|_, name: String| {
|
||||||
|
macros::replay(&name).map_err(mlua::Error::external)
|
||||||
|
})?)?;
|
||||||
|
|
||||||
|
m.set("delete", lua.create_function(|_, name: String| {
|
||||||
|
Ok(macros::delete(&name))
|
||||||
|
})?)?;
|
||||||
|
|
||||||
|
m.set("list", lua.create_function(|lua, ()| {
|
||||||
|
let arr = lua.create_table()?;
|
||||||
|
for (i, mac) in macros::list().iter().enumerate() {
|
||||||
|
let row = lua.create_table()?;
|
||||||
|
row.set("name", mac.name.clone())?;
|
||||||
|
row.set("steps_count", mac.steps.len())?;
|
||||||
|
row.set("created_at", mac.created_at)?;
|
||||||
|
let last: Value = match mac.last_run {
|
||||||
|
Some(ts) => Value::Integer(ts),
|
||||||
|
None => Value::Nil,
|
||||||
|
};
|
||||||
|
row.set("last_run", last)?;
|
||||||
|
arr.set(i + 1, row)?;
|
||||||
|
}
|
||||||
|
Ok(arr)
|
||||||
|
})?)?;
|
||||||
|
|
||||||
|
jarvis.set("macros", m)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,7 @@ impl LuaEngine {
|
||||||
api::scheduler::register(&self.lua, &jarvis)?;
|
api::scheduler::register(&self.lua, &jarvis)?;
|
||||||
api::cmd::register(&self.lua, &jarvis)?;
|
api::cmd::register(&self.lua, &jarvis)?;
|
||||||
api::health::register(&self.lua, &jarvis)?;
|
api::health::register(&self.lua, &jarvis)?;
|
||||||
|
api::macros::register(&self.lua, &jarvis)?;
|
||||||
|
|
||||||
// sandbox-controlled APIs
|
// sandbox-controlled APIs
|
||||||
if self.sandbox.allows_http() {
|
if self.sandbox.allows_http() {
|
||||||
|
|
|
||||||
303
crates/jarvis-core/src/macros.rs
Normal file
303
crates/jarvis-core/src/macros.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
//! IMBA-7: Voice-command macros — record a sequence of commands once,
|
||||||
|
//! replay them later with one phrase.
|
||||||
|
//!
|
||||||
|
//! Simpler than AHK-style keyboard hooks: we record what the user SAID, then
|
||||||
|
//! replay each phrase through the normal dispatch path. Works for anything
|
||||||
|
//! that's already a known command (volume, media keys, profile switches, ...).
|
||||||
|
//!
|
||||||
|
//! Lifecycle:
|
||||||
|
//!
|
||||||
|
//! "Запиши макрос работа" → start_recording("работа")
|
||||||
|
//! "Открой браузер" → recorder buffers "открой браузер"
|
||||||
|
//! "Запусти спотифай" → recorder buffers
|
||||||
|
//! "Режим работа" → recorder buffers
|
||||||
|
//! "Сохрани макрос" → save() → persists to disk
|
||||||
|
//!
|
||||||
|
//! "Запусти макрос работа" → replay("работа") → fires each phrase in order,
|
||||||
|
//! with a small inter-step delay
|
||||||
|
//!
|
||||||
|
//! Storage: JSON at `<APP_CONFIG_DIR>/macros.json`.
|
||||||
|
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::APP_CONFIG_DIR;
|
||||||
|
|
||||||
|
const FILE_NAME: &str = "macros.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Macro {
|
||||||
|
pub name: String,
|
||||||
|
pub steps: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_run: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||||
|
struct Store {
|
||||||
|
#[serde(default)]
|
||||||
|
macros: BTreeMap<String, Macro>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct RecordingState {
|
||||||
|
name: String,
|
||||||
|
steps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
path: PathBuf,
|
||||||
|
store: RwLock<Store>,
|
||||||
|
recording: RwLock<Option<RecordingState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static STATE: OnceCell<State> = OnceCell::new();
|
||||||
|
|
||||||
|
pub fn init() -> Result<(), String> {
|
||||||
|
if STATE.get().is_some() { return Ok(()); }
|
||||||
|
let dir = APP_CONFIG_DIR.get()
|
||||||
|
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
|
||||||
|
let path = dir.join(FILE_NAME);
|
||||||
|
let store = load_or_empty(&path);
|
||||||
|
info!("Macros loaded: {} stored at {}", store.macros.len(), path.display());
|
||||||
|
STATE.set(State { path, store: RwLock::new(store), recording: RwLock::new(None) })
|
||||||
|
.map_err(|_| "macros already initialised".to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_or_empty(path: &PathBuf) -> Store {
|
||||||
|
if !path.is_file() { return Store::default(); }
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
|
||||||
|
warn!("Corrupt macros file {}: {}", path.display(), e);
|
||||||
|
Store::default()
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Cannot read {}: {}", path.display(), e);
|
||||||
|
Store::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist(state: &State) {
|
||||||
|
let store = state.store.read();
|
||||||
|
let tmp = state.path.with_extension("json.tmp");
|
||||||
|
let json = match serde_json::to_string_pretty(&*store) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => { warn!("Macros serialize failed: {}", e); return; }
|
||||||
|
};
|
||||||
|
if let Err(e) = std::fs::write(&tmp, json) {
|
||||||
|
warn!("Macros write failed: {}", e); return;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::rename(&tmp, &state.path) {
|
||||||
|
warn!("Macros rename failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_secs() -> i64 {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_name(n: &str) -> String {
|
||||||
|
n.trim().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin recording a new macro. Replaces any in-progress recording.
|
||||||
|
pub fn start_recording(name: &str) -> Result<(), String> {
|
||||||
|
let state = STATE.get().ok_or_else(|| "macros not init".to_string())?;
|
||||||
|
let nname = normalize_name(name);
|
||||||
|
if nname.is_empty() { return Err("empty macro name".into()); }
|
||||||
|
*state.recording.write() = Some(RecordingState {
|
||||||
|
name: nname.clone(),
|
||||||
|
steps: Vec::new(),
|
||||||
|
});
|
||||||
|
info!("Macros: recording started for '{}'", nname);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffer a phrase into the active recording (if any). Called by the command
|
||||||
|
/// dispatch hook after a command runs successfully.
|
||||||
|
pub fn record_step(phrase: &str) {
|
||||||
|
let Some(state) = STATE.get() else { return; };
|
||||||
|
let mut rec = state.recording.write();
|
||||||
|
if let Some(r) = rec.as_mut() {
|
||||||
|
let p = phrase.trim();
|
||||||
|
if p.is_empty() { return; }
|
||||||
|
// Don't record macro-control commands themselves (would be infinite recursion).
|
||||||
|
if is_macro_control(p) { return; }
|
||||||
|
r.steps.push(p.to_string());
|
||||||
|
debug!("Macros: recorded step '{}' for '{}'", p, r.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_macro_control(phrase: &str) -> bool {
|
||||||
|
let p = phrase.to_lowercase();
|
||||||
|
p.contains("запиши макрос") || p.contains("сохрани макрос")
|
||||||
|
|| p.contains("отмени макрос") || p.contains("прекрати макрос")
|
||||||
|
|| p.contains("отмени запись")
|
||||||
|
|| p.starts_with("запусти макрос") || p.starts_with("воспроизведи макрос")
|
||||||
|
|| p.starts_with("record macro") || p.starts_with("save macro")
|
||||||
|
|| p.starts_with("play macro")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if a recording is currently active.
|
||||||
|
pub fn is_recording() -> bool {
|
||||||
|
STATE.get().and_then(|s| s.recording.read().as_ref().map(|_| ())).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name of the macro currently being recorded, if any.
|
||||||
|
pub fn recording_name() -> Option<String> {
|
||||||
|
STATE.get().and_then(|s| s.recording.read().as_ref().map(|r| r.name.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop recording and save the macro. Returns the number of steps captured.
|
||||||
|
pub fn save_recording() -> Result<usize, String> {
|
||||||
|
let state = STATE.get().ok_or_else(|| "macros not init".to_string())?;
|
||||||
|
let rec = state.recording.write().take()
|
||||||
|
.ok_or_else(|| "не было активной записи".to_string())?;
|
||||||
|
|
||||||
|
if rec.steps.is_empty() {
|
||||||
|
return Err("макрос пустой".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = rec.steps.len();
|
||||||
|
let m = Macro {
|
||||||
|
name: rec.name.clone(),
|
||||||
|
steps: rec.steps,
|
||||||
|
created_at: now_secs(),
|
||||||
|
last_run: None,
|
||||||
|
};
|
||||||
|
state.store.write().macros.insert(rec.name.clone(), m);
|
||||||
|
persist(state);
|
||||||
|
info!("Macros: saved '{}' ({} steps)", rec.name, count);
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the active recording without saving.
|
||||||
|
pub fn cancel_recording() -> bool {
|
||||||
|
let Some(state) = STATE.get() else { return false; };
|
||||||
|
state.recording.write().take().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list() -> Vec<Macro> {
|
||||||
|
let Some(state) = STATE.get() else { return Vec::new(); };
|
||||||
|
state.store.read().macros.values().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(name: &str) -> Option<Macro> {
|
||||||
|
let state = STATE.get()?;
|
||||||
|
state.store.read().macros.get(&normalize_name(name)).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(name: &str) -> bool {
|
||||||
|
let Some(state) = STATE.get() else { return false; };
|
||||||
|
let removed = state.store.write().macros.remove(&normalize_name(name)).is_some();
|
||||||
|
if removed { persist(state); }
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a macro as run (updates last_run timestamp).
|
||||||
|
pub fn mark_run(name: &str) {
|
||||||
|
let Some(state) = STATE.get() else { return; };
|
||||||
|
let nname = normalize_name(name);
|
||||||
|
{
|
||||||
|
let mut store = state.store.write();
|
||||||
|
if let Some(m) = store.macros.get_mut(&nname) {
|
||||||
|
m.last_run = Some(now_secs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persist(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay callback — jarvis-app registers a function that fires a text command.
|
||||||
|
/// Macros module owns the timing of replays.
|
||||||
|
pub type ReplayCallback = Box<dyn Fn(&str) + Send + Sync>;
|
||||||
|
|
||||||
|
static REPLAY_CB: once_cell::sync::OnceCell<ReplayCallback> = once_cell::sync::OnceCell::new();
|
||||||
|
|
||||||
|
pub fn set_replay_callback(cb: ReplayCallback) {
|
||||||
|
let _ = REPLAY_CB.set(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_DELAY_MS: u64 = 800;
|
||||||
|
|
||||||
|
/// Replay a stored macro. Each step is sent to the registered callback (which
|
||||||
|
/// the app wires to its text-command channel). Inter-step delay gives the
|
||||||
|
/// dispatcher time to process before the next step arrives. Returns step count.
|
||||||
|
pub fn replay(name: &str) -> Result<usize, String> {
|
||||||
|
let m = get(name).ok_or_else(|| format!("Макрос '{}' не найден.", name))?;
|
||||||
|
let cb = REPLAY_CB.get().ok_or_else(|| {
|
||||||
|
"Replay callback not registered (jarvis-app didn't wire it).".to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let steps_count = m.steps.len();
|
||||||
|
info!("Macros: replay '{}' ({} steps)", m.name, steps_count);
|
||||||
|
let _ = cb; // sanity-checked existence; thread re-reads REPLAY_CB inside.
|
||||||
|
|
||||||
|
// Spawn so the caller (likely a Lua script) returns immediately.
|
||||||
|
let name_owned = m.name.clone();
|
||||||
|
let steps = m.steps.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name(format!("macro-replay-{}", name_owned))
|
||||||
|
.spawn(move || {
|
||||||
|
let cb = match REPLAY_CB.get() {
|
||||||
|
Some(c) => c,
|
||||||
|
None => { warn!("Macros: callback vanished mid-replay"); return; }
|
||||||
|
};
|
||||||
|
for (i, step) in steps.iter().enumerate() {
|
||||||
|
info!("Macros: replay step {}/{}: {}", i + 1, steps.len(), step);
|
||||||
|
cb(step);
|
||||||
|
if i + 1 < steps.len() {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(STEP_DELAY_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mark_run(&name_owned);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Ok(steps_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_macro_control_catches_record_phrase() {
|
||||||
|
assert!(is_macro_control("Запиши макрос работа"));
|
||||||
|
assert!(is_macro_control("сохрани макрос"));
|
||||||
|
assert!(is_macro_control("запусти макрос работа"));
|
||||||
|
assert!(!is_macro_control("открой браузер"));
|
||||||
|
assert!(!is_macro_control("установи громкость 50"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_name_lowercases_trims() {
|
||||||
|
assert_eq!(normalize_name(" Работа "), "работа");
|
||||||
|
assert_eq!(normalize_name("GameMode"), "gamemode");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn macro_serde_round_trip() {
|
||||||
|
let m = Macro {
|
||||||
|
name: "test".into(),
|
||||||
|
steps: vec!["a".into(), "b".into()],
|
||||||
|
created_at: 100,
|
||||||
|
last_run: Some(200),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&m).unwrap();
|
||||||
|
let back: Macro = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.name, "test");
|
||||||
|
assert_eq!(back.steps.len(), 2);
|
||||||
|
assert_eq!(back.last_run, Some(200));
|
||||||
|
}
|
||||||
|
}
|
||||||
4
resources/commands/macros/cancel.lua
Normal file
4
resources/commands/macros/cancel.lua
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
if jarvis.macros.cancel() then
|
||||||
|
return jarvis.cmd.ok("Запись макроса отменена.")
|
||||||
|
end
|
||||||
|
return jarvis.cmd.not_found("Записи не было.")
|
||||||
107
resources/commands/macros/command.toml
Normal file
107
resources/commands/macros/command.toml
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# IMBA-7: Voice macros — record and replay sequences of voice commands.
|
||||||
|
|
||||||
|
[[commands]]
|
||||||
|
id = "macros.start_recording"
|
||||||
|
type = "lua"
|
||||||
|
script = "start.lua"
|
||||||
|
sandbox = "standard"
|
||||||
|
timeout = 3000
|
||||||
|
|
||||||
|
[commands.phrases]
|
||||||
|
ru = [
|
||||||
|
"запиши макрос",
|
||||||
|
"начни запись макроса",
|
||||||
|
"записывай макрос",
|
||||||
|
"новый макрос",
|
||||||
|
]
|
||||||
|
en = ["record macro", "start macro recording"]
|
||||||
|
ua = ["запиши макрос"]
|
||||||
|
|
||||||
|
|
||||||
|
[[commands]]
|
||||||
|
id = "macros.save"
|
||||||
|
type = "lua"
|
||||||
|
script = "save.lua"
|
||||||
|
sandbox = "standard"
|
||||||
|
timeout = 3000
|
||||||
|
|
||||||
|
[commands.phrases]
|
||||||
|
ru = [
|
||||||
|
"сохрани макрос",
|
||||||
|
"стоп макрос",
|
||||||
|
"стоп запись макроса",
|
||||||
|
"закончи запись",
|
||||||
|
"макрос готов",
|
||||||
|
]
|
||||||
|
en = ["save macro", "stop macro", "finish macro"]
|
||||||
|
ua = ["збережи макрос", "стоп макрос"]
|
||||||
|
|
||||||
|
|
||||||
|
[[commands]]
|
||||||
|
id = "macros.cancel"
|
||||||
|
type = "lua"
|
||||||
|
script = "cancel.lua"
|
||||||
|
sandbox = "minimal"
|
||||||
|
timeout = 2000
|
||||||
|
|
||||||
|
[commands.phrases]
|
||||||
|
ru = [
|
||||||
|
"отмени макрос",
|
||||||
|
"отмени запись макроса",
|
||||||
|
"отмени запись",
|
||||||
|
"забудь макрос",
|
||||||
|
]
|
||||||
|
en = ["cancel macro", "discard macro"]
|
||||||
|
ua = ["скасуй макрос"]
|
||||||
|
|
||||||
|
|
||||||
|
[[commands]]
|
||||||
|
id = "macros.replay"
|
||||||
|
type = "lua"
|
||||||
|
script = "replay.lua"
|
||||||
|
sandbox = "standard"
|
||||||
|
timeout = 60000
|
||||||
|
|
||||||
|
[commands.phrases]
|
||||||
|
ru = [
|
||||||
|
"запусти макрос",
|
||||||
|
"выполни макрос",
|
||||||
|
"воспроизведи макрос",
|
||||||
|
"сыграй макрос",
|
||||||
|
"проиграй макрос",
|
||||||
|
]
|
||||||
|
en = ["play macro", "replay macro", "run macro"]
|
||||||
|
ua = ["запусти макрос"]
|
||||||
|
|
||||||
|
|
||||||
|
[[commands]]
|
||||||
|
id = "macros.list"
|
||||||
|
type = "lua"
|
||||||
|
script = "list.lua"
|
||||||
|
sandbox = "minimal"
|
||||||
|
timeout = 3000
|
||||||
|
|
||||||
|
[commands.phrases]
|
||||||
|
ru = [
|
||||||
|
"какие у меня макросы",
|
||||||
|
"список макросов",
|
||||||
|
"покажи макросы",
|
||||||
|
]
|
||||||
|
en = ["list macros", "show macros"]
|
||||||
|
ua = ["список макросів"]
|
||||||
|
|
||||||
|
|
||||||
|
[[commands]]
|
||||||
|
id = "macros.delete"
|
||||||
|
type = "lua"
|
||||||
|
script = "delete.lua"
|
||||||
|
sandbox = "minimal"
|
||||||
|
timeout = 3000
|
||||||
|
|
||||||
|
[commands.phrases]
|
||||||
|
ru = [
|
||||||
|
"удали макрос",
|
||||||
|
"забудь о макросе",
|
||||||
|
]
|
||||||
|
en = ["delete macro", "remove macro"]
|
||||||
|
ua = ["видали макрос"]
|
||||||
18
resources/commands/macros/delete.lua
Normal file
18
resources/commands/macros/delete.lua
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
local phrase = (jarvis.context.phrase or ""):lower()
|
||||||
|
local name = jarvis.text.strip_trigger(phrase, {
|
||||||
|
"удали макрос",
|
||||||
|
"забудь о макросе",
|
||||||
|
"delete macro",
|
||||||
|
"remove macro",
|
||||||
|
"видали макрос",
|
||||||
|
})
|
||||||
|
name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||||
|
|
||||||
|
if name == "" then
|
||||||
|
return jarvis.cmd.error("Какой макрос удалить?")
|
||||||
|
end
|
||||||
|
|
||||||
|
if jarvis.macros.delete(name) then
|
||||||
|
return jarvis.cmd.ok("Удалил макрос " .. name .. ".")
|
||||||
|
end
|
||||||
|
return jarvis.cmd.not_found("Макрос " .. name .. " не найден.")
|
||||||
12
resources/commands/macros/list.lua
Normal file
12
resources/commands/macros/list.lua
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
local list = jarvis.macros.list()
|
||||||
|
if #list == 0 then
|
||||||
|
return jarvis.cmd.ok("Макросов нет.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local sample = math.min(#list, 5)
|
||||||
|
local line = string.format("Макросов: %d. ", #list)
|
||||||
|
for i = 1, sample do
|
||||||
|
line = line .. list[i].name .. " (" .. list[i].steps_count .. " шагов)"
|
||||||
|
if i < sample then line = line .. ", " end
|
||||||
|
end
|
||||||
|
return jarvis.cmd.ok(line .. ".")
|
||||||
24
resources/commands/macros/replay.lua
Normal file
24
resources/commands/macros/replay.lua
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- "Запусти макрос работа"
|
||||||
|
local phrase = (jarvis.context.phrase or ""):lower()
|
||||||
|
local name = jarvis.text.strip_trigger(phrase, {
|
||||||
|
"выполни макрос",
|
||||||
|
"воспроизведи макрос",
|
||||||
|
"запусти макрос",
|
||||||
|
"сыграй макрос",
|
||||||
|
"проиграй макрос",
|
||||||
|
"play macro",
|
||||||
|
"replay macro",
|
||||||
|
"run macro",
|
||||||
|
})
|
||||||
|
name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||||
|
|
||||||
|
if name == "" then
|
||||||
|
return jarvis.cmd.error("Какой макрос запустить?")
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, result = pcall(function() return jarvis.macros.replay(name) end)
|
||||||
|
if not ok then
|
||||||
|
return jarvis.cmd.not_found(tostring(result))
|
||||||
|
end
|
||||||
|
|
||||||
|
return jarvis.cmd.ok(string.format("Запускаю %s, шагов: %d.", name, result))
|
||||||
8
resources/commands/macros/save.lua
Normal file
8
resources/commands/macros/save.lua
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
local ok, result = pcall(function() return jarvis.macros.save() end)
|
||||||
|
if not ok then
|
||||||
|
-- result here is the error msg from save_recording
|
||||||
|
return jarvis.cmd.not_found(tostring(result))
|
||||||
|
end
|
||||||
|
|
||||||
|
local name = jarvis.macros.recording_name() or "макрос"
|
||||||
|
return jarvis.cmd.ok(string.format("Сохранил %d шагов.", result))
|
||||||
23
resources/commands/macros/start.lua
Normal file
23
resources/commands/macros/start.lua
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- "Запиши макрос работа"
|
||||||
|
local phrase = (jarvis.context.phrase or ""):lower()
|
||||||
|
local name = jarvis.text.strip_trigger(phrase, {
|
||||||
|
"начни запись макроса",
|
||||||
|
"записывай макрос",
|
||||||
|
"запиши макрос",
|
||||||
|
"новый макрос",
|
||||||
|
"record macro",
|
||||||
|
"start macro recording",
|
||||||
|
})
|
||||||
|
name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||||
|
|
||||||
|
if name == "" then
|
||||||
|
return jarvis.cmd.error("Как назвать макрос?")
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, err = pcall(function() jarvis.macros.start(name) end)
|
||||||
|
if not ok then
|
||||||
|
jarvis.log("warn", "macros.start: " .. tostring(err))
|
||||||
|
return jarvis.cmd.error("Не получилось начать запись.")
|
||||||
|
end
|
||||||
|
|
||||||
|
return jarvis.cmd.ok(string.format("Записываю макрос %s. Говорите команды. Когда закончите — скажите сохрани макрос.", name))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue