J.A.R.V.I.S-rust/crates/jarvis-core/src/commands.rs

319 lines
9.2 KiB
Rust
Raw Normal View History

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::fs;
use std::time::Duration;
2025-12-11 23:43:50 +05:00
use std::process::{Child, Command};
use seqdiff::ratio;
2023-04-27 00:28:36 +05:00
mod structs;
pub use structs::*;
use crate::{config, i18n, APP_DIR};
#[cfg(feature = "lua")]
use crate::lua::{self, SandboxLevel, CommandContext};
2023-04-27 00:28:36 +05:00
pub fn parse_commands() -> Result<Vec<JCommandsList>, String> {
let mut commands: Vec<JCommandsList> = Vec::new();
let commands_path = APP_DIR.join(config::COMMANDS_PATH);
let cmd_dirs = fs::read_dir(&commands_path)
.map_err(|e| format!("Error reading commands directory {:?}: {}", commands_path, e))?;
for entry in cmd_dirs.flatten() {
let cmd_path = entry.path();
let toml_file = cmd_path.join("command.toml");
if !toml_file.exists() {
continue;
2023-04-27 00:28:36 +05:00
}
let content = match fs::read_to_string(&toml_file) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read {}: {}", toml_file.display(), e);
continue;
}
};
2023-04-27 00:28:36 +05:00
let file: JCommandsList = match toml::from_str(&content) {
Ok(f) => f,
Err(e) => {
warn!("Failed to parse {}: {}", toml_file.display(), e);
continue;
}
};
commands.push(JCommandsList {
path: cmd_path,
commands: file.commands,
});
}
if commands.is_empty() {
Err("No commands found".into())
2023-04-27 00:28:36 +05:00
} else {
info!("Loaded {} command pack(s)", commands.len());
Ok(commands)
}
}
pub fn commands_hash(commands: &[JCommandsList]) -> String {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
let lang = i18n::get_language();
hasher.update(lang.as_bytes());
hasher.update(b"|");
// collect all command ids and phrases for current language, sorted
let mut all_data: Vec<(&str, _)> = commands.iter()
.flat_map(|ac| ac.commands.iter().map(|c| (c.id.as_str(), c.get_phrases(&lang))))
.collect();
all_data.sort_by_key(|(id, _)| *id);
for (id, phrases) in all_data {
hasher.update(id.as_bytes());
for phrase in phrases.iter() {
hasher.update(phrase.as_bytes());
}
2023-04-27 00:28:36 +05:00
}
format!("{:x}", hasher.finalize())
2023-04-27 00:28:36 +05:00
}
2023-04-27 00:28:36 +05:00
pub fn fetch_command<'a>(
phrase: &str,
commands: &'a [JCommandsList],
) -> Option<(&'a PathBuf, &'a JCommand)> {
let lang = i18n::get_language();
2026-01-05 04:20:43 +05:00
let phrase = phrase.trim().to_lowercase();
if phrase.is_empty() {
return None;
}
let phrase_chars: Vec<char> = phrase.chars().collect();
let phrase_words: Vec<&str> = phrase.split_whitespace().collect();
let mut result: Option<(&PathBuf, &JCommand)> = None;
let mut best_score = config::CMD_RATIO_THRESHOLD;
2026-01-05 04:20:43 +05:00
for cmd_list in commands {
for cmd in &cmd_list.commands {
let cmd_phrases = cmd.get_phrases(&lang);
for cmd_phrase in cmd_phrases.iter() {
let cmd_phrase_lower = cmd_phrase.trim().to_lowercase();
let cmd_phrase_chars: Vec<char> = cmd_phrase_lower.chars().collect();
2026-01-05 04:20:43 +05:00
// character-level similarity
let char_ratio = ratio(&phrase_chars, &cmd_phrase_chars);
// word-level similarity
let cmd_words: Vec<&str> = cmd_phrase_lower.split_whitespace().collect();
2026-01-05 04:20:43 +05:00
let word_score = word_overlap_score(&phrase_words, &cmd_words);
// combined score
2026-01-05 04:20:43 +05:00
let score = (char_ratio * 0.6) + (word_score * 0.4);
// early exit on perfect match
if score >= 99.0 {
debug!("Perfect match: '{}' -> '{}'", phrase, cmd_phrase_lower);
2026-01-05 04:20:43 +05:00
return Some((&cmd_list.path, cmd));
}
if score > best_score {
best_score = score;
result = Some((&cmd_list.path, cmd));
2023-04-27 00:28:36 +05:00
}
}
}
}
if let Some((_, cmd)) = result {
info!("Fuzzy match: '{}' -> cmd '{}' (score: {:.1}%)", phrase, cmd.id, best_score);
2023-04-27 00:28:36 +05:00
} else {
2026-01-05 04:20:43 +05:00
debug!("No match for '{}' (best: {:.1}%)", phrase, best_score);
2023-04-27 00:28:36 +05:00
}
result
2023-04-27 00:28:36 +05:00
}
2026-01-05 04:20:43 +05:00
fn word_overlap_score(input_words: &[&str], cmd_words: &[&str]) -> f64 {
if input_words.is_empty() || cmd_words.is_empty() {
return 0.0;
}
let mut matched = 0.0;
// pre-compute cmd word chars to avoid repeated allocations
let cmd_word_chars: Vec<Vec<char>> = cmd_words
.iter()
.map(|w| w.chars().collect())
.collect();
2026-01-05 04:20:43 +05:00
for input_word in input_words {
let input_chars: Vec<char> = input_word.chars().collect();
let best_word_match = cmd_word_chars
2026-01-05 04:20:43 +05:00
.iter()
.map(|cw| ratio(&input_chars, cw))
.fold(0.0_f64, f64::max);
2026-01-05 04:20:43 +05:00
if best_word_match > 70.0 {
matched += best_word_match / 100.0;
}
}
let max_words = input_words.len().max(cmd_words.len()) as f64;
(matched / max_words) * 100.0
}
pub fn execute_exe(exe: &str, args: &[String]) -> std::io::Result<Child> {
2023-04-27 00:28:36 +05:00
Command::new(exe).args(args).spawn()
}
pub fn execute_cli(cmd: &str, args: &[String]) -> std::io::Result<Child> {
debug!("Spawning: cmd /C {} {:?}", cmd, args);
2023-05-01 04:24:06 +05:00
if cfg!(target_os = "windows") {
2025-12-11 23:43:50 +05:00
Command::new("cmd").arg("/C").arg(cmd).args(args).spawn()
2023-05-01 04:24:06 +05:00
} else {
2025-12-11 23:43:50 +05:00
Command::new("sh").arg("-c").arg(cmd).args(args).spawn()
2023-05-01 04:24:06 +05:00
}
}
pub fn execute_command(cmd_path: &PathBuf, cmd_config: &JCommand, phrase: Option<&str>, slots: Option<&HashMap<String, SlotValue>>) -> Result<bool, String> {
// execute command by the type
match cmd_config.cmd_type.as_str() {
// BRUH
"voice" => Ok(true),
// LUA command
#[cfg(feature = "lua")]
"lua" => {
execute_lua_command(cmd_path, cmd_config, phrase, slots)
}
// AutoHotkey command
// @TODO: Consider adding ahk source files execution?
2023-04-27 00:28:36 +05:00
"ahk" => {
let exe_path_absolute = Path::new(&cmd_config.exe_path);
let exe_path_local = cmd_path.join(&cmd_config.exe_path);
let exe_path = if exe_path_absolute.exists() {
exe_path_absolute
2023-04-27 00:28:36 +05:00
} else {
exe_path_local.as_path()
};
execute_exe(exe_path.to_str().unwrap(), &cmd_config.exe_args)
.map(|_| true)
.map_err(|e| format!("AHK process spawn error: {}", e))
2023-04-27 00:28:36 +05:00
}
// CLI command type
// @TODO: Consider security restrictions
2023-04-27 00:28:36 +05:00
"cli" => {
execute_cli(&cmd_config.cli_cmd, &cmd_config.cli_args)
.map(|_| true)
.map_err(|e| format!("CLI command error: {}", e))
2023-04-27 00:28:36 +05:00
}
// TERMINATOR command (T1000)
2023-04-27 00:28:36 +05:00
"terminate" => {
std::thread::sleep(Duration::from_secs(2));
std::process::exit(0);
}
// STOP CHANING
"stop_chaining" => Ok(false),
// other
_ => {
error!("Command type unknown: {}", cmd_config.cmd_type);
Err(format!("Command type unknown: {}", cmd_config.cmd_type).into())
}
2023-04-27 00:28:36 +05:00
}
}
// look up a command by its ID
pub fn get_command_by_id<'a>(
commands: &'a [JCommandsList],
id: &str,
) -> Option<(&'a PathBuf, &'a JCommand)> {
for cmd_list in commands {
for cmd in &cmd_list.commands {
if cmd.id == id {
return Some((&cmd_list.path, cmd));
}
}
}
None
}
pub fn list_paths(commands: &[JCommandsList]) -> Vec<&Path> {
commands.iter().map(|x| x.path.as_path()).collect()
2025-12-11 23:43:50 +05:00
}
#[cfg(feature = "lua")]
fn execute_lua_command(
cmd_path: &PathBuf,
cmd_config: &JCommand,
phrase: Option<&str>,
slots: Option<&HashMap<String, SlotValue>>
) -> Result<bool, String> {
// get script path
let script_name = if cmd_config.script.is_empty() {
"script.lua"
} else {
&cmd_config.script
};
let script_path = cmd_path.join(script_name);
if !script_path.exists() {
return Err(format!("Lua script not found: {}", script_path.display()));
}
// parse sandbox level
let sandbox = SandboxLevel::from_str(&cmd_config.sandbox);
// create context
let context = CommandContext {
phrase: phrase.unwrap_or("").to_string(),
command_id: cmd_config.id.clone(),
command_path: cmd_path.clone(),
language: i18n::get_language(),
slots: slots.map(|s| s.clone()),
};
// get timeout
let timeout = Duration::from_millis(cmd_config.timeout);
info!("Executing Lua command: {} (sandbox: {:?}, timeout: {:?})",
cmd_config.id, sandbox, timeout);
// execute
match lua::execute(&script_path, context, sandbox, timeout) {
Ok(result) => {
info!("Lua command {} completed (chain: {})", cmd_config.id, result.chain);
Ok(result.chain)
}
Err(e) => {
error!("Lua command {} failed: {}", cmd_config.id, e);
Err(e.to_string())
}
}
}