AnyBot/src/share.rs

138 lines
5.6 KiB
Rust
Raw Normal View History

// 分享 Bot: the original shares team bots through its servers (members, roles). Here a bot is
// shared as a file: its look, provider/model, instructions, routines and demonstrations — never
// the chat, API keys, tokens or the person's always-allow grants.
use crate::model::Bot;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const FORMAT: &str = "anybot-bot";
pub const VERSION: u32 = 1;
#[derive(Serialize, Deserialize)]
pub struct Shared {
pub format: String,
pub version: u32,
pub bot: Value,
}
/// Token-like fields removed from routine triggers before sharing.
const SECRET_KEYS: [&str; 4] = ["token", "apiKey", "secret", "api_key"];
fn scrub(v: &mut Value) {
match v {
Value::Object(m) => {
for k in SECRET_KEYS {
if m.contains_key(k) {
m.insert(k.to_string(), Value::String(String::new()));
}
}
for (_, x) in m.iter_mut() {
scrub(x);
}
}
Value::Array(a) => a.iter_mut().for_each(scrub),
_ => {}
}
}
/// The bot as a shareable file.
pub fn export(b: &Bot) -> String {
let mut v = serde_json::to_value(b).unwrap_or(Value::Null);
if let Value::Object(m) = &mut v {
for k in ["messages", "allow_rules", "unread", "last_turn", "workdir", "pinned", "id"] {
m.remove(k);
}
if let Some(Value::Array(routines)) = m.get_mut("routines") {
for r in routines.iter_mut() {
if let Value::Object(rm) = r {
for k in ["last_run", "next_run"] {
rm.remove(k);
}
}
}
}
// Demonstrations keep their steps; the pictures and video stay on this Mac.
if let Some(Value::Array(demos)) = m.get_mut("demos") {
for d in demos.iter_mut() {
if let Value::Object(dm) = d {
dm.insert("video".into(), Value::String(String::new()));
dm.insert("shots".into(), Value::Array(vec![]));
}
}
}
}
scrub(&mut v);
serde_json::to_string_pretty(&Shared { format: FORMAT.into(), version: VERSION, bot: v }).unwrap_or_default()
}
/// A bot from a shared file, with a new id and fresh routine ids (`next_routine` is advanced).
pub fn import(text: &str, new_id: u64, next_routine: &mut u64, now: i64) -> Result<Bot, String> {
let s: Shared = serde_json::from_str(text).map_err(|_| "這不是 AnyBot 的 Bot 檔案".to_string())?;
if s.format != FORMAT {
return Err("這不是 AnyBot 的 Bot 檔案".into());
}
if s.version > VERSION {
return Err("這個檔案來自較新的 AnyBot,請先更新".into());
}
let mut v = s.bot;
if let Value::Object(m) = &mut v {
m.insert("id".into(), Value::from(new_id));
m.insert("messages".into(), Value::Array(vec![]));
}
let mut b: Bot = serde_json::from_value(v).map_err(|e| format!("檔案內容不完整:{e}"))?;
for r in b.routines.iter_mut() {
r.id = *next_routine;
*next_routine += 1;
r.created_at = now;
r.last_run = None;
// Triggers need the new owner's own tokens: start paused until they're filled in.
if r.trigger.is_some() {
r.paused = true;
}
r.next_run = if r.trigger.is_some() { None } else { crate::routines::next_run(&r.schedule, now, now) };
}
Ok(b)
}
/// Finder dialogs (macOS).
pub fn choose_save(default_name: &str) -> Option<std::path::PathBuf> {
if !cfg!(target_os = "macos") {
return crate::platform::choose_save_linux(&crate::i18n::t("儲存"), default_name);
}
let script = r#"function run(argv) { const app = Application.currentApplication(); app.includeStandardAdditions = true;
try { return app.chooseFileName({withPrompt: '匯出 Bot', defaultName: argv[0]}).toString(); } catch (e) { return ''; } }"#;
let o = std::process::Command::new("osascript").args(["-l", "JavaScript", "-e", script, "--", default_name]).output().ok()?;
let p = String::from_utf8_lossy(&o.stdout).trim().to_string();
(!p.is_empty()).then(|| std::path::PathBuf::from(p))
}
pub fn choose_open() -> Option<std::path::PathBuf> {
crate::attach::pick_files().into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_bots_leave_secrets_and_chat_behind() {
let mut st = crate::model::State::demo();
let mut b = st.bots[1].clone();
b.allow_rules = vec!["shell:git".into()];
b.messages.push(crate::model::Message::user("私人對話"));
b.routines[0].trigger = Some(crate::triggers::Trigger::Github { repo: "me/app".into(), token: "ghp_SECRET".into(), events: vec![], users: vec![], branch: String::new(), pr: 0 });
b.demos.push(crate::teach::Demo { name: "報表".into(), created: String::new(), steps: vec!["點 (1, 2)".into()], video: "/Users/x/v.mp4".into(), shots: vec!["/Users/x/1.jpg".into()] });
let text = export(&b);
assert!(!text.contains("ghp_SECRET") && !text.contains("私人對話") && !text.contains("shell:git") && !text.contains("/Users/x"));
let mut next = 500;
let got = import(&text, 99, &mut next, crate::routines::now()).unwrap();
assert_eq!((got.id, got.name.clone(), got.messages.len()), (99, b.name.clone(), 0));
assert_eq!(got.routines[0].id, 500);
assert!(got.routines[0].paused, "a trigger waits for the new owner's token");
assert_eq!(got.demos[0].steps, vec!["點 (1, 2)".to_string()]);
assert!(import("{}", 1, &mut next, 0).is_err());
st.bots.clear();
}
}