// 分享 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 { 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 { 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 { 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(); } }