AnyBot/src/share.rs

138 lines
5.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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