basic Lua 5.4 implementation with few example commands

This commit is contained in:
Priler
2026-01-17 05:46:38 +05:00
parent 11c2500d9c
commit 7440baa1b2
35 changed files with 2554 additions and 460 deletions

1182
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,4 +40,8 @@ futures-util = "0.3"
fluent = "0.17.0"
fluent-bundle = "0.16.0"
unic-langid = "0.9"
chrono = "0.4"
chrono = "0.4"
mlua = { version = "0.11.5", features = ["lua54", "vendored", "async", "serde"] }
reqwest = { version = "0.13.1", features = ["blocking", "json"] }
tempfile = "^3.24"
winrt-notification = "0.5"

View File

@@ -306,7 +306,7 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
if let Some((cmd_path, cmd_config)) = cmd_result {
info!("Command found: {:?}", cmd_path);
match commands::execute_command(&cmd_path, &cmd_config) {
match commands::execute_command(&cmd_path, &cmd_config, Some(&text)) {
Ok(chain) => {
info!("Command executed successfully");
// voices::play_ok();

View File

@@ -38,7 +38,16 @@ intent-classifier = { version = "0.1.0", optional = true }
tokio = { version = "1", features = ["sync"], optional = true }
mlua = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true }
tempfile.workspace = true
[target.'cfg(windows)'.dependencies]
winrt-notification = { workspace = true, optional = true }
[features]
default = ["jarvis_app"]
jarvis_app = ["vosk", "intent-classifier", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util"]
intent = ["intent-classifier", "tokio"]
jarvis_app = ["vosk", "intent-classifier", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util", "lua"]
intent = ["intent-classifier", "tokio"]
lua = ["mlua", "reqwest", "winrt-notification"]
lua_only = ["lua", "tokio"]

View File

@@ -10,6 +10,8 @@ pub use structs::*;
use crate::{config, i18n, APP_DIR};
#[cfg(feature = "lua")]
use crate::lua::{self, SandboxLevel, CommandContext};
pub fn parse_commands() -> Result<Vec<JCommandsList>, String> {
let mut commands: Vec<JCommandsList> = Vec::new();
@@ -189,10 +191,20 @@ pub fn execute_cli(cmd: &str, args: &[String]) -> std::io::Result<Child> {
}
}
pub fn execute_command(cmd_path: &Path, cmd_config: &JCommand) -> Result<bool, String> {
match cmd_config.action.as_str() {
pub fn execute_command(cmd_path: &PathBuf, cmd_config: &JCommand, phrase: Option<&str>) -> Result<bool, String> {
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)
}
// AutoHotkey command
// @TODO: Consider adding ahk source files execution?
"ahk" => {
let exe_path_absolute = Path::new(&cmd_config.exe_path);
let exe_path_local = cmd_path.join(&cmd_config.exe_path);
@@ -208,23 +220,80 @@ pub fn execute_command(cmd_path: &Path, cmd_config: &JCommand) -> Result<bool, S
.map_err(|e| format!("AHK process spawn error: {}", e))
}
// CLI command type
// @TODO: Consider security restrictions
"cli" => {
execute_cli(&cmd_config.cli_cmd, &cmd_config.cli_args)
.map(|_| true)
.map_err(|e| format!("CLI command error: {}", e))
}
// TERMINATOR command (T1000)
"terminate" => {
std::thread::sleep(Duration::from_secs(2));
std::process::exit(0);
}
// STOP CHANING
"stop_chaining" => Ok(false),
_ => Err(format!("Unknown command type: {}", cmd_config.action)),
// other
_ => {
error!("Command type unknown: {}", cmd_config.cmd_type);
Err(format!("Command type unknown: {}", cmd_config.cmd_type).into())
}
}
}
pub fn list_paths(commands: &[JCommandsList]) -> Vec<&Path> {
commands.iter().map(|x| x.path.as_path()).collect()
}
#[cfg(feature = "lua")]
fn execute_lua_command(
cmd_path: &PathBuf,
cmd_config: &JCommand,
phrase: Option<&str>,
) -> 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(),
};
// 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())
}
}
}

View File

@@ -15,26 +15,42 @@ pub struct JCommandsList {
#[derive(Serialize, Deserialize, Debug)]
pub struct JCommand {
pub id: String,
pub action: String,
// Available command types are: "lua", "ahk", "cli", "voice", "terminate", "stop_chaining"
#[serde(rename = "type")]
pub cmd_type: String,
#[serde(default)]
pub description: String,
// for "ahk" type
#[serde(default)]
pub exe_path: String,
#[serde(default)]
pub exe_args: Vec<String>,
// for "cli" type
#[serde(default)]
pub cli_cmd: String,
#[serde(default)]
pub cli_args: Vec<String>,
// #[serde(default)]
// pub sounds: Vec<String>,
// for "lua" type
#[serde(default)]
pub script: String,
// Lua sandbox level: "minimal", "standard", "full"
// basically this is an access level
#[serde(default)]
pub sandbox: String,
// Script timeout in milliseconds (default 10000 = 10s)
#[serde(default)]
pub timeout: u64,
// Multi-language sounds
#[serde(default)]
pub sounds: HashMap<String, Vec<String>>,
@@ -57,12 +73,20 @@ impl Clone for JCommand {
fn clone(&self) -> Self {
Self {
id: self.id.clone(),
action: self.action.clone(),
cmd_type: self.cmd_type.clone(),
description: self.description.clone(),
exe_path: self.exe_path.clone(),
exe_args: self.exe_args.clone(),
cli_cmd: self.cli_cmd.clone(),
cli_args: self.cli_args.clone(),
script: self.script.clone(),
sandbox: self.sandbox.clone(),
timeout: self.timeout.clone(),
sounds: self.sounds.clone(),
phrases: self.phrases.clone(),

View File

@@ -175,6 +175,9 @@ pub const GAIN_MAX: f32 = 3.0; // maximum gain multiplier
// nnnoiseless frame size (fixed by library)
pub const NNNOISELESS_FRAME_SIZE: usize = 480;
// LUA
pub const DEFAULT_LUA_SANDBOX: &str = "standard";
pub const DEFAULT_LUA_TIMEOUT: u64 = 10000; // ms
// ETC
pub const CMD_RATIO_THRESHOLD: f64 = 65f64;

View File

@@ -38,6 +38,9 @@ pub mod voices;
pub mod audio_buffer;
#[cfg(feature = "lua")]
pub mod lua;
// shared statics
// pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| std::env::current_dir().unwrap());
pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| {

View File

@@ -0,0 +1,28 @@
mod engine;
mod sandbox;
mod error;
mod api;
mod structs;
pub use structs::*;
pub use engine::LuaEngine;
pub use sandbox::SandboxLevel;
pub use error::LuaError;
use std::path::PathBuf;
use std::time::Duration;
#[cfg(test)]
mod tests;
// Execute a Lua command script
pub fn execute(
script_path: &PathBuf,
context: CommandContext,
sandbox: SandboxLevel,
timeout: Duration,
) -> Result<CommandResult, LuaError> {
let engine = LuaEngine::new(sandbox)?;
engine.execute(script_path, context, timeout)
}

View File

@@ -0,0 +1,7 @@
pub mod core;
pub mod audio;
pub mod context;
pub mod http;
pub mod fs;
pub mod state;
pub mod system;

View File

@@ -0,0 +1,37 @@
// QUICK HELP ON HOW TO ADD NEW LUA API MODULE
//
// # 1. DEFINE NEW API MODULE FILE
use mlua::{Lua, Table};
use crate::lua::error::LuaError;
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let mymodule = lua.create_table()?;
// add functions
let my_fn = lua.create_function(|_, arg: String| {
// implementation
Ok(format!("Result: {}", arg))
})?;
mymodule.set("my_function", my_fn)?;
jarvis.set("mymodule", mymodule)?;
Ok(())
}
// # 2. ADD NEW API MODULE TO mod.rs
pub mod mymodule;
// # 3. REGISTER NEW MODULE IN engine.rs
// in register_api()
api::mymodule::register(&self.lua, &jarvis)?;
// # 4. YOU CAN ALSO DEFINE ASYNC FUNCTIONS INSTEAD
let async_fn = lua.create_async_function(|_, url: String| async move {
let response = reqwest::get(&url).await
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
Ok(response.text().await.unwrap_or_default())
})?;

View File

@@ -0,0 +1,77 @@
// Audio Lua API: something sound related, apparently :3
use mlua::{Lua, Table};
use crate::voices::{self, Reaction};
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let audio = lua.create_table()?;
// jarvis.audio.play(reaction)
// reactions: "ok", "reply", "greet", "not_found", "error", "goodbye", "thanks"
let play_fn = lua.create_function(|_, reaction: String| {
let reaction_enum = match reaction.to_lowercase().as_str() {
"ok" => Reaction::Ok,
"reply" => Reaction::Reply,
"greet" => Reaction::Greet,
"not_found" => Reaction::NotFound,
"error" => Reaction::Error,
"goodbye" => Reaction::Goodbye,
"thanks" => Reaction::Thanks,
// "joke" => Reaction::Joke, NO PUN INTENDED :3
_ => {
log::warn!("[Lua] Unknown reaction: {}", reaction);
return Ok(false);
}
};
voices::play(reaction_enum);
Ok(true)
})?;
audio.set("play", play_fn)?;
// jarvis.audio.play_ok()
let play_ok_fn = lua.create_function(|_, ()| {
voices::play_ok();
Ok(())
})?;
audio.set("play_ok", play_ok_fn)?;
// jarvis.audio.play_reply()
let play_reply_fn = lua.create_function(|_, ()| {
voices::play_reply();
Ok(())
})?;
audio.set("play_reply", play_reply_fn)?;
// jarvis.audio.play_error()
let play_error_fn = lua.create_function(|_, ()| {
voices::play_error();
Ok(())
})?;
audio.set("play_error", play_error_fn)?;
// jarvis.audio.play_not_found()
let play_not_found_fn = lua.create_function(|_, ()| {
voices::play_not_found();
Ok(())
})?;
audio.set("play_not_found", play_not_found_fn)?;
// jarvis.audio.play_greet()
let play_greet_fn = lua.create_function(|_, ()| {
voices::play_greet();
Ok(())
})?;
audio.set("play_greet", play_greet_fn)?;
// jarvis.audio.play_goodbye()
let play_goodbye_fn = lua.create_function(|_, ()| {
voices::play_goodbye();
Ok(())
})?;
audio.set("play_goodbye", play_goodbye_fn)?;
jarvis.set("audio", audio)?;
Ok(())
}

View File

@@ -0,0 +1,31 @@
// Context Lua API: read-only command context
use mlua::{Lua, Table};
use crate::lua::{CommandContext};
pub fn register(lua: &Lua, jarvis: &Table, ctx: &CommandContext) -> mlua::Result<()> {
let context = lua.create_table()?;
// read-only context values
context.set("phrase", ctx.phrase.clone())?;
context.set("command_id", ctx.command_id.clone())?;
context.set("command_path", ctx.command_path.to_string_lossy().to_string())?;
context.set("language", ctx.language.clone())?;
// time info
let time = lua.create_table()?;
let now = chrono::Local::now();
time.set("year", now.format("%Y").to_string())?;
time.set("month", now.format("%m").to_string())?;
time.set("day", now.format("%d").to_string())?;
time.set("hour", now.format("%H").to_string())?;
time.set("minute", now.format("%M").to_string())?;
time.set("second", now.format("%S").to_string())?;
time.set("weekday", now.format("%A").to_string())?;
time.set("timestamp", now.timestamp())?;
context.set("time", time)?;
jarvis.set("context", context)?;
Ok(())
}

View File

@@ -0,0 +1,50 @@
// Core Lua API: log, sleep, print, etc.
use mlua::{Lua, Table, MultiValue};
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
// @ jarvis.log(level, message)
// log something
let log_fn = lua.create_function(|_, (level, message): (String, String)| {
match level.to_lowercase().as_str() {
"debug" => log::debug!("[Lua] {}", message),
"info" => log::info!("[Lua] {}", message),
"warn" => log::warn!("[Lua] {}", message),
"error" => log::error!("[Lua] {}", message),
_ => log::info!("[Lua] {}", message),
}
Ok(())
})?;
jarvis.set("log", log_fn)?;
// @ jarvis.print(...)
// simple print
let print_fn = lua.create_function(|_, args: MultiValue| {
let parts: Vec<String> = args.iter()
.map(|v| format!("{:?}", v))
.collect();
log::info!("[Lua] {}", parts.join(" "));
Ok(())
})?;
jarvis.set("print", print_fn)?;
// @ jarvis.sleep(ms)
// ..zZZ
let sleep_fn = lua.create_function(|_, ms: u64| {
std::thread::sleep(std::time::Duration::from_millis(ms));
Ok(())
})?;
jarvis.set("sleep", sleep_fn)?;
// @ jarvis.speak(text)
// @TODO: update when TTS will be implemented
let speak_fn = lua.create_function(|_, text: String| {
log::info!("[Lua] SPEAK: {}", text);
// pass
Ok(())
})?;
jarvis.set("speak", speak_fn)?;
Ok(())
}

View File

@@ -0,0 +1,212 @@
// File System Lua API: read, write, list (sandboxed)
use mlua::{Lua, Table};
use std::path::{Path, PathBuf};
use std::fs;
use crate::lua::sandbox::SandboxLevel;
pub fn register(
lua: &Lua,
jarvis: &Table,
command_path: &PathBuf,
sandbox: SandboxLevel,
) -> mlua::Result<()> {
let fs_table = lua.create_table()?;
let cmd_path = command_path.clone();
let sandbox_level = sandbox;
// jarvis.fs.read(path)
let cmd_path_read = cmd_path.clone();
let read_fn = lua.create_function(move |_, path: String| {
let full_path = resolve_path(&cmd_path_read, &path, sandbox_level)?;
fs::read_to_string(&full_path)
.map_err(|e| mlua::Error::runtime(format!("Read error: {}", e)))
})?;
fs_table.set("read", read_fn)?;
// jarvis.fs.read_bytes(path)
let cmd_path_read_bytes = cmd_path.clone();
let read_bytes_fn = lua.create_function(move |lua, path: String| {
let full_path = resolve_path(&cmd_path_read_bytes, &path, sandbox_level)?;
let bytes = fs::read(&full_path)
.map_err(|e| mlua::Error::runtime(format!("Read error: {}", e)))?;
Ok(lua.create_string(&bytes)?)
})?;
fs_table.set("read_bytes", read_bytes_fn)?;
// jarvis.fs.write(path, content)
let cmd_path_write = cmd_path.clone();
let write_fn = lua.create_function(move |_, (path, content): (String, String)| {
if !sandbox_level.allows_fs_write() {
return Err(mlua::Error::runtime("Write not allowed in this sandbox"));
}
let full_path = resolve_path(&cmd_path_write, &path, sandbox_level)?;
// ensure parent directory exists
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| mlua::Error::runtime(format!("Create dir error: {}", e)))?;
}
fs::write(&full_path, content)
.map_err(|e| mlua::Error::runtime(format!("Write error: {}", e)))?;
Ok(true)
})?;
fs_table.set("write", write_fn)?;
// jarvis.fs.append(path, content)
let cmd_path_append = cmd_path.clone();
let append_fn = lua.create_function(move |_, (path, content): (String, String)| {
if !sandbox_level.allows_fs_write() {
return Err(mlua::Error::runtime("Write not allowed in this sandbox"));
}
let full_path = resolve_path(&cmd_path_append, &path, sandbox_level)?;
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&full_path)
.map_err(|e| mlua::Error::runtime(format!("Open error: {}", e)))?;
file.write_all(content.as_bytes())
.map_err(|e| mlua::Error::runtime(format!("Write error: {}", e)))?;
Ok(true)
})?;
fs_table.set("append", append_fn)?;
// jarvis.fs.exists(path)
let cmd_path_exists = cmd_path.clone();
let exists_fn = lua.create_function(move |_, path: String| {
let full_path = resolve_path(&cmd_path_exists, &path, sandbox_level)?;
Ok(full_path.exists())
})?;
fs_table.set("exists", exists_fn)?;
// jarvis.fs.is_file(path)
let cmd_path_is_file = cmd_path.clone();
let is_file_fn = lua.create_function(move |_, path: String| {
let full_path = resolve_path(&cmd_path_is_file, &path, sandbox_level)?;
Ok(full_path.is_file())
})?;
fs_table.set("is_file", is_file_fn)?;
// jarvis.fs.is_dir(path)
let cmd_path_is_dir = cmd_path.clone();
let is_dir_fn = lua.create_function(move |_, path: String| {
let full_path = resolve_path(&cmd_path_is_dir, &path, sandbox_level)?;
Ok(full_path.is_dir())
})?;
fs_table.set("is_dir", is_dir_fn)?;
// jarvis.fs.list(path)
let cmd_path_list = cmd_path.clone();
let list_fn = lua.create_function(move |lua, path: Option<String>| {
let full_path = if let Some(p) = path {
resolve_path(&cmd_path_list, &p, sandbox_level)?
} else {
cmd_path_list.clone()
};
let result = lua.create_table()?;
let entries = fs::read_dir(&full_path)
.map_err(|e| mlua::Error::runtime(format!("List error: {}", e)))?;
let mut idx = 1;
for entry in entries {
if let Ok(entry) = entry {
let item = lua.create_table()?;
item.set("name", entry.file_name().to_string_lossy().to_string())?;
item.set("path", entry.path().to_string_lossy().to_string())?;
item.set("is_file", entry.path().is_file())?;
item.set("is_dir", entry.path().is_dir())?;
result.set(idx, item)?;
idx += 1;
}
}
Ok(result)
})?;
fs_table.set("list", list_fn)?;
// jarvis.fs.mkdir(path)
let cmd_path_mkdir = cmd_path.clone();
let mkdir_fn = lua.create_function(move |_, path: String| {
if !sandbox_level.allows_fs_write() {
return Err(mlua::Error::runtime("Write not allowed in this sandbox"));
}
let full_path = resolve_path(&cmd_path_mkdir, &path, sandbox_level)?;
fs::create_dir_all(&full_path)
.map_err(|e| mlua::Error::runtime(format!("Mkdir error: {}", e)))?;
Ok(true)
})?;
fs_table.set("mkdir", mkdir_fn)?;
// jarvis.fs.remove(path)
let cmd_path_remove = cmd_path.clone();
let remove_fn = lua.create_function(move |_, path: String| {
if !sandbox_level.allows_fs_write() {
return Err(mlua::Error::runtime("Write not allowed in this sandbox"));
}
let full_path = resolve_path(&cmd_path_remove, &path, sandbox_level)?;
if full_path.is_dir() {
fs::remove_dir_all(&full_path)
.map_err(|e| mlua::Error::runtime(format!("Remove error: {}", e)))?;
} else {
fs::remove_file(&full_path)
.map_err(|e| mlua::Error::runtime(format!("Remove error: {}", e)))?;
}
Ok(true)
})?;
fs_table.set("remove", remove_fn)?;
jarvis.set("fs", fs_table)?;
Ok(())
}
// Resolve path relative to command folder, with sandbox checks
fn resolve_path(command_path: &PathBuf, path: &str, sandbox: SandboxLevel) -> mlua::Result<PathBuf> {
let path = Path::new(path);
// if absolute path, check sandbox allows it
if path.is_absolute() {
if !sandbox.allows_expanded_paths() {
return Err(mlua::Error::runtime("Absolute paths not allowed in this sandbox"));
}
return Ok(path.to_path_buf());
}
// relative path - resolve against command folder
let resolved = command_path.join(path);
// canonicalize to resolve ../ etc and check it's still within command folder
let canonical = resolved.canonicalize()
.unwrap_or_else(|_| resolved.clone());
let cmd_canonical = command_path.canonicalize()
.unwrap_or_else(|_| command_path.clone());
if !sandbox.allows_expanded_paths() && !canonical.starts_with(&cmd_canonical) {
return Err(mlua::Error::runtime("Path escapes command folder"));
}
Ok(resolved)
}

View File

@@ -0,0 +1,226 @@
// HTTP Lua API: GET, POST, JSON requests
use mlua::{Lua, Table, Value};
use std::collections::HashMap;
use std::time::Duration;
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let http = lua.create_table()?;
// jarvis.http.get(url, headers?)
let get_fn = lua.create_function(|lua, (url, headers): (String, Option<Table>)| {
http_request(lua, "GET", &url, None, headers)
})?;
http.set("get", get_fn)?;
// jarvis.http.post(url, body, headers?)
let post_fn = lua.create_function(|lua, (url, body, headers): (String, Option<String>, Option<Table>)| {
http_request(lua, "POST", &url, body, headers)
})?;
http.set("post", post_fn)?;
// jarvis.http.post_json(url, data, headers?)
let post_json_fn = lua.create_function(|lua, (url, data, headers): (String, Table, Option<Table>)| {
// convert Lua table to JSON string
let json_value = table_to_json(lua, data)?;
let body = serde_json::to_string(&json_value)
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
// add content-type header
let mut header_map: HashMap<String, String> = HashMap::new();
header_map.insert("Content-Type".to_string(), "application/json".to_string());
if let Some(h) = headers {
for pair in h.pairs::<String, String>() {
if let Ok((k, v)) = pair {
header_map.insert(k, v);
}
}
}
http_request_with_headers(lua, "POST", &url, Some(body), header_map)
})?;
http.set("post_json", post_json_fn)?;
// jarvis.http.json(url) - GET + parse JSON
let json_fn = lua.create_function(|lua, url: String| {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
let response = client.get(&url)
.send()
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
if response.status().is_success() {
let json: serde_json::Value = response.json()
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
json_to_lua(lua, json)
} else {
Ok(Value::Nil)
}
})?;
http.set("json", json_fn)?;
jarvis.set("http", http)?;
Ok(())
}
fn http_request(
lua: &Lua,
method: &str,
url: &str,
body: Option<String>,
headers: Option<Table>,
) -> mlua::Result<Table> {
let header_map: HashMap<String, String> = if let Some(h) = headers {
h.pairs::<String, String>()
.filter_map(|r| r.ok())
.collect()
} else {
HashMap::new()
};
http_request_with_headers(lua, method, url, body, header_map)
}
fn http_request_with_headers(
lua: &Lua,
method: &str,
url: &str,
body: Option<String>,
headers: HashMap<String, String>,
) -> mlua::Result<Table> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
let mut request = match method {
"POST" => client.post(url),
"PUT" => client.put(url),
"DELETE" => client.delete(url),
_ => client.get(url),
};
for (k, v) in headers {
request = request.header(&k, &v);
}
if let Some(b) = body {
request = request.body(b);
}
let result = lua.create_table()?;
match request.send() {
Ok(response) => {
result.set("ok", response.status().is_success())?;
result.set("status", response.status().as_u16())?;
// get headers
let headers_table = lua.create_table()?;
for (name, value) in response.headers() {
if let Ok(v) = value.to_str() {
headers_table.set(name.as_str(), v)?;
}
}
result.set("headers", headers_table)?;
// get body
match response.text() {
Ok(text) => result.set("body", text)?,
Err(e) => result.set("body", format!("Error reading body: {}", e))?,
}
}
Err(e) => {
result.set("ok", false)?;
result.set("status", 0)?;
result.set("error", e.to_string())?;
result.set("body", "")?;
}
}
Ok(result)
}
// Convert Lua table to serde_json::Value
fn table_to_json(lua: &Lua, table: Table) -> mlua::Result<serde_json::Value> {
use serde_json::{Value as JsonValue, Map, Number};
// check if it's an array (sequential integer keys starting from 1)
let is_array = table.clone().pairs::<i64, Value>()
.filter_map(|r| r.ok())
.enumerate()
.all(|(i, (k, _))| k == (i + 1) as i64);
if is_array && table.len()? > 0 {
let arr: Vec<JsonValue> = table.sequence_values::<Value>()
.filter_map(|r| r.ok())
.map(|v| lua_to_json(lua, v))
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonValue::Array(arr))
} else {
let mut map = Map::new();
for pair in table.pairs::<String, Value>() {
let (k, v) = pair?;
map.insert(k, lua_to_json(lua, v)?);
}
Ok(JsonValue::Object(map))
}
}
// Convert Lua Value to serde_json::Value
fn lua_to_json(lua: &Lua, value: Value) -> mlua::Result<serde_json::Value> {
use serde_json::{Value as JsonValue, Number};
match value {
Value::Nil => Ok(JsonValue::Null),
Value::Boolean(b) => Ok(JsonValue::Bool(b)),
Value::Integer(i) => Ok(JsonValue::Number(Number::from(i))),
Value::Number(n) => {
Number::from_f64(n)
.map(JsonValue::Number)
.ok_or_else(|| mlua::Error::runtime("Invalid float"))
}
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
Value::Table(t) => table_to_json(lua, t),
_ => Err(mlua::Error::runtime("Unsupported type for JSON")),
}
}
// Convert serde_json::Value to Lua Value
fn json_to_lua(lua: &Lua, json: serde_json::Value) -> mlua::Result<Value> {
use serde_json::Value as JsonValue;
match json {
JsonValue::Null => Ok(Value::Nil),
JsonValue::Bool(b) => Ok(Value::Boolean(b)),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
JsonValue::String(s) => Ok(Value::String(lua.create_string(&s)?)),
JsonValue::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.into_iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
JsonValue::Object(map) => {
let table = lua.create_table()?;
for (k, v) in map {
table.set(k, json_to_lua(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}

View File

@@ -0,0 +1,184 @@
// State Lua API: persistent key-value storage per command
use mlua::{Lua, Table, Result, Value};
use std::path::PathBuf;
use std::fs;
use std::collections::HashMap;
const STATE_FILE: &str = ".state.json";
pub fn register(lua: &Lua, jarvis: &Table, command_path: &PathBuf) -> mlua::Result<()> {
let state = lua.create_table()?;
let state_path = command_path.join(STATE_FILE);
// jarvis.state.get(key)
let state_path_get = state_path.clone();
let get_fn = lua.create_function(move |lua, key: String| {
let data = load_state(&state_path_get);
if let Some(value) = data.get(&key) {
json_to_lua_value(lua, value.clone())
} else {
Ok(Value::Nil)
}
})?;
state.set("get", get_fn)?;
// jarvis.state.set(key, value)
let state_path_set = state_path.clone();
let set_fn = lua.create_function(move |_, (key, value): (String, Value)| {
let mut data = load_state(&state_path_set);
let json_value = lua_to_json_value(value)?;
data.insert(key, json_value);
save_state(&state_path_set, &data)?;
Ok(true)
})?;
state.set("set", set_fn)?;
// jarvis.state.delete(key)
let state_path_delete = state_path.clone();
let delete_fn = lua.create_function(move |_, key: String| {
let mut data = load_state(&state_path_delete);
let existed = data.remove(&key).is_some();
save_state(&state_path_delete, &data)?;
Ok(existed)
})?;
state.set("delete", delete_fn)?;
// jarvis.state.clear()
let state_path_clear = state_path.clone();
let clear_fn = lua.create_function(move |_, ()| {
let data: HashMap<String, serde_json::Value> = HashMap::new();
save_state(&state_path_clear, &data)?;
Ok(true)
})?;
state.set("clear", clear_fn)?;
// jarvis.state.keys()
let state_path_keys = state_path.clone();
let keys_fn = lua.create_function(move |lua, ()| {
let data = load_state(&state_path_keys);
let table = lua.create_table()?;
for (i, key) in data.keys().enumerate() {
table.set(i + 1, key.clone())?;
}
Ok(table)
})?;
state.set("keys", keys_fn)?;
// jarvis.state.all()
let state_path_all = state_path.clone();
let all_fn = lua.create_function(move |lua, ()| {
let data = load_state(&state_path_all);
let table = lua.create_table()?;
for (key, value) in data {
let lua_value = json_to_lua_value(lua, value)?;
table.set(key, lua_value)?;
}
Ok(table)
})?;
state.set("all", all_fn)?;
jarvis.set("state", state)?;
Ok(())
}
fn load_state(path: &PathBuf) -> HashMap<String, serde_json::Value> {
if !path.exists() {
return HashMap::new();
}
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_state(path: &PathBuf, data: &HashMap<String, serde_json::Value>) -> mlua::Result<()> {
let json = serde_json::to_string_pretty(data)
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
fs::write(path, json)
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
Ok(())
}
fn lua_to_json_value(value: Value) -> mlua::Result<serde_json::Value> {
use serde_json::Value as JsonValue;
match value {
Value::Nil => Ok(JsonValue::Null),
Value::Boolean(b) => Ok(JsonValue::Bool(b)),
Value::Integer(i) => Ok(JsonValue::Number(i.into())),
Value::Number(n) => {
serde_json::Number::from_f64(n)
.map(JsonValue::Number)
.ok_or_else(|| mlua::Error::runtime("Invalid float"))
}
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
Value::Table(t) => {
// check if array
let is_array = t.clone().pairs::<i64, Value>()
.filter_map(|r| r.ok())
.enumerate()
.all(|(i, (k, _))| k == (i + 1) as i64);
if is_array && t.len().unwrap_or(0) > 0 {
let arr: Vec<JsonValue> = t.sequence_values::<Value>()
.filter_map(|r| r.ok())
.map(lua_to_json_value)
.collect::<Result<Vec<_>>>()?;
Ok(JsonValue::Array(arr))
} else {
let mut map = serde_json::Map::new();
for pair in t.pairs::<String, Value>() {
let (k, v) = pair?;
map.insert(k, lua_to_json_value(v)?);
}
Ok(JsonValue::Object(map))
}
}
_ => Err(mlua::Error::runtime("Unsupported type for state")),
}
}
fn json_to_lua_value(lua: &Lua, json: serde_json::Value) -> mlua::Result<Value> {
use serde_json::Value as JsonValue;
match json {
JsonValue::Null => Ok(Value::Nil),
JsonValue::Bool(b) => Ok(Value::Boolean(b)),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::Number(f))
} else {
Ok(Value::Nil)
}
}
JsonValue::String(s) => Ok(Value::String(lua.create_string(&s)?)),
JsonValue::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.into_iter().enumerate() {
table.set(i + 1, json_to_lua_value(lua, v)?)?;
}
Ok(Value::Table(table))
}
JsonValue::Object(map) => {
let table = lua.create_table()?;
for (k, v) in map {
table.set(k, json_to_lua_value(lua, v)?)?;
}
Ok(Value::Table(table))
}
}
}

View File

@@ -0,0 +1,240 @@
// System Lua API: exec, open, clipboard, notify
use mlua::{Lua, Table};
use std::process::Command;
use crate::lua::sandbox::SandboxLevel;
pub fn register(lua: &Lua, jarvis: &Table, sandbox: SandboxLevel) -> mlua::Result<()> {
let system = lua.create_table()?;
// jarvis.system.open(url_or_path) - always available
let open_fn = lua.create_function(|_, target: String| {
let result = if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", "start", "", &target])
.spawn()
} else if cfg!(target_os = "macos") {
Command::new("open")
.arg(&target)
.spawn()
} else {
Command::new("xdg-open")
.arg(&target)
.spawn()
};
match result {
Ok(_) => Ok(true),
Err(e) => {
log::warn!("[Lua] Failed to open {}: {}", target, e);
Ok(false)
}
}
})?;
system.set("open", open_fn)?;
// jarvis.system.exec(cmd, args?) - only in full sandbox
if sandbox.allows_exec() {
let exec_fn = lua.create_function(|lua, (cmd, args): (String, Option<Table>)| {
let mut command = if cfg!(target_os = "windows") {
let mut c = Command::new("cmd");
c.args(["/C", &cmd]);
c
} else {
let mut c = Command::new("sh");
c.args(["-c", &cmd]);
c
};
// add extra args if provided
if let Some(args_table) = args {
for pair in args_table.sequence_values::<String>() {
if let Ok(arg) = pair {
command.arg(arg);
}
}
}
let output = command.output()
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
let result = lua.create_table()?;
result.set("success", output.status.success())?;
result.set("code", output.status.code().unwrap_or(-1))?;
result.set("stdout", String::from_utf8_lossy(&output.stdout).to_string())?;
result.set("stderr", String::from_utf8_lossy(&output.stderr).to_string())?;
Ok(result)
})?;
system.set("exec", exec_fn)?;
}
// jarvis.system.notify(title, message) - always available
let notify_fn = lua.create_function(|_, (title, message): (String, String)| {
log::info!("[Lua] NOTIFY: {} - {}", title, message);
// platform-specific notification
#[cfg(target_os = "windows")]
{
use winrt_notification::{Toast, Duration as ToastDuration};
if let Err(e) = Toast::new(Toast::POWERSHELL_APP_ID)
.title(&title)
.text1(&message)
.duration(ToastDuration::Short)
.show()
{
log::warn!("[Lua] Failed to show toast notification: {}", e);
// fallback to msg.exe
let _ = Command::new("msg")
.args(["*", "/time:10", &format!("{}: {}", title, message)])
.spawn();
}
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("notify-send")
.args([&title, &message])
.spawn();
}
#[cfg(target_os = "macos")]
{
let script = format!(
r#"display notification "{}" with title "{}""#,
message.replace("\"", "\\\""),
title.replace("\"", "\\\"")
);
let _ = Command::new("osascript")
.args(["-e", &script])
.spawn();
}
Ok(true)
})?;
system.set("notify", notify_fn)?;
// jarvis.system.clipboard - subtable
let clipboard = lua.create_table()?;
// jarvis.system.clipboard.get() - always available
let clipboard_get_fn = lua.create_function(|_, ()| {
#[cfg(target_os = "windows")]
{
let output = Command::new("powershell")
.args(["-Command", "Get-Clipboard"])
.output()
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg(target_os = "linux")]
{
let output = Command::new("xclip")
.args(["-selection", "clipboard", "-o"])
.output()
.or_else(|_| {
Command::new("xsel")
.args(["--clipboard", "--output"])
.output()
})
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[cfg(target_os = "macos")]
{
let output = Command::new("pbpaste")
.output()
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(mlua::Error::runtime("Clipboard not supported on this platform"))
}
})?;
clipboard.set("get", clipboard_get_fn)?;
// jarvis.system.clipboard.set(text) - only in full sandbox
if sandbox.allows_clipboard_write() {
let clipboard_set_fn = lua.create_function(|_, text: String| {
#[cfg(target_os = "windows")]
{
let script = format!("Set-Clipboard -Value '{}'", text.replace("'", "''"));
Command::new("powershell")
.args(["-Command", &script])
.output()
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
}
#[cfg(target_os = "linux")]
{
use std::io::Write;
let mut child = Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn()
.or_else(|_| {
Command::new("xsel")
.args(["--clipboard", "--input"])
.stdin(std::process::Stdio::piped())
.spawn()
})
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(text.as_bytes())
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
}
}
#[cfg(target_os = "macos")]
{
use std::io::Write;
let mut child = Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(text.as_bytes())
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
}
}
Ok(true)
})?;
clipboard.set("set", clipboard_set_fn)?;
}
system.set("clipboard", clipboard)?;
// jarvis.system.env(name) - get environment variable (always available)
let env_fn = lua.create_function(|_, name: String| {
Ok(std::env::var(&name).ok())
})?;
system.set("env", env_fn)?;
// jarvis.system.platform - read-only string
let platform = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
};
system.set("platform", platform)?;
jarvis.set("system", system)?;
Ok(())
}

View File

@@ -0,0 +1,171 @@
use mlua::{Lua, Result as LuaResult, Value, StdLib};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use std::fs;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, mpsc};
use super::sandbox::SandboxLevel;
use super::error::LuaError;
use super::{CommandContext, CommandResult};
use super::api;
pub struct LuaEngine {
lua: Lua,
sandbox: SandboxLevel,
}
impl LuaEngine {
pub fn new(sandbox: SandboxLevel) -> Result<Self, LuaError> {
// select which standard libraries to load based on sandbox access level
let std_libs = match sandbox {
SandboxLevel::Minimal => {
StdLib::TABLE | StdLib::STRING | StdLib::MATH
}
SandboxLevel::Standard => {
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8
}
SandboxLevel::Full => {
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8 | StdLib::OS
}
};
let lua = Lua::new_with(std_libs, mlua::LuaOptions::default())
.map_err(|e| LuaError::InitError(e.to_string()))?;
// remove dangerous globals regardless of sandbox
{
let globals = lua.globals();
// always remove these
let _ = globals.set("loadfile", Value::Nil);
let _ = globals.set("dofile", Value::Nil);
let _ = globals.set("load", Value::Nil);
let _ = globals.set("loadstring", Value::Nil);
// remove io unless full sandbox
if !matches!(sandbox, SandboxLevel::Full) {
let _ = globals.set("io", Value::Nil);
}
// remove os.execute, os.exit, os.setlocale even in full mode
// for SECURITY REASONS!!!
if matches!(sandbox, SandboxLevel::Full) {
if let Ok(os) = globals.get::<mlua::Table>("os") {
let _ = os.set("execute", Value::Nil);
let _ = os.set("exit", Value::Nil);
let _ = os.set("remove", Value::Nil);
let _ = os.set("rename", Value::Nil);
let _ = os.set("setlocale", Value::Nil);
}
}
}
Ok(Self { lua, sandbox })
}
// Register all jarvis APIs
fn register_api(&self, context: &CommandContext) -> Result<(), LuaError> {
let globals = self.lua.globals();
// main jarvis table
let jarvis = self.lua.create_table()
.map_err(|e| LuaError::InitError(e.to_string()))?;
// always register core APIs
api::core::register(&self.lua, &jarvis)?;
api::audio::register(&self.lua, &jarvis)?;
api::context::register(&self.lua, &jarvis, context)?;
// sandbox-controlled APIs
if self.sandbox.allows_http() {
api::http::register(&self.lua, &jarvis)?;
}
if self.sandbox.allows_state() {
api::state::register(&self.lua, &jarvis, &context.command_path)?;
}
if self.sandbox.allows_fs() {
api::fs::register(&self.lua, &jarvis, &context.command_path, self.sandbox)?;
}
api::system::register(&self.lua, &jarvis, self.sandbox)?;
globals.set("jarvis", jarvis)
.map_err(|e| LuaError::InitError(e.to_string()))?;
Ok(())
}
// Main LUA executor
pub fn execute(
&self,
script_path: &PathBuf,
context: CommandContext,
timeout: Duration,
) -> Result<CommandResult, LuaError> {
// register APIs
self.register_api(&context)?;
// load script
let script_content = fs::read_to_string(script_path)
.map_err(|e| LuaError::LoadError(format!("{}: {}", script_path.display(), e)))?;
let script_name = script_path.file_name()
.unwrap()
.to_string_lossy()
.to_string();
// set up timeout hook
let start = std::time::Instant::now();
self.lua.set_hook(mlua::HookTriggers {
every_nth_instruction: Some(1000),
..Default::default()
}, move |_lua, _debug| {
if start.elapsed() > timeout {
Err(mlua::Error::runtime("Script timeout"))
} else {
Ok(mlua::VmState::Continue)
}
}).map_err(|e| LuaError::InitError(e.to_string()))?;
// execute script
let result = self.lua.load(&script_content)
.set_name(&script_name)
.eval::<Value>();
// remove hook
let _ = self.lua.remove_hook();
// result
match result {
Ok(value) => Ok(self.parse_result(value)),
Err(e) => {
if e.to_string().contains("timeout") {
Err(LuaError::Timeout)
} else {
Err(LuaError::RuntimeError(e.to_string()))
}
}
}
}
// Parse Lua return value into CommandResult
fn parse_result(&self, value: Value) -> CommandResult {
match value {
// return { chain = false }
Value::Table(t) => {
let chain = t.get::<bool>("chain").unwrap_or(true);
CommandResult { chain }
}
// return false (shorthand for no chain)
Value::Boolean(chain) => CommandResult { chain },
// return nil or no return = chain
_ => CommandResult::default(),
}
}
}

View File

@@ -0,0 +1,49 @@
use std::fmt;
#[derive(Debug)]
pub enum LuaError {
// Failed to create Lua VM
InitError(String),
// Failed to load script file
LoadError(String),
// Script execution error
RuntimeError(String),
// Script exceeded timeout
Timeout,
// Sandbox violation
SandboxViolation(String),
// IO error
IoError(std::io::Error),
}
impl fmt::Display for LuaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LuaError::InitError(msg) => write!(f, "Lua init error: {}", msg),
LuaError::LoadError(msg) => write!(f, "Script load error: {}", msg),
LuaError::RuntimeError(msg) => write!(f, "Runtime error: {}", msg),
LuaError::Timeout => write!(f, "Script timeout"),
LuaError::SandboxViolation(msg) => write!(f, "Sandbox violation: {}", msg),
LuaError::IoError(e) => write!(f, "IO error: {}", e),
}
}
}
impl std::error::Error for LuaError {}
impl From<mlua::Error> for LuaError {
fn from(e: mlua::Error) -> Self {
LuaError::RuntimeError(e.to_string())
}
}
impl From<std::io::Error> for LuaError {
fn from(e: std::io::Error) -> Self {
LuaError::IoError(e)
}
}

View File

@@ -0,0 +1,65 @@
use serde::{Deserialize, Serialize};
// Sandbox level controlling what APIs are available
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SandboxLevel {
// Minimal: only core APIs (log, speak, audio, context)
Minimal,
// Standard: + http, state, fs (command folder only)
Standard,
// Full: + system.exec, expanded fs access
Full,
}
impl SandboxLevel {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"minimal" => SandboxLevel::Minimal,
"full" => SandboxLevel::Full,
_ => SandboxLevel::Standard,
}
}
// Can use HTTP API
pub fn allows_http(&self) -> bool {
matches!(self, SandboxLevel::Standard | SandboxLevel::Full)
}
// Can use persistent state API
pub fn allows_state(&self) -> bool {
matches!(self, SandboxLevel::Standard | SandboxLevel::Full)
}
// Can use file system API
pub fn allows_fs(&self) -> bool {
matches!(self, SandboxLevel::Standard | SandboxLevel::Full)
}
// Can write files
pub fn allows_fs_write(&self) -> bool {
matches!(self, SandboxLevel::Standard | SandboxLevel::Full)
}
// Can execute system commands
pub fn allows_exec(&self) -> bool {
matches!(self, SandboxLevel::Full)
}
// Can access clipboard write
pub fn allows_clipboard_write(&self) -> bool {
matches!(self, SandboxLevel::Full)
}
// Can access paths outside command folder
pub fn allows_expanded_paths(&self) -> bool {
matches!(self, SandboxLevel::Full)
}
}
impl Default for SandboxLevel {
fn default() -> Self {
SandboxLevel::Standard
}
}

View File

@@ -0,0 +1,30 @@
use std::path::PathBuf;
// Context passed to Lua scripts
#[derive(Debug, Clone)]
pub struct CommandContext {
// The phrase that triggered the command
pub phrase: String,
// Command ID
pub command_id: String,
// Path to command folder
pub command_path: PathBuf,
// Current language
pub language: String,
}
// Result returned from Lua script execution
#[derive(Debug, Clone)]
pub struct CommandResult {
// Whether to continue chaining commands
pub chain: bool,
}
impl Default for CommandResult {
fn default() -> Self {
Self { chain: true }
}
}

View File

@@ -0,0 +1,111 @@
#[cfg(test)]
mod tests {
use crate::lua::{CommandContext, LuaError, SandboxLevel, execute};
use std::path::PathBuf;
use std::time::Duration;
use tempfile::tempdir;
use std::fs;
fn create_test_context(cmd_path: PathBuf) -> CommandContext {
CommandContext {
phrase: "test phrase".to_string(),
command_id: "test_cmd".to_string(),
command_path: cmd_path,
language: "en".to_string(),
}
}
#[test]
fn test_minimal_sandbox() {
let dir = tempdir().unwrap();
let script_path = dir.path().join("test.lua");
fs::write(&script_path, r#"
jarvis.log("info", "test log")
return { chain = false }
"#).unwrap();
let context = create_test_context(dir.path().to_path_buf());
let result = execute(
&script_path,
context,
SandboxLevel::Minimal,
Duration::from_secs(5),
);
assert!(result.is_ok());
assert_eq!(result.unwrap().chain, false);
}
#[test]
fn test_state_persistence() {
let dir = tempdir().unwrap();
let script_path = dir.path().join("test.lua");
// first run - set state
fs::write(&script_path, r#"
jarvis.state.set("key", "value")
return true
"#).unwrap();
let context = create_test_context(dir.path().to_path_buf());
execute(&script_path, context, SandboxLevel::Standard, Duration::from_secs(5)).unwrap();
// second run - read state
fs::write(&script_path, r#"
local val = jarvis.state.get("key")
if val == "value" then
return true
else
error("State not persisted")
end
"#).unwrap();
let context = create_test_context(dir.path().to_path_buf());
let result = execute(&script_path, context, SandboxLevel::Standard, Duration::from_secs(5));
assert!(result.is_ok());
}
#[test]
fn test_timeout() {
let dir = tempdir().unwrap();
let script_path = dir.path().join("test.lua");
fs::write(&script_path, r#"
while true do end
"#).unwrap();
let context = create_test_context(dir.path().to_path_buf());
let result = execute(
&script_path,
context,
SandboxLevel::Minimal,
Duration::from_millis(100),
);
assert!(matches!(result, Err(LuaError::Timeout)));
}
#[test]
fn test_sandbox_fs_escape() {
let dir = tempdir().unwrap();
let script_path = dir.path().join("test.lua");
fs::write(&script_path, r#"
local ok, err = pcall(function()
jarvis.fs.read("../../../etc/passwd")
end)
if ok then
error("Should have been blocked")
end
return true
"#).unwrap();
let context = create_test_context(dir.path().to_path_buf());
let result = execute(&script_path, context, SandboxLevel::Standard, Duration::from_secs(5));
assert!(result.is_ok());
}
}

View File

@@ -7,7 +7,8 @@ use parking_lot::RwLock;
use crate::{DB, SOUND_DIR, audio, config, time};
pub mod structs;
mod structs;
pub use structs::*;
static VOICES: OnceCell<Vec<structs::VoiceConfig>> = OnceCell::new();
static CURRENT_VOICE_ID: OnceCell<RwLock<String>> = OnceCell::new();

View File

@@ -1,4 +1,4 @@
use jarvis_core::voices::{self, structs::VoiceConfig};
use jarvis_core::voices::{self, VoiceConfig};
#[tauri::command]
pub fn list_voices() -> Vec<VoiceConfig> {

View File

@@ -6,6 +6,7 @@
import os
from pathlib import Path
import shutil
import sys
# some config vars
# format: (source, destination_name)
@@ -30,6 +31,9 @@ TARGET_DIRS = (
ABS_PATH = os.getcwd() + "/"
# check for force flag
force_overwrite = "-force" in sys.argv
for tdir in TARGET_DIRS:
tdir = ABS_PATH + tdir
@@ -52,7 +56,12 @@ for tdir in TARGET_DIRS:
full_target_dir_path = os.path.join(tdir, target_name)
if os.path.isdir(full_target_dir_path):
print("[-] Directory already exists, skipping: ", src, "->", target_name)
if force_overwrite:
shutil.rmtree(full_target_dir_path)
shutil.copytree(src_path, full_target_dir_path)
print("[+] Directory overwritten: ", src, "->", target_name)
else:
print("[-] Directory already exists, skipping: ", src, "->", target_name)
else:
shutil.copytree(src_path, full_target_dir_path)
print("[+] Directory copied: ", src, "->", target_name)
@@ -63,11 +72,16 @@ for tdir in TARGET_DIRS:
full_target_file_path = os.path.join(tdir, target_name)
if os.path.isfile(full_target_file_path):
print("[-] File already exists, skipping: ", src, "->", target_name)
if force_overwrite:
os.remove(full_target_file_path)
shutil.copy(src_path, full_target_file_path)
print("[+] File overwritten: ", src, "->", target_name)
else:
print("[-] File already exists, skipping: ", src, "->", target_name)
else:
shutil.copy(src_path, full_target_file_path)
print("[+] File copied: ", src, "->", target_name)
else:
print("[?] Unknown entity to copy: ", src)
print("Post compile build done.")
print("Post compile build done.")

View File

@@ -1,6 +1,6 @@
[[commands]]
id = "browser_open"
action = "ahk"
type = "ahk"
exe_path = "ahk/Run browser.exe"
sounds.ru = ["ok1", "ok2", "ok3", "ok4"]
sounds.en = ["ok1", "ok2", "ok3"]
@@ -36,7 +36,7 @@ phrases.ua = [
[[commands]]
id = "browser_close"
action = "ahk"
type = "ahk"
exe_path = "ahk/Close browser.exe"
sounds.ru = ["ok1", "ok2", "ok3", "ok4"]
sounds.en = ["ok1", "ok2", "ok3"]
@@ -64,7 +64,7 @@ phrases.ua = [
[[commands]]
id = "open_google"
action = "ahk"
type = "ahk"
exe_path = "ahk/Run website.exe"
exe_args = ["http://google.com"]
sounds.ru = ["ok1", "ok2", "ok3", "ok4"]

View File

@@ -0,0 +1,13 @@
[[commands]]
id = "counter"
type = "lua"
script = "script.lua"
sandbox = "standard"
timeout = 5000
phrases.ru = [
"счётчик"
]
phrases.en = [
"counter",
"count",
]

View File

@@ -0,0 +1,16 @@
-- simple counter demonstrating state persistence
local count = jarvis.state.get("count") or 0
count = count + 1
jarvis.state.set("count", count)
local lang = jarvis.context.language
local msg = lang == "ru"
and "Счётчик: " .. count
or "Counter: " .. count
jarvis.log("info", msg)
jarvis.system.notify("Counter", tostring(count))
jarvis.audio.play_ok()
return { chain = true }

View File

@@ -0,0 +1,14 @@
[[commands]]
id = "hello"
type = "lua"
script = "script.lua"
sandbox = "minimal"
timeout = 5000
phrases.ru = [
"привет",
"здравствуй",
]
phrases.en = [
"hello",
"hi",
]

View File

@@ -0,0 +1,21 @@
-- simple test hello command
local lang = jarvis.context.language
local hour = tonumber(jarvis.context.time.hour)
-- determine greeting based on time
local greeting
if hour >= 5 and hour < 12 then
greeting = lang == "ru" and "Доброе утро" or "Good morning"
elseif hour >= 12 and hour < 17 then
greeting = lang == "ru" and "Добрый день" or "Good afternoon"
elseif hour >= 17 and hour < 22 then
greeting = lang == "ru" and "Добрый вечер" or "Good evening"
else
greeting = lang == "ru" and "Доброй ночи" or "Good night"
end
jarvis.log("info", "Greeting user: " .. greeting)
jarvis.audio.play_reply()
return { chain = true }

View File

@@ -1,7 +1,9 @@
[[commands]]
id = "weather"
action = "cli"
cli_cmd = "weather.py"
type = "lua"
script = "script.lua"
timeout = 5000
sandbox = "standard"
[commands.phrases]
ru = [
@@ -15,4 +17,18 @@ en = [
[commands.sounds]
ru = ["weather_ru_1", "weather_ru_2"]
en = ["weather_en_1", "weather_en_2"]
en = ["weather_en_1", "weather_en_2"]
[[commands]]
id = "set_city"
type = "lua"
script = "set_city.lua"
sandbox = "standard"
timeout = 5000
phrases = [
"установи город",
"set city",
"change city",
]

View File

@@ -0,0 +1,29 @@
-- weather command using wttr.in API
local lang = jarvis.context.language
-- get saved city or use default
local city = jarvis.state.get("city") or "Moscow"
jarvis.log("info", "Fetching weather for: " .. city)
-- build URL
local url = "https://wttr.in/" .. city .. "?format=3&lang=" .. lang
-- make request
local response = jarvis.http.get(url)
if response.ok then
jarvis.log("info", "Weather: " .. response.body)
-- show notification
local title = lang == "ru" and "Погода" or "Weather"
jarvis.system.notify(title, response.body)
jarvis.audio.play_ok()
else
jarvis.log("error", "Failed to fetch weather: " .. (response.error or "unknown error"))
jarvis.audio.play_error()
end
return { chain = false }

View File

@@ -0,0 +1,32 @@
-- set city for weather command
local phrase = jarvis.context.phrase
local lang = jarvis.context.language
-- try to extract city name from phrase
-- this is a simple example - you might want better parsing
local city = phrase:match("город%s+(.+)") or phrase:match("city%s+(.+)")
if city then
city = city:gsub("^%s*(.-)%s*$", "%1") -- trim
-- save to state (shared with weather command)
jarvis.state.set("city", city)
local msg = lang == "ru"
and "Город установлен: " .. city
or "City set to: " .. city
jarvis.log("info", msg)
jarvis.system.notify("Jarvis", msg)
jarvis.audio.play_ok()
else
local msg = lang == "ru"
and "Не удалось определить город"
or "Could not determine city"
jarvis.log("warn", msg)
jarvis.audio.play_not_found()
end
return { chain = false }