138 lines
5.6 KiB
Rust
138 lines
5.6 KiB
Rust
// 分享 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();
|
||
}
|
||
}
|