remove: deprecated QuickJS runtime and associated libraries

- Deleted `bootstrap.js`, `qjs_ops`, and `mod.rs` from QuickJS runtime module.
- Removed unused QuickJS-related code, including APIs for browser-like shims, IndexedDB, WebSocket, TDLib, and OAuth.
- Simplified project structure by eliminating redundant QuickJS dependencies.
This commit is contained in:
cyrus
2026-03-17 23:09:37 +05:30
parent bdb9e407a8
commit 8eaf682d83
11 changed files with 2 additions and 3056 deletions
+2 -2
View File
@@ -191,7 +191,7 @@ impl QjsSkillInstance {
}
// Load bootstrap
let bootstrap_code = include_str!("../services/quickjs-libs/bootstrap.js");
let bootstrap_code = include_str!("../services/quickjs_libs/bootstrap.js");
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bootstrap_code) {
let detail = format_js_exception(&js_ctx, &e);
return Err(format!("Bootstrap failed: {detail}"));
@@ -383,7 +383,7 @@ async fn run_event_loop(
let new_map: HashMap<String, serde_json::Value> = ops
.data
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.map(|(k, v): (&String, &serde_json::Value)| (k.clone(), v.clone()))
.collect();
state.write().published_state = new_map.clone();
File diff suppressed because it is too large Load Diff
@@ -1,13 +0,0 @@
//! TDLib Runtime Module
//!
//! Provides a QuickJS JavaScript runtime (via rquickjs) for running
//! skill JavaScript code and TDLib integration. Provides a browser-like
//! environment for skill execution.
pub mod qjs_ops;
pub mod service;
pub mod storage;
#[allow(unused_imports)]
pub use service::{TdClientAdapter, TdClientConfig, TdUpdate, TdlibV8Service};
pub use storage::IdbStorage;
@@ -1,48 +0,0 @@
//! QuickJS native ops registered on `globalThis.__ops`.
//!
//! Split by category for readability:
//! - `types` — shared state structs, constants, helpers
//! - `ops_core` — console, crypto, performance, platform, timers
//! - `ops_net` — fetch, WebSocket, net bridge
//! - `ops_storage` — IndexedDB, DB bridge, Store bridge
//! - `ops_state` — published state, filesystem data
//! - `ops_tdlib` — TDLib (Telegram) integration
mod ops_core;
mod ops_net;
mod ops_state;
mod ops_storage;
mod ops_tdlib;
pub mod types;
// Re-export public API used by qjs_skill_instance.rs
pub use types::{poll_timers, SkillContext, SkillState, TimerState, WebSocketState};
use parking_lot::RwLock;
use rquickjs::{Ctx, Object, Result as JsResult};
use std::sync::Arc;
use crate::services::quickjs_libs::storage::IdbStorage;
use types::SkillContext as SC;
/// Register all ops on `globalThis.__ops`.
pub fn register_ops(
ctx: &Ctx<'_>,
storage: IdbStorage,
skill_context: SC,
skill_state: Arc<RwLock<SkillState>>,
timer_state: Arc<RwLock<TimerState>>,
ws_state: Arc<RwLock<WebSocketState>>,
) -> JsResult<()> {
let globals = ctx.globals();
let ops = Object::new(ctx.clone())?;
ops_core::register(ctx, &ops, timer_state)?;
ops_net::register(ctx, &ops, ws_state)?;
ops_storage::register(ctx, &ops, storage, skill_context.clone())?;
ops_state::register(ctx, &ops, skill_state, skill_context.clone())?;
ops_tdlib::register(ctx, &ops, skill_context.clone())?;
globals.set("__ops", ops)?;
Ok(())
}
@@ -1,116 +0,0 @@
//! Core ops: console, crypto, performance, platform, timers.
use parking_lot::RwLock;
use rquickjs::{Ctx, Function, Object};
use std::sync::Arc;
use std::time::{Duration, Instant};
use super::types::{TimerEntry, TimerState, ALLOWED_ENV_VARS};
pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, timer_state: Arc<RwLock<TimerState>>) -> rquickjs::Result<()> {
// ========================================================================
// Console (3)
// ========================================================================
ops.set("console_log", Function::new(ctx.clone(), |msg: String| {
log::info!("[js] {}", msg);
}))?;
ops.set("console_warn", Function::new(ctx.clone(), |msg: String| {
log::warn!("[js] {}", msg);
}))?;
ops.set("console_error", Function::new(ctx.clone(), |msg: String| {
log::error!("[js] {}", msg);
}))?;
// ========================================================================
// Crypto (3)
// ========================================================================
ops.set("crypto_random", Function::new(ctx.clone(), |len: usize| -> Vec<u8> {
use rand::RngCore;
let mut buf = vec![0u8; len];
rand::thread_rng().fill_bytes(&mut buf);
buf
}))?;
ops.set("atob", Function::new(ctx.clone(), |input: String| -> rquickjs::Result<String> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&input)
.map_err(|e| super::types::js_err(e.to_string()))?;
String::from_utf8(bytes).map_err(|e| super::types::js_err(e.to_string()))
}))?;
ops.set("btoa", Function::new(ctx.clone(), |input: String| -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(input.as_bytes())
}))?;
// ========================================================================
// Performance (1)
// ========================================================================
ops.set("performance_now", Function::new(ctx.clone(), || -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64()
* 1000.0
}))?;
// ========================================================================
// Platform (2)
// ========================================================================
ops.set("platform_os", Function::new(ctx.clone(), || -> &'static str {
if cfg!(target_os = "windows") { "windows" }
else if cfg!(target_os = "macos") { "macos" }
else if cfg!(target_os = "linux") { "linux" }
else if cfg!(target_os = "android") { "android" }
else if cfg!(target_os = "ios") { "ios" }
else { "unknown" }
}))?;
ops.set("platform_env", Function::new(ctx.clone(), |key: String| -> Option<String> {
if ALLOWED_ENV_VARS.contains(&key.as_str()) {
std::env::var(&key).ok()
} else {
None
}
}))?;
ops.set("get_session_token", Function::new(ctx.clone(), || -> String {
let token = crate::commands::auth::SESSION_SERVICE.get_token().unwrap_or_default();
return token;
}))?;
// ========================================================================
// Timers (2)
// ========================================================================
{
let ts = timer_state.clone();
ops.set("timer_start", Function::new(ctx.clone(),
move |id: u32, delay_ms: u32, is_interval: bool| {
let mut state = ts.write();
state.timers.insert(id, TimerEntry {
deadline: Instant::now() + Duration::from_millis(delay_ms as u64),
delay_ms,
is_interval,
});
},
))?;
}
{
let ts = timer_state;
ops.set("timer_cancel", Function::new(ctx.clone(), move |id: u32| {
let mut state = ts.write();
state.timers.remove(&id);
}))?;
}
Ok(())
}
@@ -1,183 +0,0 @@
//! Network ops: fetch, WebSocket, net bridge.
use parking_lot::RwLock;
use rquickjs::{function::Async, Ctx, Function, Object};
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use super::types::{js_err, WebSocketConnection, WebSocketState};
/// Shared HTTP client — built once, reused across all fetch calls.
/// Using a shared client enables connection pooling, persistent TLS sessions,
/// and prevents per-request TLS handshake overhead that can cause hangs.
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
fn get_http_client() -> &'static reqwest::Client {
HTTP_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.use_rustls_tls()
.connect_timeout(std::time::Duration::from_secs(10))
.pool_idle_timeout(std::time::Duration::from_secs(90))
.pool_max_idle_per_host(10)
.build()
.expect("failed to build shared HTTP client")
})
}
pub fn register<'js>(
ctx: &Ctx<'js>,
ops: &Object<'js>,
ws_state: Arc<RwLock<WebSocketState>>,
) -> rquickjs::Result<()> {
// ========================================================================
// Fetch (1) - ASYNC
// ========================================================================
ops.set(
"fetch",
Function::new(
ctx.clone(),
Async(move |url: String, options: String| async move {
let opts: serde_json::Value =
serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?;
let method = opts["method"].as_str().unwrap_or("GET");
let headers_obj = opts["headers"].as_object();
let body = opts["body"].as_str();
let timeout_secs = opts["timeout"]
.as_u64()
.or_else(|| opts["timeout"].as_f64().map(|f| f as u64))
.unwrap_or(30);
let client = get_http_client();
let mut req = match method {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"PATCH" => client.patch(&url),
"DELETE" => client.delete(&url),
_ => client.get(&url),
};
req = req.timeout(std::time::Duration::from_secs(timeout_secs));
if let Some(h) = headers_obj {
for (k, v) in h {
if let Some(val_str) = v.as_str() {
req = req.header(k, val_str);
}
}
}
if let Some(b) = body {
req = req.body(b.to_string());
}
// Hard safety net: tokio timeout wraps send+body read so a stalled
// connection cannot block the QuickJS event loop indefinitely even
// if the per-request timeout on the reqwest builder fails to fire.
let total_deadline = std::time::Duration::from_secs(timeout_secs + 5);
let response = tokio::time::timeout(total_deadline, req.send())
.await
.map_err(|_| js_err(format!("request timed out after {}s", timeout_secs + 5)))?
.map_err(|e| {
let mut msg = e.to_string();
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
msg.push_str(&format!(" | caused by: {cause}"));
source = std::error::Error::source(cause);
}
js_err(msg)
})?;
let status = response.status().as_u16();
let status_text = response
.status()
.canonical_reason()
.unwrap_or("")
.to_string();
let headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body_text = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs + 5),
response.text(),
)
.await
.map_err(|_| js_err(format!("body read timed out after {}s", timeout_secs + 5)))?
.map_err(|e| js_err(e.to_string()))?;
let result = serde_json::json!({
"status": status,
"statusText": status_text,
"headers": headers,
"body": body_text,
});
Ok::<String, rquickjs::Error>(result.to_string())
}),
),
)?;
// ========================================================================
// WebSocket (4) - placeholders
// ========================================================================
{
let ws = ws_state.clone();
ops.set(
"ws_connect",
Function::new(
ctx.clone(),
Async(move |url: String| {
let ws = ws.clone();
async move {
let mut state = ws.write();
let id = state.next_id;
state.next_id += 1;
state.connections.insert(id, WebSocketConnection { url });
Ok::<u32, rquickjs::Error>(id)
}
}),
),
)?;
}
{
let ws = ws_state.clone();
ops.set(
"ws_send",
Function::new(ctx.clone(), move |_id: u32, _data: String| {
let _state = ws.read();
}),
)?;
}
{
let ws = ws_state.clone();
ops.set(
"ws_recv",
Function::new(
ctx.clone(),
Async(move |_id: u32| {
let _ws = ws.clone();
async move { Ok::<Option<String>, rquickjs::Error>(None) }
}),
),
)?;
}
{
let ws = ws_state;
ops.set(
"ws_close",
Function::new(ctx.clone(), move |id: u32, _code: u16, _reason: String| {
let mut state = ws.write();
state.connections.remove(&id);
}),
)?;
}
Ok(())
}
@@ -1,85 +0,0 @@
//! State and data ops: published state get/set, filesystem data read/write.
use parking_lot::RwLock;
use rquickjs::{Ctx, Function, Object};
use std::sync::Arc;
use super::types::{js_err, SkillContext, SkillState};
pub fn register<'js>(
ctx: &Ctx<'js>,
ops: &Object<'js>,
skill_state: Arc<RwLock<SkillState>>,
skill_context: SkillContext,
) -> rquickjs::Result<()> {
// ========================================================================
// State Bridge (3)
// ========================================================================
{
let ss = skill_state.clone();
ops.set("state_get", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let state = ss.read();
let value = state.data.get(&key).cloned().unwrap_or(serde_json::Value::Null);
serde_json::to_string(&value).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let ss = skill_state.clone();
ops.set("state_set", Function::new(ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
let mut state = ss.write();
state.data.insert(key, value);
state.dirty = true;
Ok(())
},
))?;
}
{
let ss = skill_state;
ops.set("state_set_partial", Function::new(ctx.clone(),
move |partial_json: String| -> rquickjs::Result<()> {
let partial: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&partial_json).map_err(|e| js_err(e.to_string()))?;
let mut state = ss.write();
for (k, v) in partial {
state.data.insert(k, v);
}
state.dirty = true;
Ok(())
},
))?;
}
// ========================================================================
// Data Bridge (2)
// ========================================================================
{
let sc = skill_context.clone();
ops.set("data_read", Function::new(ctx.clone(),
move |filename: String| -> rquickjs::Result<String> {
let path = sc.data_dir.join(&filename);
std::fs::read_to_string(&path).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let sc = skill_context;
ops.set("data_write", Function::new(ctx.clone(),
move |filename: String, content: String| -> rquickjs::Result<()> {
let path = sc.data_dir.join(&filename);
std::fs::write(&path, content).map_err(|e| js_err(e.to_string()))
},
))?;
}
Ok(())
}
@@ -1,260 +0,0 @@
//! Storage ops: IndexedDB, DB bridge, Store bridge.
use rquickjs::{Ctx, Function, Object};
use super::types::{js_err, SkillContext};
use crate::services::quickjs_libs::storage::IdbStorage;
pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, storage: IdbStorage, skill_context: SkillContext) -> rquickjs::Result<()> {
// ========================================================================
// IndexedDB (11) - all sync
// ========================================================================
{
let s = storage.clone();
ops.set("idb_open", Function::new(ctx.clone(),
move |name: String, version: u32| -> rquickjs::Result<String> {
let result = s.open_database(&name, version).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_close", Function::new(ctx.clone(), move |name: String| {
s.close_database(&name);
}))?;
}
{
let s = storage.clone();
ops.set("idb_delete_database", Function::new(ctx.clone(),
move |name: String| -> rquickjs::Result<()> {
s.delete_database(&name).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_create_object_store", Function::new(ctx.clone(),
move |db_name: String, store_name: String, options: String| -> rquickjs::Result<()> {
let opts: serde_json::Value =
serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?;
let key_path = opts["keyPath"].as_str();
let auto_increment = opts["autoIncrement"].as_bool().unwrap_or(false);
s.create_object_store(&db_name, &store_name, key_path, auto_increment)
.map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_delete_object_store", Function::new(ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<()> {
s.delete_object_store(&db_name, &store_name).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_get", Function::new(ctx.clone(),
move |db_name: String, store_name: String, key: String| -> rquickjs::Result<String> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
let result = s.get(&db_name, &store_name, &key_val).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_put", Function::new(ctx.clone(),
move |db_name: String, store_name: String, key: String, value: String| -> rquickjs::Result<()> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
let value_val: serde_json::Value =
serde_json::from_str(&value).map_err(|e| js_err(e.to_string()))?;
s.put(&db_name, &store_name, &key_val, &value_val).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_delete", Function::new(ctx.clone(),
move |db_name: String, store_name: String, key: String| -> rquickjs::Result<()> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
s.delete(&db_name, &store_name, &key_val).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_clear", Function::new(ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<()> {
s.clear(&db_name, &store_name).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_get_all", Function::new(ctx.clone(),
move |db_name: String, store_name: String, count: Option<u32>| -> rquickjs::Result<String> {
let result = s.get_all(&db_name, &store_name, count).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_get_all_keys", Function::new(ctx.clone(),
move |db_name: String, store_name: String, count: Option<u32>| -> rquickjs::Result<String> {
let result = s.get_all_keys(&db_name, &store_name, count).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
ops.set("idb_count", Function::new(ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<u32> {
s.count(&db_name, &store_name).map_err(|e| js_err(e))
},
))?;
}
// ========================================================================
// DB Bridge (5)
// ========================================================================
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_exec", Function::new(ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<i64> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let rows = s.skill_db_exec(&sc.skill_id, &sql, &params).map_err(|e| js_err(e))?;
Ok(rows as i64)
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_get", Function::new(ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<String> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let result = s.skill_db_get(&sc.skill_id, &sql, &params).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_all", Function::new(ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<String> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let result = s.skill_db_all(&sc.skill_id, &sql, &params).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_kv_get", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let result = s.skill_kv_get(&sc.skill_id, &key).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_kv_set", Function::new(ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
s.skill_kv_set(&sc.skill_id, &key, &value).map_err(|e| js_err(e))
},
))?;
}
// ========================================================================
// Store Bridge (4)
// ========================================================================
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("store_get", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let result = s.skill_store_get(&sc.skill_id, &key).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("store_set", Function::new(ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
s.skill_store_set(&sc.skill_id, &key, &value).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("store_delete", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<()> {
s.skill_store_delete(&sc.skill_id, &key).map_err(|e| js_err(e))
},
))?;
}
{
let s = storage;
let sc = skill_context;
ops.set("store_keys", Function::new(ctx.clone(),
move || -> rquickjs::Result<String> {
let keys = s.skill_store_keys(&sc.skill_id).map_err(|e| js_err(e))?;
serde_json::to_string(&keys).map_err(|e| js_err(e.to_string()))
},
))?;
}
Ok(())
}
@@ -1,161 +0,0 @@
//! Shared types, state structs, and helpers for QuickJS ops.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};
// ============================================================================
// Timer State
// ============================================================================
#[derive(Debug)]
pub struct TimerEntry {
pub deadline: Instant,
pub delay_ms: u32,
pub is_interval: bool,
}
#[derive(Debug, Default)]
pub struct TimerState {
pub timers: HashMap<u32, TimerEntry>,
}
impl TimerState {
pub fn poll_ready(&mut self) -> Vec<u32> {
let now = Instant::now();
let mut ready = Vec::new();
let mut to_remove = Vec::new();
for (&id, entry) in &self.timers {
if now >= entry.deadline {
ready.push(id);
if !entry.is_interval {
to_remove.push(id);
}
}
}
for id in to_remove {
self.timers.remove(&id);
}
for &id in &ready {
if let Some(entry) = self.timers.get_mut(&id) {
if entry.is_interval {
entry.deadline = now + Duration::from_millis(entry.delay_ms as u64);
}
}
}
ready
}
pub fn time_until_next(&self) -> Option<Duration> {
let now = Instant::now();
self.timers
.values()
.map(|e| e.deadline.saturating_duration_since(now))
.min()
}
}
pub fn poll_timers(timer_state: &RwLock<TimerState>) -> (Vec<u32>, Option<Duration>) {
let mut ts = timer_state.write();
let ready = ts.poll_ready();
let next = ts.time_until_next();
(ready, next)
}
// ============================================================================
// Skill Context
// ============================================================================
#[derive(Clone)]
pub struct SkillContext {
pub skill_id: String,
pub data_dir: PathBuf,
}
// ============================================================================
// Skill State (shared published state)
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillState {
#[serde(flatten)]
pub data: serde_json::Map<String, serde_json::Value>,
/// Set to true when data is modified; the event loop clears it after syncing.
#[serde(skip)]
pub dirty: bool,
}
impl Default for SkillState {
fn default() -> Self {
Self {
data: serde_json::Map::new(),
dirty: false,
}
}
}
// ============================================================================
// WebSocket State (placeholder)
// ============================================================================
#[derive(Debug)]
pub struct WebSocketConnection {
pub url: String,
}
#[derive(Debug, Default)]
pub struct WebSocketState {
pub connections: HashMap<u32, WebSocketConnection>,
pub next_id: u32,
}
// ============================================================================
// Constants & Helpers
// ============================================================================
pub const ALLOWED_ENV_VARS: &[&str] = &[
"VITE_BACKEND_URL",
"BACKEND_URL",
"JWT_TOKEN",
"VITE_TELEGRAM_BOT_USERNAME",
"VITE_TELEGRAM_BOT_ID",
"NODE_ENV",
];
pub fn check_telegram_skill(skill_id: &str) -> Result<(), String> {
if skill_id != "telegram" {
Err("TDLib operations only available in telegram skill".to_string())
} else {
Ok(())
}
}
/// Sanitize error message for use with QuickJS/rquickjs.
/// Some messages (e.g. from SQLite "Invalid symbol 45, offset 19") can trigger
/// "Invalid symbol" when rquickjs creates the JS exception — avoid comma and hyphen.
fn sanitize_error_message(msg: &str) -> String {
msg.chars()
.map(|c| {
if c == ',' || c == '-' {
' '
} else if c.is_ascii() && !c.is_ascii_control() {
c
} else if c == '\n' || c == '\r' || c == '\t' {
' '
} else {
'?'
}
})
.collect()
}
pub fn js_err(msg: impl AsRef<str>) -> rquickjs::Error {
let sanitized = sanitize_error_message(msg.as_ref());
rquickjs::Error::new_from_js_message("skill", "RuntimeError", sanitized)
}
@@ -1,457 +0,0 @@
//! TdlibV8Service — High-level TDLib service using V8 runtime.
//!
//! Manages TDLib client instances running in V8 with tdweb.
//! Provides async send/receive interface and update broadcasting.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, mpsc, oneshot};
use super::storage::IdbStorage;
/// Configuration for a TDLib client.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct TdClientConfig {
/// API ID from my.telegram.org
pub api_id: i32,
/// API hash from my.telegram.org
pub api_hash: String,
/// Database directory name
pub database_directory: String,
/// Files directory name
pub files_directory: String,
/// Use test DC
#[serde(default)]
pub use_test_dc: bool,
/// Use file database
#[serde(default = "default_true")]
pub use_file_database: bool,
/// Use chat info database
#[serde(default = "default_true")]
pub use_chat_info_database: bool,
/// Use message database
#[serde(default = "default_true")]
pub use_message_database: bool,
/// System language code
#[serde(default = "default_lang")]
pub system_language_code: String,
/// Device model
#[serde(default = "default_device")]
pub device_model: String,
/// Application version
#[serde(default = "default_version")]
pub application_version: String,
}
#[allow(dead_code)]
fn default_true() -> bool {
true
}
#[allow(dead_code)]
fn default_lang() -> String {
"en".to_string()
}
#[allow(dead_code)]
fn default_device() -> String {
"Desktop".to_string()
}
#[allow(dead_code)]
fn default_version() -> String {
"1.0.0".to_string()
}
impl Default for TdClientConfig {
fn default() -> Self {
Self {
api_id: 0,
api_hash: String::new(),
database_directory: "tdlib".to_string(),
files_directory: "tdlib_files".to_string(),
use_test_dc: false,
use_file_database: true,
use_chat_info_database: true,
use_message_database: true,
system_language_code: "en".to_string(),
device_model: "Desktop".to_string(),
application_version: "1.0.0".to_string(),
}
}
}
/// A TDLib update received from the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct TdUpdate {
/// The update type (e.g., "updateNewMessage")
#[serde(rename = "@type")]
pub update_type: String,
/// The full update data
#[serde(flatten)]
pub data: serde_json::Value,
}
/// Messages sent to the TDLib service.
#[derive(Debug)]
#[allow(dead_code)]
pub enum TdServiceMessage {
/// Send a TDLib query
Send {
user_id: String,
query: serde_json::Value,
reply: oneshot::Sender<Result<serde_json::Value, String>>,
},
/// Get current auth state
GetAuthState {
user_id: String,
reply: oneshot::Sender<Result<serde_json::Value, String>>,
},
/// Create a new client
CreateClient {
user_id: String,
config: TdClientConfig,
reply: oneshot::Sender<Result<(), String>>,
},
/// Destroy a client
DestroyClient {
user_id: String,
reply: oneshot::Sender<Result<(), String>>,
},
/// Stop the service
Stop,
}
/// State for a single TDLib client.
#[allow(dead_code)]
struct TdClientState {
/// Current authorization state
auth_state: serde_json::Value,
/// Whether the client is ready
is_ready: bool,
}
/// TDLib V8 Service that manages TDLib clients.
#[allow(dead_code)]
pub struct TdlibV8Service {
/// Data directory for TDLib databases
data_dir: PathBuf,
/// Storage layer for IndexedDB emulation
storage: IdbStorage,
/// Message sender for the service
tx: mpsc::Sender<TdServiceMessage>,
/// Update broadcaster
update_tx: broadcast::Sender<(String, TdUpdate)>,
}
#[allow(dead_code)]
impl TdlibV8Service {
/// Create a new TDLib V8 service.
pub async fn new(data_dir: PathBuf) -> Result<Self, String> {
let storage = IdbStorage::new(&data_dir)?;
let (tx, _rx) = mpsc::channel(64);
let (update_tx, _) = broadcast::channel(256);
let service = Self {
data_dir,
storage,
tx,
update_tx,
};
Ok(service)
}
/// Get a sender for the service.
pub fn sender(&self) -> mpsc::Sender<TdServiceMessage> {
self.tx.clone()
}
/// Subscribe to TDLib updates.
/// Returns a receiver that will receive (user_id, update) tuples.
pub fn subscribe(&self) -> broadcast::Receiver<(String, TdUpdate)> {
self.update_tx.subscribe()
}
/// Send a query to TDLib.
pub async fn send(
&self,
user_id: &str,
query: serde_json::Value,
) -> Result<serde_json::Value, String> {
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(TdServiceMessage::Send {
user_id: user_id.to_string(),
query,
reply: reply_tx,
})
.await
.map_err(|e| format!("Failed to send query: {e}"))?;
reply_rx
.await
.map_err(|_| "Service did not respond".to_string())?
}
/// Get the current authorization state.
pub async fn get_auth_state(&self, user_id: &str) -> Result<serde_json::Value, String> {
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(TdServiceMessage::GetAuthState {
user_id: user_id.to_string(),
reply: reply_tx,
})
.await
.map_err(|e| format!("Failed to get auth state: {e}"))?;
reply_rx
.await
.map_err(|_| "Service did not respond".to_string())?
}
/// Create a new TDLib client for a user.
pub async fn create_client(
&self,
user_id: &str,
config: TdClientConfig,
) -> Result<(), String> {
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(TdServiceMessage::CreateClient {
user_id: user_id.to_string(),
config,
reply: reply_tx,
})
.await
.map_err(|e| format!("Failed to create client: {e}"))?;
reply_rx
.await
.map_err(|_| "Service did not respond".to_string())?
}
/// Destroy a TDLib client.
pub async fn destroy_client(&self, user_id: &str) -> Result<(), String> {
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(TdServiceMessage::DestroyClient {
user_id: user_id.to_string(),
reply: reply_tx,
})
.await
.map_err(|e| format!("Failed to destroy client: {e}"))?;
reply_rx
.await
.map_err(|_| "Service did not respond".to_string())?
}
}
/// TDLib client adapter that wraps a V8 runtime.
///
/// This is a placeholder for the full tdweb integration.
/// In the full implementation, this would:
/// 1. Load the tdweb JavaScript bundle
/// 2. Initialize TdClient with the WASM module
/// 3. Handle send/receive through the V8 runtime
#[allow(dead_code)]
pub struct TdClientAdapter {
user_id: String,
config: TdClientConfig,
data_dir: PathBuf,
storage: IdbStorage,
auth_state: serde_json::Value,
query_id: u64,
}
#[allow(dead_code)]
impl TdClientAdapter {
/// Create a new TDLib client adapter.
pub fn new(
user_id: String,
config: TdClientConfig,
data_dir: PathBuf,
storage: IdbStorage,
) -> Self {
Self {
user_id,
config,
data_dir,
storage,
auth_state: serde_json::json!({
"@type": "authorizationStateWaitTdlibParameters"
}),
query_id: 0,
}
}
/// Initialize the TDLib client.
///
/// This would load the tdweb bundle and initialize the WASM module.
pub async fn init(&mut self) -> Result<(), String> {
log::info!("[tdlib:{}] Initializing TDLib client", self.user_id);
// TODO: Load tdweb.js and libtdjson.wasm
// TODO: Create TdClient instance in V8
// TODO: Set up update handlers
// For now, simulate initialization
self.auth_state = serde_json::json!({
"@type": "authorizationStateWaitTdlibParameters"
});
Ok(())
}
/// Send a query to TDLib.
pub async fn send(&mut self, query: serde_json::Value) -> Result<serde_json::Value, String> {
self.query_id += 1;
let query_id = self.query_id;
log::debug!(
"[tdlib:{}] Sending query {}: {:?}",
self.user_id,
query_id,
query.get("@type")
);
// TODO: Execute query through V8/tdweb
// For now, return a placeholder response
let query_type = query
.get("@type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
match query_type {
"getAuthorizationState" => Ok(self.auth_state.clone()),
"setTdlibParameters" => {
self.auth_state = serde_json::json!({
"@type": "authorizationStateWaitPhoneNumber"
});
Ok(serde_json::json!({ "@type": "ok" }))
}
_ => Ok(serde_json::json!({
"@type": "error",
"code": 400,
"message": "TDLib not fully initialized - tdweb integration pending"
})),
}
}
/// Get the current authorization state.
pub fn get_auth_state(&self) -> serde_json::Value {
self.auth_state.clone()
}
/// Destroy the client.
pub async fn destroy(&mut self) -> Result<(), String> {
log::info!("[tdlib:{}] Destroying TDLib client", self.user_id);
// TODO: Properly close TDLib client
// TODO: Cleanup V8 runtime
Ok(())
}
}
/// JavaScript code to initialize the TDLib bridge in V8.
///
/// This provides the `__tdlib_send` and `__tdlib_get_auth_state` functions
/// that the bootstrap.js exposes to skills.
#[allow(dead_code)]
pub const TDLIB_BRIDGE_JS: &str = r#"
// TDLib Bridge for V8 Runtime
// This will be replaced with actual tdweb integration
(function() {
// Client state
const clients = new Map();
let defaultClient = null;
// Create a mock client for now
class MockTdClient {
constructor(options) {
this.options = options;
this.authState = { '@type': 'authorizationStateWaitTdlibParameters' };
this.queryId = 0;
this.callbacks = new Map();
}
async send(query) {
this.queryId++;
const queryType = query['@type'];
console.log('[TDLib] Send:', queryType);
// Handle known query types
switch (queryType) {
case 'getAuthorizationState':
return this.authState;
case 'setTdlibParameters':
this.authState = { '@type': 'authorizationStateWaitPhoneNumber' };
return { '@type': 'ok' };
case 'setAuthenticationPhoneNumber':
this.authState = { '@type': 'authorizationStateWaitCode' };
return { '@type': 'ok' };
default:
return {
'@type': 'error',
'code': 400,
'message': 'TDLib not fully initialized - waiting for tdweb integration'
};
}
}
getAuthState() {
return this.authState;
}
}
// Initialize default client
function initClient(userId, options) {
const client = new MockTdClient(options);
clients.set(userId, client);
if (!defaultClient) {
defaultClient = client;
}
return client;
}
// Get or create client
function getClient(userId) {
if (!clients.has(userId)) {
initClient(userId, {});
}
return clients.get(userId);
}
// Export to global scope
globalThis.__tdlib_send = async function(userId, query) {
const client = getClient(userId);
return await client.send(query);
};
globalThis.__tdlib_get_auth_state = function(userId) {
const client = getClient(userId);
return client.getAuthState();
};
globalThis.__tdlib_init = function(userId, options) {
return initClient(userId, options);
};
console.log('[TDLib] Bridge initialized (mock mode)');
})();
"#;
@@ -1,673 +0,0 @@
//! IndexedDB Storage Layer (SQLite-backed)
//!
//! Provides persistent storage for:
//! - IndexedDB API (for tdweb)
//! - Skill databases (db bridge)
//! - Skill key-value store (store bridge)
use parking_lot::RwLock;
use rusqlite::{params, Connection, OpenFlags};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Result of opening an IndexedDB database.
/// Used by the IndexedDB emulation layer for tdweb.
#[derive(Debug, Clone, serde::Serialize)]
pub struct IdbOpenResult {
/// Whether a version upgrade is needed.
pub needs_upgrade: bool,
/// The previous version (0 if new database).
pub old_version: u32,
/// List of existing object store names.
pub object_stores: Vec<String>,
}
/// IndexedDB-compatible storage backed by SQLite.
#[derive(Clone)]
pub struct IdbStorage {
/// Base directory for all databases.
data_dir: PathBuf,
/// Open database connections (db_name -> connection).
connections: Arc<RwLock<HashMap<String, Arc<parking_lot::Mutex<Connection>>>>>,
/// Database version info (used by IndexedDB emulation).
#[allow(dead_code)]
versions: Arc<RwLock<HashMap<String, u32>>>,
}
impl IdbStorage {
/// Create a new IdbStorage instance.
pub fn new(data_dir: &Path) -> Result<Self, String> {
std::fs::create_dir_all(data_dir)
.map_err(|e| format!("Failed to create data directory: {e}"))?;
Ok(Self {
data_dir: data_dir.to_path_buf(),
connections: Arc::new(RwLock::new(HashMap::new())),
versions: Arc::new(RwLock::new(HashMap::new())),
})
}
/// Get or create a database connection.
fn get_connection(&self, db_name: &str) -> Result<Arc<parking_lot::Mutex<Connection>>, String> {
// Check if already open
if let Some(conn) = self.connections.read().get(db_name) {
return Ok(conn.clone());
}
// Open/create the database
let db_path = self.data_dir.join(format!("{}.sqlite", db_name));
let conn = Connection::open_with_flags(
&db_path,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| format!("Failed to open database '{}': {}", db_name, e))?;
// Initialize schema
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS _idb_meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS _idb_stores (
name TEXT PRIMARY KEY,
key_path TEXT,
auto_increment INTEGER DEFAULT 0
);
"#,
)
.map_err(|e| format!("Failed to initialize database schema: {e}"))?;
let conn = Arc::new(parking_lot::Mutex::new(conn));
self.connections
.write()
.insert(db_name.to_string(), conn.clone());
Ok(conn)
}
/// Open or create an IndexedDB database.
pub fn open_database(&self, name: &str, version: u32) -> Result<IdbOpenResult, String> {
let conn = self.get_connection(name)?;
let conn_guard = conn.lock();
// Get current version
let current_version: u32 = conn_guard
.query_row(
"SELECT value FROM _idb_meta WHERE key = 'version'",
[],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
// Check if upgrade needed
let needs_upgrade = version > current_version;
if needs_upgrade {
// Update version
conn_guard
.execute(
"INSERT OR REPLACE INTO _idb_meta (key, value) VALUES ('version', ?)",
params![version.to_string()],
)
.map_err(|e| format!("Failed to update version: {e}"))?;
}
// Get object store names
let mut stmt = conn_guard
.prepare("SELECT name FROM _idb_stores")
.map_err(|e| format!("Failed to query object stores: {e}"))?;
let object_stores: Vec<String> = stmt
.query_map([], |row| row.get(0))
.map_err(|e| format!("Failed to fetch object stores: {e}"))?
.filter_map(|r| r.ok())
.collect();
self.versions.write().insert(name.to_string(), version);
Ok(IdbOpenResult {
needs_upgrade,
old_version: current_version,
object_stores,
})
}
/// Close a database connection.
pub fn close_database(&self, name: &str) {
self.connections.write().remove(name);
self.versions.write().remove(name);
}
/// Delete a database.
pub fn delete_database(&self, name: &str) -> Result<(), String> {
self.close_database(name);
let db_path = self.data_dir.join(format!("{}.sqlite", name));
if db_path.exists() {
std::fs::remove_file(&db_path)
.map_err(|e| format!("Failed to delete database: {e}"))?;
}
Ok(())
}
/// Create an object store.
pub fn create_object_store(
&self,
db_name: &str,
store_name: &str,
key_path: Option<&str>,
auto_increment: bool,
) -> Result<(), String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.lock();
// Register the store
conn_guard
.execute(
"INSERT OR REPLACE INTO _idb_stores (name, key_path, auto_increment) VALUES (?, ?, ?)",
params![store_name, key_path, auto_increment as i32],
)
.map_err(|e| format!("Failed to create object store: {e}"))?;
// Create the data table
let table_name = format!("store_{}", sanitize_name(store_name));
conn_guard
.execute(
&format!(
r#"
CREATE TABLE IF NOT EXISTS "{}" (
key TEXT PRIMARY KEY,
value TEXT
)
"#,
table_name
),
[],
)
.map_err(|e| format!("Failed to create store table: {e}"))?;
Ok(())
}
/// Delete an object store.
pub fn delete_object_store(&self, db_name: &str, store_name: &str) -> Result<(), String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.lock();
// Remove from registry
conn_guard
.execute("DELETE FROM _idb_stores WHERE name = ?", params![store_name])
.map_err(|e| format!("Failed to delete object store: {e}"))?;
// Drop the table
let table_name = format!("store_{}", sanitize_name(store_name));
conn_guard
.execute(&format!("DROP TABLE IF EXISTS \"{}\"", table_name), [])
.map_err(|e| format!("Failed to drop store table: {e}"))?;
Ok(())
}
/// Get a value from an object store.
pub fn get(
&self,
db_name: &str,
store_name: &str,
key: &serde_json::Value,
) -> Result<Option<serde_json::Value>, String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
let result: Option<String> = conn_guard
.query_row(
&format!("SELECT value FROM \"{}\" WHERE key = ?", table_name),
params![key_str],
|row| row.get(0),
)
.ok();
match result {
Some(value_str) => {
let value: serde_json::Value =
serde_json::from_str(&value_str).unwrap_or(serde_json::Value::Null);
Ok(Some(value))
}
None => Ok(None),
}
}
/// Put a value into an object store.
pub fn put(
&self,
db_name: &str,
store_name: &str,
key: &serde_json::Value,
value: &serde_json::Value,
) -> Result<(), String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
let value_str = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
conn_guard
.execute(
&format!(
"INSERT OR REPLACE INTO \"{}\" (key, value) VALUES (?, ?)",
table_name
),
params![key_str, value_str],
)
.map_err(|e| format!("Failed to put value: {e}"))?;
Ok(())
}
/// Delete a value from an object store.
pub fn delete(
&self,
db_name: &str,
store_name: &str,
key: &serde_json::Value,
) -> Result<(), String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
conn_guard
.execute(
&format!("DELETE FROM \"{}\" WHERE key = ?", table_name),
params![key_str],
)
.map_err(|e| format!("Failed to delete value: {e}"))?;
Ok(())
}
/// Clear all values from an object store.
pub fn clear(&self, db_name: &str, store_name: &str) -> Result<(), String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
conn_guard
.execute(&format!("DELETE FROM \"{}\"", table_name), [])
.map_err(|e| format!("Failed to clear store: {e}"))?;
Ok(())
}
/// Get all values from an object store.
pub fn get_all(
&self,
db_name: &str,
store_name: &str,
count: Option<u32>,
) -> Result<Vec<serde_json::Value>, String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let limit = count.map(|c| format!(" LIMIT {}", c)).unwrap_or_default();
let mut stmt = conn_guard
.prepare(&format!("SELECT value FROM \"{}\"{}", table_name, limit))
.map_err(|e| format!("Failed to query values: {e}"))?;
let values: Vec<serde_json::Value> = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| format!("Failed to fetch values: {e}"))?
.filter_map(|r| r.ok())
.filter_map(|s| serde_json::from_str(&s).ok())
.collect();
Ok(values)
}
/// Get all keys from an object store.
pub fn get_all_keys(
&self,
db_name: &str,
store_name: &str,
count: Option<u32>,
) -> Result<Vec<serde_json::Value>, String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let limit = count.map(|c| format!(" LIMIT {}", c)).unwrap_or_default();
let mut stmt = conn_guard
.prepare(&format!("SELECT key FROM \"{}\"{}", table_name, limit))
.map_err(|e| format!("Failed to query keys: {e}"))?;
let keys: Vec<serde_json::Value> = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| format!("Failed to fetch keys: {e}"))?
.filter_map(|r| r.ok())
.filter_map(|s| serde_json::from_str(&s).ok())
.collect();
Ok(keys)
}
/// Count values in an object store.
pub fn count(&self, db_name: &str, store_name: &str) -> Result<u32, String> {
let conn = self.get_connection(db_name)?;
let conn_guard = conn.lock();
let table_name = format!("store_{}", sanitize_name(store_name));
let count: u32 = conn_guard
.query_row(
&format!("SELECT COUNT(*) FROM \"{}\"", table_name),
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count values: {e}"))?;
Ok(count)
}
// ========================================================================
// Skill Database Bridge Methods
// ========================================================================
/// Execute SQL and return affected row count.
pub fn skill_db_exec(
&self,
skill_id: &str,
sql: &str,
params: &[serde_json::Value],
) -> Result<usize, String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.lock();
let params: Vec<Box<dyn rusqlite::ToSql>> = params
.iter()
.map(|v| -> Box<dyn rusqlite::ToSql> { Box::new(json_to_sql(v)) })
.collect();
let params_refs: Vec<&dyn rusqlite::ToSql> =
params.iter().map(|b| b.as_ref()).collect();
conn_guard
.execute(sql, params_refs.as_slice())
.map_err(|e| format!("SQL exec failed: {e}"))
}
/// Execute SQL and return a single row.
pub fn skill_db_get(
&self,
skill_id: &str,
sql: &str,
params: &[serde_json::Value],
) -> Result<serde_json::Value, String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.lock();
let params: Vec<Box<dyn rusqlite::ToSql>> = params
.iter()
.map(|v| -> Box<dyn rusqlite::ToSql> { Box::new(json_to_sql(v)) })
.collect();
let params_refs: Vec<&dyn rusqlite::ToSql> =
params.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn_guard
.prepare(sql)
.map_err(|e| format!("SQL prepare failed: {e}"))?;
let column_count = stmt.column_count();
// Capture column names before the closure to avoid borrow checker issues
let column_names: Vec<String> = (0..column_count)
.map(|i| stmt.column_name(i).unwrap_or("?").to_string())
.collect();
let result = stmt.query_row(params_refs.as_slice(), |row| {
let mut obj = serde_json::Map::new();
for (i, col_name) in column_names.iter().enumerate() {
let value = sql_to_json(row, i);
obj.insert(col_name.clone(), value);
}
Ok(serde_json::Value::Object(obj))
});
match result {
Ok(v) => Ok(v),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(serde_json::Value::Null),
Err(e) => Err(format!("SQL query failed: {e}")),
}
}
/// Execute SQL and return all rows.
pub fn skill_db_all(
&self,
skill_id: &str,
sql: &str,
params: &[serde_json::Value],
) -> Result<serde_json::Value, String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.lock();
let params: Vec<Box<dyn rusqlite::ToSql>> = params
.iter()
.map(|v| -> Box<dyn rusqlite::ToSql> { Box::new(json_to_sql(v)) })
.collect();
let params_refs: Vec<&dyn rusqlite::ToSql> =
params.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn_guard
.prepare(sql)
.map_err(|e| format!("SQL prepare failed: {e}"))?;
let column_count = stmt.column_count();
let column_names: Vec<String> = (0..column_count)
.map(|i| stmt.column_name(i).unwrap_or("?").to_string())
.collect();
let rows = stmt
.query_map(params_refs.as_slice(), |row| {
let mut obj = serde_json::Map::new();
for (i, name) in column_names.iter().enumerate() {
let value = sql_to_json(row, i);
obj.insert(name.clone(), value);
}
Ok(serde_json::Value::Object(obj))
})
.map_err(|e| format!("SQL query failed: {e}"))?
.filter_map(|r| r.ok())
.collect::<Vec<_>>();
Ok(serde_json::Value::Array(rows))
}
/// Get a key-value pair.
pub fn skill_kv_get(&self, skill_id: &str, key: &str) -> Result<serde_json::Value, String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.lock();
// Ensure KV table exists
conn_guard
.execute(
"CREATE TABLE IF NOT EXISTS _kv (key TEXT PRIMARY KEY, value TEXT)",
[],
)
.map_err(|e| format!("Failed to create KV table: {e}"))?;
let result: Option<String> = conn_guard
.query_row("SELECT value FROM _kv WHERE key = ?", params![key], |row| {
row.get(0)
})
.ok();
match result {
Some(v) => {
serde_json::from_str(&v).map_err(|e| format!("Failed to parse value: {e}"))
}
None => Ok(serde_json::Value::Null),
}
}
/// Set a key-value pair.
pub fn skill_kv_set(
&self,
skill_id: &str,
key: &str,
value: &serde_json::Value,
) -> Result<(), String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.lock();
// Ensure KV table exists
conn_guard
.execute(
"CREATE TABLE IF NOT EXISTS _kv (key TEXT PRIMARY KEY, value TEXT)",
[],
)
.map_err(|e| format!("Failed to create KV table: {e}"))?;
let value_str = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
conn_guard
.execute(
"INSERT OR REPLACE INTO _kv (key, value) VALUES (?, ?)",
params![key, value_str],
)
.map_err(|e| format!("Failed to set value: {e}"))?;
Ok(())
}
// ========================================================================
// Skill Store Bridge Methods (same as KV but different namespace)
// ========================================================================
/// Get a store value.
pub fn skill_store_get(&self, skill_id: &str, key: &str) -> Result<serde_json::Value, String> {
self.skill_kv_get(skill_id, &format!("_store_{}", key))
}
/// Set a store value.
pub fn skill_store_set(
&self,
skill_id: &str,
key: &str,
value: &serde_json::Value,
) -> Result<(), String> {
self.skill_kv_set(skill_id, &format!("_store_{}", key), value)
}
/// Delete a store value.
pub fn skill_store_delete(&self, skill_id: &str, key: &str) -> Result<(), String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.lock();
conn_guard
.execute(
"DELETE FROM _kv WHERE key = ?",
params![format!("_store_{}", key)],
)
.map_err(|e| format!("Failed to delete value: {e}"))?;
Ok(())
}
/// List all store keys.
pub fn skill_store_keys(&self, skill_id: &str) -> Result<Vec<String>, String> {
let db_name = format!("skill_{}", skill_id);
let conn = self.get_connection(&db_name)?;
let conn_guard = conn.lock();
// Ensure KV table exists
conn_guard
.execute(
"CREATE TABLE IF NOT EXISTS _kv (key TEXT PRIMARY KEY, value TEXT)",
[],
)
.map_err(|e| format!("Failed to create KV table: {e}"))?;
let mut stmt = conn_guard
.prepare("SELECT key FROM _kv WHERE key LIKE '_store_%'")
.map_err(|e| format!("Failed to query keys: {e}"))?;
let keys: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| format!("Failed to fetch keys: {e}"))?
.filter_map(|r| r.ok())
.map(|k| k.strip_prefix("_store_").unwrap_or(&k).to_string())
.collect();
Ok(keys)
}
}
/// Sanitize a name for use as a table name.
fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
.collect()
}
/// Convert a JSON value to a SQLite value.
fn json_to_sql(v: &serde_json::Value) -> rusqlite::types::Value {
match v {
serde_json::Value::Null => rusqlite::types::Value::Null,
serde_json::Value::Bool(b) => rusqlite::types::Value::Integer(*b as i64),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
rusqlite::types::Value::Integer(i)
} else if let Some(f) = n.as_f64() {
rusqlite::types::Value::Real(f)
} else {
rusqlite::types::Value::Text(n.to_string())
}
}
serde_json::Value::String(s) => rusqlite::types::Value::Text(s.clone()),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
rusqlite::types::Value::Text(v.to_string())
}
}
}
/// Convert a SQLite row value to JSON.
fn sql_to_json(row: &rusqlite::Row, idx: usize) -> serde_json::Value {
use rusqlite::types::ValueRef;
match row.get_ref(idx) {
Ok(ValueRef::Null) => serde_json::Value::Null,
Ok(ValueRef::Integer(i)) => serde_json::json!(i),
Ok(ValueRef::Real(f)) => serde_json::json!(f),
Ok(ValueRef::Text(s)) => {
let s = String::from_utf8_lossy(s).to_string();
// Try to parse as JSON
serde_json::from_str(&s).unwrap_or_else(|_| serde_json::json!(s))
}
Ok(ValueRef::Blob(b)) => {
serde_json::json!(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
b
))
}
Err(_) => serde_json::Value::Null,
}
}