mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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();
|
||||
|
||||
|
||||
-1058
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, ¶ms).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, ¶ms).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, ¶ms).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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user