From 12b1ed4ccb56dc38c9fb28f9370b230e378e948e Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 15:43:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(scheduler):=20IMBA-5=20proactive=20schedul?= =?UTF-8?q?er=20=E2=80=94=20reminders,=20daily=20briefings,=20intervals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background thread that wakes J.A.R.V.I.S. on a schedule to speak reminders. Nothing else competes here on desktop — Алиса/Сири are reactive-only. Core (crates/jarvis-core/src/scheduler.rs) - Schedule::{Daily{h,m}, Interval{secs}, Once{at}} with Schedule::parse for "daily HH:MM" / "at HH:MM" / "every N minutes|hours" / "in N minutes|hours". Russian units (час/часа/часов/минут/секунд) also accepted. - ScheduledTask {id, name, schedule, action, last_fired, enabled, created_at}. Action::Speak{text} | Action::Lua{script_path}. - JSON persistence at /schedule.json, atomic write-through. - add/remove/clear/list/find; mark_fired auto-deletes Once tasks. - start_background() spawns a 30-second tick thread (idempotent). Each tick calls due_tasks(), fires Action via tts::speak_default (after voices::play_reply "ahem" cue) or Lua engine. - 7 unit tests (all passing). Lua API (crates/jarvis-core/src/lua/api/scheduler.rs) - jarvis.scheduler.add({name, schedule, action={type, text|script_path}}) - jarvis.scheduler.{list, count, remove(id), clear}. - Tasks come back as {id, name, schedule_human, action, enabled, last_fired}. Wire-up (crates/jarvis-app/src/main.rs) - scheduler::init() + scheduler::start_background() after profiles::init. - Tasks survive restarts via schedule.json. Voice commands (resources/commands/scheduler/, 6 ids) - scheduler.add_reminder "напомни через 5 минут выключить кофеварку" - scheduler.add_at "напомни в 18:00 забрать ребёнка" - scheduler.add_recurring "каждые 2 часа напоминай попить воды" - scheduler.add_daily "каждый день в 9:00 делай briefing" - scheduler.list "что у меня запланировано" - scheduler.clear "очисти расписание" Russian-aware parsers (час/часа/часов, минут/минуту/минуты) live inside the Lua packs — easy to extend without touching Rust. Tests: 31/31 jarvis-core unit tests pass (24 prior + 7 scheduler). Build: cargo build --release -p jarvis-app and -p jarvis-gui both green. --- crates/jarvis-app/src/main.rs | 7 + crates/jarvis-core/src/lib.rs | 2 + crates/jarvis-core/src/lua/api.rs | 3 +- crates/jarvis-core/src/lua/api/scheduler.rs | 144 ++++++ crates/jarvis-core/src/lua/engine.rs | 1 + crates/jarvis-core/src/scheduler.rs | 449 ++++++++++++++++++ resources/commands/scheduler/add_at.lua | 58 +++ resources/commands/scheduler/add_daily.lua | 62 +++ .../commands/scheduler/add_recurring.lua | 74 +++ resources/commands/scheduler/add_reminder.lua | 102 ++++ resources/commands/scheduler/clear.lua | 8 + resources/commands/scheduler/command.toml | 109 +++++ resources/commands/scheduler/list.lua | 20 + 13 files changed, 1038 insertions(+), 1 deletion(-) create mode 100644 crates/jarvis-core/src/lua/api/scheduler.rs create mode 100644 crates/jarvis-core/src/scheduler.rs create mode 100644 resources/commands/scheduler/add_at.lua create mode 100644 resources/commands/scheduler/add_daily.lua create mode 100644 resources/commands/scheduler/add_recurring.lua create mode 100644 resources/commands/scheduler/add_reminder.lua create mode 100644 resources/commands/scheduler/clear.lua create mode 100644 resources/commands/scheduler/command.toml create mode 100644 resources/commands/scheduler/list.lua diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index 774e7b2..45c1475 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -73,6 +73,13 @@ fn main() -> Result<(), String> { warn!("Profiles init failed: {}", e); } + eprintln!("[jarvis-app] step: scheduler::init + background"); + if let Err(e) = jarvis_core::scheduler::init() { + warn!("Scheduler init failed: {}", e); + } else { + jarvis_core::scheduler::start_background(); + } + eprintln!("[jarvis-app] step: recorder::init"); if recorder::init().is_err() { notify_mic_problem(); diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs index cc420d7..61a7027 100644 --- a/crates/jarvis-core/src/lib.rs +++ b/crates/jarvis-core/src/lib.rs @@ -56,6 +56,8 @@ pub mod long_term_memory; pub mod profiles; +pub mod scheduler; + #[cfg(feature = "llm")] pub mod llm; diff --git a/crates/jarvis-core/src/lua/api.rs b/crates/jarvis-core/src/lua/api.rs index ba85045..9c5adf4 100644 --- a/crates/jarvis-core/src/lua/api.rs +++ b/crates/jarvis-core/src/lua/api.rs @@ -10,4 +10,5 @@ pub mod llm; pub mod text; pub mod memory; pub mod profile; -pub mod vision; \ No newline at end of file +pub mod vision; +pub mod scheduler; \ No newline at end of file diff --git a/crates/jarvis-core/src/lua/api/scheduler.rs b/crates/jarvis-core/src/lua/api/scheduler.rs new file mode 100644 index 0000000..6c52437 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/scheduler.rs @@ -0,0 +1,144 @@ +//! Lua bindings for the proactive scheduler. +//! +//! Usage: +//! jarvis.scheduler.add({ +//! name = "Daily briefing", +//! schedule = "daily 09:00", +//! action = { type = "speak", text = "Доброе утро. Готов к работе." } +//! }) +//! +//! jarvis.scheduler.add({ +//! name = "Reminder", +//! schedule = "in 5 minutes", +//! action = { type = "speak", text = "Выключи кофеварку." } +//! }) +//! +//! for _, t in ipairs(jarvis.scheduler.list()) do +//! print(t.id, t.name, t.schedule_human) +//! end +//! +//! jarvis.scheduler.remove(id) +//! jarvis.scheduler.clear() +//! +//! Schedule string syntax (see `scheduler::Schedule::parse`): +//! "daily HH:MM" | "at HH:MM" | "every N minutes" | "every N hours" | "in N minutes" | "in N hours" + +use mlua::{Lua, Table, Value}; + +use crate::scheduler::{self, Action, Schedule, ScheduledTask}; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let sched = lua.create_table()?; + + let add_fn = lua.create_function(|_, t: Table| { + let name: String = t.get::>("name")?.unwrap_or_else(|| "task".to_string()); + let schedule_str: String = t.get::("schedule")?; + let id: String = t.get::>("id")?.unwrap_or_default(); + + let action_tbl: Table = t.get::("action")?; + let action_type: String = action_tbl.get::("type")?; + let action = match action_type.as_str() { + "speak" => Action::Speak { + text: action_tbl.get::("text").unwrap_or_default(), + }, + "lua" => Action::Lua { + script_path: action_tbl.get::("script_path").unwrap_or_default(), + }, + other => return Err(mlua::Error::external(format!("unknown action type: {}", other))), + }; + + let schedule = Schedule::parse(&schedule_str) + .map_err(|e| mlua::Error::external(format!("bad schedule '{}': {}", schedule_str, e)))?; + + let task = ScheduledTask { + id, + name, + schedule, + action, + last_fired: None, + enabled: true, + created_at: 0, + }; + scheduler::add(task).map_err(mlua::Error::external) + })?; + sched.set("add", add_fn)?; + + let remove_fn = lua.create_function(|_, id: String| { + Ok(scheduler::remove(&id)) + })?; + sched.set("remove", remove_fn)?; + + let clear_fn = lua.create_function(|_, ()| { + Ok(scheduler::clear()) + })?; + sched.set("clear", clear_fn)?; + + let list_fn = lua.create_function(|lua, ()| { + let arr = lua.create_table()?; + for (i, t) in scheduler::list().iter().enumerate() { + arr.set(i + 1, task_to_lua(lua, t)?)?; + } + Ok(arr) + })?; + sched.set("list", list_fn)?; + + let count_fn = lua.create_function(|_, ()| { + Ok(scheduler::list().len()) + })?; + sched.set("count", count_fn)?; + + jarvis.set("scheduler", sched)?; + Ok(()) +} + +fn task_to_lua(lua: &Lua, t: &ScheduledTask) -> mlua::Result
{ + let tbl = lua.create_table()?; + tbl.set("id", t.id.clone())?; + tbl.set("name", t.name.clone())?; + tbl.set("enabled", t.enabled)?; + tbl.set("created_at", t.created_at)?; + tbl.set("schedule_human", schedule_human(&t.schedule))?; + + let last_fired: Value = match t.last_fired { + Some(ts) => Value::Integer(ts), + None => Value::Nil, + }; + tbl.set("last_fired", last_fired)?; + + let action_tbl = lua.create_table()?; + match &t.action { + Action::Speak { text } => { + action_tbl.set("type", "speak")?; + action_tbl.set("text", text.clone())?; + } + Action::Lua { script_path } => { + action_tbl.set("type", "lua")?; + action_tbl.set("script_path", script_path.clone())?; + } + } + tbl.set("action", action_tbl)?; + + Ok(tbl) +} + +fn schedule_human(s: &Schedule) -> String { + match s { + Schedule::Daily { hour, minute } => format!("каждый день в {:02}:{:02}", hour, minute), + Schedule::Interval { seconds } => { + if *seconds % 3600 == 0 { + format!("каждые {} часов", seconds / 3600) + } else if *seconds % 60 == 0 { + format!("каждые {} минут", seconds / 60) + } else { + format!("каждые {} секунд", seconds) + } + } + Schedule::Once { at } => { + chrono::DateTime::from_timestamp(*at, 0) + .map(|dt: chrono::DateTime| { + dt.with_timezone(&chrono::Local).format("один раз в %H:%M %d.%m").to_string() + }) + .unwrap_or_else(|| format!("один раз в {}", at)) + } + } +} diff --git a/crates/jarvis-core/src/lua/engine.rs b/crates/jarvis-core/src/lua/engine.rs index c34fd15..a580cf7 100644 --- a/crates/jarvis-core/src/lua/engine.rs +++ b/crates/jarvis-core/src/lua/engine.rs @@ -80,6 +80,7 @@ impl LuaEngine { api::text::register(&self.lua, &jarvis)?; api::memory::register(&self.lua, &jarvis)?; api::profile::register(&self.lua, &jarvis)?; + api::scheduler::register(&self.lua, &jarvis)?; // sandbox-controlled APIs if self.sandbox.allows_http() { diff --git a/crates/jarvis-core/src/scheduler.rs b/crates/jarvis-core/src/scheduler.rs new file mode 100644 index 0000000..d0ad36f --- /dev/null +++ b/crates/jarvis-core/src/scheduler.rs @@ -0,0 +1,449 @@ +//! IMBA-5: Proactive scheduler. +//! +//! Lets J.A.R.V.I.S. start the conversation on its own — fire reminders, run +//! a morning briefing routine at a fixed time, or nudge a habit at intervals. +//! No other Russian voice assistant on the desktop does this well. +//! +//! Storage: JSON at `/schedule.json`. Atomic write-through. +//! Background thread ticks every 60 seconds (default) and fires due tasks. +//! Tasks are deduplicated by id; same id replaces. +//! +//! Schedule formats (parsed by `Schedule::parse`): +//! - "daily HH:MM" e.g. "daily 09:00" +//! - "every N minutes" e.g. "every 30 minutes" +//! - "every N hours" e.g. "every 2 hours" +//! - "in N minutes" e.g. "in 5 minutes" (one-shot) +//! - "in N hours" e.g. "in 1 hours" (one-shot) +//! - "at HH:MM" (today only; one-shot) +//! +//! Action types: +//! - speak — TTS the message via `crate::tts::speak_default` +//! - lua — run a Lua script at the given path (sandboxed standard) +//! - command — emit IPC message to trigger a known command (TODO) +//! +//! Lua bindings (registered separately): `jarvis.scheduler.{add, list, remove, clear}`. + +use chrono::{Datelike, Local, Timelike}; +use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use crate::APP_CONFIG_DIR; + +const FILE_NAME: &str = "schedule.json"; +const TICK_INTERVAL_SECS: u64 = 30; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Schedule { + /// Fires every day at the given local hour:minute. + Daily { hour: u32, minute: u32 }, + /// Fires every `seconds` from `anchor` (last fire or task creation). + Interval { seconds: u64 }, + /// Fires once at the given UNIX timestamp (seconds), then disables itself. + Once { at: i64 }, +} + +impl Schedule { + pub fn parse(spec: &str) -> Result { + let s = spec.trim().to_lowercase(); + + // "daily HH:MM" + if let Some(rest) = s.strip_prefix("daily") { + return parse_hhmm(rest.trim()).map(|(h, m)| Schedule::Daily { hour: h, minute: m }); + } + // "at HH:MM" → Once today + if let Some(rest) = s.strip_prefix("at") { + let (h, m) = parse_hhmm(rest.trim())?; + let now = Local::now(); + let mut at = now + .with_hour(h) + .and_then(|t| t.with_minute(m)) + .and_then(|t| t.with_second(0)) + .ok_or_else(|| "invalid time".to_string())?; + if at <= now { + at = at + chrono::Duration::days(1); + } + return Ok(Schedule::Once { at: at.timestamp() }); + } + // "every N minutes" / "every N hours" + if let Some(rest) = s.strip_prefix("every") { + let parts: Vec<&str> = rest.trim().split_whitespace().collect(); + if parts.len() == 2 { + let n: u64 = parts[0].parse().map_err(|_| format!("bad number: {}", parts[0]))?; + let secs = match parts[1] { + "minute" | "minutes" | "минут" | "минуту" | "минуты" => n * 60, + "hour" | "hours" | "час" | "часа" | "часов" => n * 3600, + "second" | "seconds" | "секунд" | "секунду" | "секунды" => n, + other => return Err(format!("unknown unit: {}", other)), + }; + return Ok(Schedule::Interval { seconds: secs }); + } + } + // "in N minutes" / "in N hours" + if let Some(rest) = s.strip_prefix("in") { + let parts: Vec<&str> = rest.trim().split_whitespace().collect(); + if parts.len() == 2 { + let n: i64 = parts[0].parse().map_err(|_| format!("bad number: {}", parts[0]))?; + let secs = match parts[1] { + "minute" | "minutes" | "минут" | "минуту" | "минуты" => n * 60, + "hour" | "hours" | "час" | "часа" | "часов" => n * 3600, + "second" | "seconds" | "секунд" | "секунду" | "секунды" => n, + other => return Err(format!("unknown unit: {}", other)), + }; + let at = Local::now().timestamp() + secs; + return Ok(Schedule::Once { at }); + } + } + Err(format!("unknown schedule spec: {}", spec)) + } + + /// Returns the next UNIX timestamp this schedule should fire at, given the + /// most recent fire time (or task creation time). + pub fn next_fire(&self, last_fired: Option, now: i64) -> Option { + match self { + Schedule::Daily { hour, minute } => { + let today = Local::now() + .with_hour(*hour)? + .with_minute(*minute)? + .with_second(0)?; + let today_ts = today.timestamp(); + if today_ts > now && last_fired.map_or(true, |lf| { + chrono::DateTime::from_timestamp(lf, 0).map_or(true, |dt: chrono::DateTime| { + dt.with_timezone(&Local).day() != today.day() + }) + }) { + Some(today_ts) + } else { + Some(today_ts + 86400) + } + } + Schedule::Interval { seconds } => { + let anchor = last_fired.unwrap_or(now); + Some(anchor + *seconds as i64) + } + Schedule::Once { at } => { + if last_fired.is_some() { + None + } else { + Some(*at) + } + } + } + } +} + +fn parse_hhmm(s: &str) -> Result<(u32, u32), String> { + let mut it = s.splitn(2, ':'); + let h: u32 = it.next().ok_or("missing hour")?.parse() + .map_err(|_| "bad hour".to_string())?; + let m: u32 = it.next().ok_or("missing minute")?.parse() + .map_err(|_| "bad minute".to_string())?; + if h > 23 || m > 59 { + return Err("hour/minute out of range".into()); + } + Ok((h, m)) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Action { + /// Speak a plain text message via the active TTS backend. + Speak { text: String }, + /// Run a Lua script (absolute path). + Lua { script_path: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledTask { + pub id: String, + pub name: String, + pub schedule: Schedule, + pub action: Action, + #[serde(default)] + pub last_fired: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub created_at: i64, +} + +fn default_true() -> bool { true } + +#[derive(Default, Debug, Serialize, Deserialize)] +struct Store { + #[serde(default)] + tasks: Vec, +} + +struct State { + path: PathBuf, + store: RwLock, +} + +static STATE: OnceCell = OnceCell::new(); +static THREAD_RUNNING: AtomicBool = AtomicBool::new(false); + +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!("Scheduler loaded: {} tasks from {}", store.tasks.len(), path.display()); + STATE.set(State { path, store: RwLock::new(store) }) + .map_err(|_| "scheduler 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 schedule 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!("Schedule serialize failed: {}", e); return; } + }; + if let Err(e) = std::fs::write(&tmp, json) { + warn!("Schedule write failed: {}", e); return; + } + if let Err(e) = std::fs::rename(&tmp, &state.path) { + warn!("Schedule rename failed: {}", e); + } +} + +fn now_secs() -> i64 { + Local::now().timestamp() +} + +fn gen_id() -> String { + use std::time::SystemTime; + let micros = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros()) + .unwrap_or(0); + format!("task-{}", micros) +} + +/// Add (or replace by id) a scheduled task. +pub fn add(mut task: ScheduledTask) -> Result { + let state = STATE.get().ok_or_else(|| "scheduler not init".to_string())?; + if task.id.is_empty() { + task.id = gen_id(); + } + if task.created_at == 0 { + task.created_at = now_secs(); + } + { + let mut store = state.store.write(); + store.tasks.retain(|t| t.id != task.id); + store.tasks.push(task.clone()); + } + persist(state); + info!("Scheduler: added task {} ({:?})", task.id, task.schedule); + Ok(task.id) +} + +pub fn remove(id: &str) -> bool { + let Some(state) = STATE.get() else { return false; }; + let removed = { + let mut store = state.store.write(); + let before = store.tasks.len(); + store.tasks.retain(|t| t.id != id); + before != store.tasks.len() + }; + if removed { + persist(state); + info!("Scheduler: removed task {}", id); + } + removed +} + +pub fn clear() -> usize { + let Some(state) = STATE.get() else { return 0; }; + let n = { + let mut store = state.store.write(); + let n = store.tasks.len(); + store.tasks.clear(); + n + }; + persist(state); + info!("Scheduler: cleared {} tasks", n); + n +} + +pub fn list() -> Vec { + let Some(state) = STATE.get() else { return Vec::new(); }; + state.store.read().tasks.clone() +} + +pub fn find(id: &str) -> Option { + let state = STATE.get()?; + state.store.read().tasks.iter().find(|t| t.id == id).cloned() +} + +fn mark_fired(state: &State, id: &str, when: i64) -> bool { + let mut should_persist = false; + let mut should_remove = false; + { + let mut store = state.store.write(); + if let Some(t) = store.tasks.iter_mut().find(|t| t.id == id) { + t.last_fired = Some(when); + should_persist = true; + if matches!(t.schedule, Schedule::Once { .. }) { + should_remove = true; + } + } + if should_remove { + store.tasks.retain(|t| t.id != id); + } + } + if should_persist { + persist(state); + } + should_remove +} + +/// Compute due tasks (next_fire <= now and enabled). +fn due_tasks(state: &State, now: i64) -> Vec { + let store = state.store.read(); + store.tasks.iter() + .filter(|t| t.enabled) + .filter(|t| t.schedule.next_fire(t.last_fired, now).map_or(false, |nf| nf <= now)) + .cloned() + .collect() +} + +/// Start the background tick thread. Idempotent. +pub fn start_background() { + if THREAD_RUNNING.swap(true, Ordering::SeqCst) { + return; + } + std::thread::Builder::new() + .name("jarvis-scheduler".into()) + .spawn(|| { + info!("Scheduler thread started (tick every {}s).", TICK_INTERVAL_SECS); + loop { + std::thread::sleep(Duration::from_secs(TICK_INTERVAL_SECS)); + tick(); + } + }) + .ok(); +} + +fn tick() { + let Some(state) = STATE.get() else { return; }; + let now = now_secs(); + let due = due_tasks(state, now); + for task in due { + info!("Scheduler: firing {} '{}'", task.id, task.name); + fire_action(&task.action); + mark_fired(state, &task.id, now); + } +} + +fn fire_action(action: &Action) { + match action { + Action::Speak { text } => { + // Play "reply" sound first so the user knows it's the scheduler. + crate::voices::play_reply(); + std::thread::sleep(Duration::from_millis(400)); + crate::tts::speak_default(text); + } + Action::Lua { script_path } => { + #[cfg(feature = "lua")] + run_lua_script(script_path); + #[cfg(not(feature = "lua"))] + warn!("Lua feature disabled — cannot run {}", script_path); + } + } +} + +#[cfg(feature = "lua")] +fn run_lua_script(script_path: &str) { + use crate::lua::{LuaEngine, SandboxLevel, CommandContext}; + use std::path::Path; + let path = Path::new(script_path).to_path_buf(); + if !path.is_file() { + warn!("Scheduler: lua script not found: {}", path.display()); + return; + } + let engine = match LuaEngine::new(SandboxLevel::Standard) { + Ok(e) => e, + Err(e) => { warn!("Scheduler: lua engine init: {:?}", e); return; } + }; + let ctx = CommandContext { + phrase: String::new(), + command_id: "scheduler".into(), + command_path: path.parent().map(|p| p.to_path_buf()).unwrap_or_default(), + language: crate::i18n::get_language(), + slots: None, + }; + match engine.execute(&path, ctx, Duration::from_secs(30)) { + Ok(_) => {} + Err(e) => warn!("Scheduler: lua exec failed: {:?}", e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_daily() { + let s = Schedule::parse("daily 09:00").unwrap(); + assert_eq!(s, Schedule::Daily { hour: 9, minute: 0 }); + } + + #[test] + fn parse_interval_minutes() { + let s = Schedule::parse("every 30 minutes").unwrap(); + assert_eq!(s, Schedule::Interval { seconds: 1800 }); + } + + #[test] + fn parse_interval_russian() { + let s = Schedule::parse("every 2 часа").unwrap(); + assert_eq!(s, Schedule::Interval { seconds: 7200 }); + } + + #[test] + fn parse_in_minutes() { + let s = Schedule::parse("in 5 minutes").unwrap(); + matches!(s, Schedule::Once { .. }); + } + + #[test] + fn parse_bad_unit() { + assert!(Schedule::parse("every 5 weeks").is_err()); + } + + #[test] + fn interval_next_fire() { + let s = Schedule::Interval { seconds: 60 }; + assert_eq!(s.next_fire(Some(100), 1000), Some(160)); + } + + #[test] + fn once_disables_after_fire() { + let s = Schedule::Once { at: 500 }; + assert_eq!(s.next_fire(None, 1000), Some(500)); + assert_eq!(s.next_fire(Some(500), 1000), None); + } +} diff --git a/resources/commands/scheduler/add_at.lua b/resources/commands/scheduler/add_at.lua new file mode 100644 index 0000000..63bef04 --- /dev/null +++ b/resources/commands/scheduler/add_at.lua @@ -0,0 +1,58 @@ +-- "напомни в 18:00 забрать ребёнка" +local phrase = (jarvis.context.phrase or ""):lower() + +local body = jarvis.text.strip_trigger(phrase, { + "напомни сегодня в", + "напомни в", + "разбуди в", + "поставь напоминалку на", + "remind me at", + "wake me at", + "нагадай о", + "розбуди о", +}) + +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +-- Parse "HH:MM " or "HH MM " +local h, m, rest = body:match("^(%d%d?)%s*[:%-%s]%s*(%d%d?)%s*(.*)$") +if not h then + h, rest = body:match("^(%d%d?)%s*(.*)$") + if not h then + jarvis.speak("Не понял время.") + jarvis.audio.play_error() + return { chain = false } + end + m = "00" +end +m = m or "00" + +local hn = tonumber(h) +local mn = tonumber(m) +if hn < 0 or hn > 23 or mn < 0 or mn > 59 then + jarvis.speak("Время вне диапазона.") + jarvis.audio.play_error() + return { chain = false } +end + +local schedule = string.format("at %02d:%02d", hn, mn) +local text_body = (rest or ""):gsub("^[%s,:%.]+", ""):gsub("%s+$", "") +if text_body == "" then text_body = "Напоминание." end + +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = "Reminder", + schedule = schedule, + action = { type = "speak", text = text_body } + }) +end) +if not ok then + jarvis.log("warn", "scheduler.add: " .. tostring(err)) + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak(string.format("Напомню в %02d:%02d.", hn, mn)) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/scheduler/add_daily.lua b/resources/commands/scheduler/add_daily.lua new file mode 100644 index 0000000..bb528b4 --- /dev/null +++ b/resources/commands/scheduler/add_daily.lua @@ -0,0 +1,62 @@ +-- "каждый день в 9:00 делай briefing" / "каждое утро в 8 напоминай зарядку" +local phrase = (jarvis.context.phrase or ""):lower() + +local body = jarvis.text.strip_trigger(phrase, { + "каждое утро в", + "каждый день в", + "ежедневно в", + "по утрам в", + "every day at", + "daily at", + "щодня о", + "кожен день о", +}) + +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +-- Parse "HH:MM " or "HH " +local h, m, rest = body:match("^(%d%d?)%s*[:%-]%s*(%d%d?)%s*(.*)$") +if not h then + h, rest = body:match("^(%d%d?)%s*(.*)$") + m = "00" +end + +if not h then + jarvis.speak("Не понял время.") + jarvis.audio.play_error() + return { chain = false } +end + +local hn = tonumber(h) +local mn = tonumber(m) or 0 +if hn < 0 or hn > 23 or mn < 0 or mn > 59 then + jarvis.speak("Время вне диапазона.") + jarvis.audio.play_error() + return { chain = false } +end + +local schedule = string.format("daily %02d:%02d", hn, mn) +local text_body = (rest or ""):gsub("^напоминай%s+", "") + :gsub("^напомни%s+", "") + :gsub("^делай%s+", "") + :gsub("^[%s,:%.]+", "") + :gsub("%s+$", "") +if text_body == "" then text_body = "Доброе утро." end + +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = "Daily", + schedule = schedule, + action = { type = "speak", text = text_body } + }) +end) +if not ok then + jarvis.log("warn", "scheduler.add: " .. tostring(err)) + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak(string.format("Каждый день в %02d:%02d.", hn, mn)) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/scheduler/add_recurring.lua b/resources/commands/scheduler/add_recurring.lua new file mode 100644 index 0000000..92ee1d3 --- /dev/null +++ b/resources/commands/scheduler/add_recurring.lua @@ -0,0 +1,74 @@ +-- "каждые 2 часа напоминай попить воды" / "каждый час напоминай размяться" +local phrase = (jarvis.context.phrase or ""):lower() + +local body = jarvis.text.strip_trigger(phrase, { + "напоминай каждые", + "напоминай каждый", + "каждые", + "каждый час", + "remind me every", + "every", + "кожні", + "нагадуй кожні", +}) + +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +-- Parse " " or "час " (implicit 1) +local n_str, unit, rest = body:match("^(%d+)%s+(%S+)%s*(.*)$") +local n, unit_norm, sample + +if n_str then + n = tonumber(n_str) + if unit:match("^минут") or unit:match("^minute") then + unit_norm = "minutes" + sample = n .. " минут" + elseif unit:match("^час") or unit:match("^hour") then + unit_norm = "hours" + sample = n .. " час" .. (n == 1 and "" or (n < 5 and "а" or "ов")) + else + jarvis.speak("Не понял единицу.") + jarvis.audio.play_error() + return { chain = false } + end +else + -- "каждый час напоминай..." + local single, rest2 = body:match("^(%a+)%s*(.*)$") + if single and (single == "час" or single == "минуту" or single == "минута") then + n = 1 + unit_norm = (single == "час") and "hours" or "minutes" + sample = (single == "час") and "час" or "минуту" + rest = rest2 + else + jarvis.speak("Не понял через сколько повторять.") + jarvis.audio.play_error() + return { chain = false } + end +end + +-- Strip optional reminder verb +local text_body = (rest or ""):gsub("^напоминай%s+", "") + :gsub("^напомни%s+", "") + :gsub("^remind me%s+", "") + :gsub("^[%s,:%.]+", "") + :gsub("%s+$", "") +if text_body == "" then text_body = "Напоминание." end + +local schedule = string.format("every %d %s", n, unit_norm) +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = "Recurring", + schedule = schedule, + action = { type = "speak", text = text_body } + }) +end) +if not ok then + jarvis.log("warn", "scheduler.add: " .. tostring(err)) + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak("Буду напоминать каждые " .. sample .. ".") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/scheduler/add_reminder.lua b/resources/commands/scheduler/add_reminder.lua new file mode 100644 index 0000000..4d47b9c --- /dev/null +++ b/resources/commands/scheduler/add_reminder.lua @@ -0,0 +1,102 @@ +-- "напомни мне через 5 минут выключить кофеварку" +local phrase = (jarvis.context.phrase or ""):lower() + +-- Strip the trigger +local body = jarvis.text.strip_trigger(phrase, { + "напомни мне через", + "напомни через", + "поставь напоминалку через", + "поставь напоминание через", + "разбуди через", + "remind me in", + "set a reminder in", + "нагадай через", + "постав нагадування через", +}) + +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if body == "" then + jarvis.speak("Через сколько и про что напомнить?") + jarvis.audio.play_error() + return { chain = false } +end + +-- Parse " " +-- N can be digit or word (один, два, ...) — for v1 only digits +local n_str, unit, rest = body:match("^(%d+)%s+(%S+)%s*(.*)$") +if not n_str then + -- maybe "час" / "минуту" (singular) + local single_unit, rest2 = body:match("^([%a]+)%s*(.*)$") + if single_unit then + local map = { + ["час"] = "1 hours", + ["минуту"] = "1 minutes", + ["секунду"] = "1 seconds", + } + if map[single_unit] then + local schedule = "in " .. map[single_unit] + local text_body = rest2:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + if text_body == "" then text_body = "Напоминание." end + local ok, err = pcall(function() + jarvis.scheduler.add({ + name = "Reminder", + schedule = schedule, + action = { type = "speak", text = text_body } + }) + end) + if not ok then + jarvis.log("warn", "scheduler.add: " .. tostring(err)) + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } + end + jarvis.speak("Напомню через " .. single_unit .. ".") + jarvis.audio.play_ok() + return { chain = false } + end + end + jarvis.speak("Не понял через сколько. Скажите например: через 5 минут.") + jarvis.audio.play_error() + return { chain = false } +end + +local n = tonumber(n_str) +local unit_norm +local sample +if unit:match("^минут") or unit == "минуты" or unit == "минуту" or unit:match("^minute") then + unit_norm = "minutes" + sample = n .. " минут" +elseif unit:match("^час") or unit:match("^hour") then + unit_norm = "hours" + sample = n .. " час" .. (n == 1 and "" or (n < 5 and "а" or "ов")) +elseif unit:match("^секунд") or unit:match("^second") then + unit_norm = "seconds" + sample = n .. " секунд" +else + jarvis.speak("Не понял единицу. Минуты или часы.") + jarvis.audio.play_error() + return { chain = false } +end + +local schedule = string.format("in %d %s", n, unit_norm) +local text_body = rest:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") +if text_body == "" then text_body = "Напоминание." end + +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = "Reminder", + schedule = schedule, + action = { type = "speak", text = text_body } + }) +end) +if not ok then + jarvis.log("warn", "scheduler.add: " .. tostring(err)) + jarvis.speak("Не получилось добавить напоминание.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak("Напомню через " .. sample .. ".") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/scheduler/clear.lua b/resources/commands/scheduler/clear.lua new file mode 100644 index 0000000..9ff3cd6 --- /dev/null +++ b/resources/commands/scheduler/clear.lua @@ -0,0 +1,8 @@ +local n = jarvis.scheduler.clear() +if n == 0 then + jarvis.speak("В расписании и так пусто.") +else + jarvis.speak(string.format("Удалил %d задач.", n)) +end +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/scheduler/command.toml b/resources/commands/scheduler/command.toml new file mode 100644 index 0000000..29a8b08 --- /dev/null +++ b/resources/commands/scheduler/command.toml @@ -0,0 +1,109 @@ +# IMBA-5: Proactive scheduler — recurring reminders, daily briefings, intervals. + +[[commands]] +id = "scheduler.add_reminder" +type = "lua" +script = "add_reminder.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "напомни мне через", "напомни через", + "поставь напоминалку через", "поставь напоминание через", + "разбуди через", +] +en = ["remind me in", "set a reminder in"] +ua = ["нагадай через", "постав нагадування через"] + + +[[commands]] +id = "scheduler.add_at" +type = "lua" +script = "add_at.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "напомни в", + "напомни сегодня в", + "разбуди в", + "поставь напоминалку на", +] +en = ["remind me at", "wake me at"] +ua = ["нагадай о", "розбуди о"] + + +[[commands]] +id = "scheduler.add_recurring" +type = "lua" +script = "add_recurring.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "каждые", + "каждый час", + "каждые два часа", + "напоминай каждые", + "напоминай каждый", +] +en = ["every", "remind me every"] +ua = ["кожні", "нагадуй кожні"] + + +[[commands]] +id = "scheduler.add_daily" +type = "lua" +script = "add_daily.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "каждый день в", + "каждое утро в", + "ежедневно в", + "по утрам в", +] +en = ["every day at", "daily at"] +ua = ["щодня о", "кожен день о"] + + +[[commands]] +id = "scheduler.list" +type = "lua" +script = "list.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "что у меня запланировано", + "какие напоминания", + "покажи расписание", + "какие задачи у меня", + "что в расписании", +] +en = ["what's on my schedule", "show schedule", "list reminders"] +ua = ["що в розкладі", "покажи розклад"] + + +[[commands]] +id = "scheduler.clear" +type = "lua" +script = "clear.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "очисти расписание", + "удали все напоминания", + "отмени все напоминания", + "сбрось расписание", +] +en = ["clear schedule", "cancel all reminders"] +ua = ["очисти розклад", "скасуй всі нагадування"] diff --git a/resources/commands/scheduler/list.lua b/resources/commands/scheduler/list.lua new file mode 100644 index 0000000..2410a57 --- /dev/null +++ b/resources/commands/scheduler/list.lua @@ -0,0 +1,20 @@ +local tasks = jarvis.scheduler.list() +if #tasks == 0 then + jarvis.speak("Расписание пустое.") + jarvis.audio.play_ok() + return { chain = false } +end + +local sample = math.min(#tasks, 5) +local line = string.format("Запланировано %d. ", #tasks) +for i = 1, sample do + local t = tasks[i] + local what = (t.action and t.action.text) or t.name or "задача" + line = line .. t.schedule_human .. ": " .. what + if i < sample then line = line .. ". " end +end +line = line .. "." + +jarvis.speak(line) +jarvis.audio.play_ok() +return { chain = false }