AnyBot/src/macapps.rs

418 lines
27 KiB
Rust
Raw Normal View History

// The Mac's own apps as tools, like Grok Bot's device tools (local-exec-daemon):
// ios_read_reminders / ios_write_reminders, mobile_read_calendar / mobile_write_calendar,
// mobile_read_contacts, mac_read_notes / mac_write_notes, mac_read_mail / mac_draft_mail /
// mac_send_mail / mac_mail_accounts, and sending an iMessage.
// Rules taken from the original:
// - a note or an email is read up to 20,000 characters (zlt=2e4);
// - contacts are fetched 50 at a time (Dlt=50);
// - device calls time out after 30 s (g4r=3e4);
// - writing (reminders, calendar, notes, mail drafts) needs consent; sending mail is only ever
// approved one message at a time (recordLocalToolApproval refuses a standing grant for it);
// - mac_write_notes appends when given id + currentTitle, else creates (needs a title);
// mac_draft_mail needs to (or replyToId) and a body.
// The original runs them through its signed helper; here they go through osascript (JXA), so
// macOS asks once per app under 「系統設定 → 隱私權與安全性 → 自動化」.
use serde_json::{json, Value};
use std::time::{Duration, Instant};
pub const MAX_CHARS: usize = 20_000;
pub const CONTACT_BATCH: usize = 50;
pub const CALL_TIMEOUT_MS: u64 = 30_000;
/// Messages scanned when searching mail (estimated; Mail scripting is slow on big inboxes).
const MAIL_SCAN: usize = 300;
pub const NAMES: [&str; 11] = [
"ios_read_reminders",
"ios_write_reminders",
"mobile_read_calendar",
"mobile_write_calendar",
"mobile_read_contacts",
"mac_read_notes",
"mac_write_notes",
"mac_read_mail",
"mac_draft_mail",
"mac_send_mail",
"mac_mail_accounts",
];
pub const SEND_MESSAGE: &str = "mac_send_message";
pub fn is_mac_tool(name: &str) -> bool {
NAMES.contains(&name) || name == SEND_MESSAGE
}
/// The app a tool uses (for the consent wording and the per-app grant).
pub fn app_of(name: &str) -> &'static str {
match name {
"ios_read_reminders" | "ios_write_reminders" => "提醒事項",
"mobile_read_calendar" | "mobile_write_calendar" => "行事曆",
"mobile_read_contacts" => "聯絡人",
"mac_read_notes" | "mac_write_notes" => "備忘錄",
SEND_MESSAGE => "訊息",
_ => "郵件",
}
}
pub fn is_write(name: &str) -> bool {
matches!(name, "ios_write_reminders" | "mobile_write_calendar" | "mac_write_notes" | "mac_draft_mail")
}
/// Each one is approved on its own, never "always".
pub fn is_send(name: &str) -> bool {
name == "mac_send_mail" || name == SEND_MESSAGE
}
pub fn label(name: &str) -> &'static str {
match name {
"ios_read_reminders" => "查看提醒事項",
"ios_write_reminders" => "修改提醒事項",
"mobile_read_calendar" => "查看行事曆",
"mobile_write_calendar" => "修改行事曆",
"mobile_read_contacts" => "查聯絡人",
"mac_read_notes" => "讀備忘錄",
"mac_write_notes" => "寫備忘錄",
"mac_read_mail" => "讀郵件",
"mac_draft_mail" => "起草郵件",
"mac_send_mail" => "寄出郵件",
"mac_mail_accounts" => "查看郵件帳號",
SEND_MESSAGE => "傳訊息",
_ => "Mac App",
}
}
fn s(v: &Value, k: &str) -> String {
v.get(k).and_then(Value::as_str).unwrap_or("").trim().to_string()
}
fn list(v: &Value, k: &str) -> Vec<String> {
match v.get(k) {
Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str().map(|s| s.trim().to_string())).filter(|s| !s.is_empty()).collect(),
Some(Value::String(x)) => x.split([',', ';']).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
_ => vec![],
}
}
pub fn describe(name: &str, args: &str) -> String {
let v: Value = serde_json::from_str(args).unwrap_or(Value::Null);
match name {
"ios_write_reminders" => format!("{}「{}」", match s(&v, "action").as_str() { "complete" => "完成", "delete" => "刪除", "update" => "修改", _ => "新增" }, s(&v, "title")),
"mobile_write_calendar" => format!("{}「{}」{}", match s(&v, "action").as_str() { "delete" => "刪除", "update" => "修改", _ => "新增" }, s(&v, "title"), s(&v, "start")),
"mobile_read_calendar" => format!("{} ~ {}", s(&v, "from"), s(&v, "to")),
"mobile_read_contacts" | "mac_read_notes" | "mac_read_mail" => s(&v, "query"),
"mac_write_notes" => if s(&v, "id").is_empty() { format!("新備忘錄「{}」", s(&v, "title")) } else { format!("加到「{}」", s(&v, "currentTitle")) },
"mac_draft_mail" | "mac_send_mail" => format!("給 {}:{}", list(&v, "to").join("、"), s(&v, "subject")),
SEND_MESSAGE => {
let t: String = s(&v, "text").chars().take(40).collect();
format!("給 {}:{t}", s(&v, "to"))
}
_ => String::new(),
}
}
pub fn specs() -> Vec<Value> {
let strs = json!({"type": "array", "items": {"type": "string"}});
vec![
json!({"name": "ios_read_reminders", "description": "Read the person's Reminders on this Mac. Optional list name; completed ones only when asked.",
"parameters": {"type": "object", "properties": {"list": {"type": "string"}, "includeCompleted": {"type": "boolean"}, "query": {"type": "string"}, "limit": {"type": "integer"}}}}),
json!({"name": "ios_write_reminders", "description": "Add, update, complete or delete a reminder in the person's Reminders. due: ISO date-time in the person's time zone.",
"parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["add", "update", "complete", "delete"]}, "id": {"type": "string"}, "title": {"type": "string"}, "notes": {"type": "string"}, "due": {"type": "string"}, "list": {"type": "string"}}, "required": ["action"]}}),
json!({"name": "mobile_read_calendar", "description": "Read events from the person's Calendar between from and to (ISO dates).",
"parameters": {"type": "object", "properties": {"from": {"type": "string"}, "to": {"type": "string"}, "calendar": {"type": "string"}}, "required": ["from", "to"]}}),
json!({"name": "mobile_write_calendar", "description": "Add, update or delete an event in the person's Calendar. start/end: ISO date-times.",
"parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["add", "update", "delete"]}, "id": {"type": "string"}, "title": {"type": "string"}, "start": {"type": "string"}, "end": {"type": "string"}, "location": {"type": "string"}, "notes": {"type": "string"}, "calendar": {"type": "string"}, "allDay": {"type": "boolean"}}, "required": ["action"]}}),
json!({"name": "mobile_read_contacts", "description": "Find people in the person's Contacts by name, email or phone.",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["query"]}}),
json!({"name": "mac_read_notes", "description": "Search the person's Notes (query, folder), or read one note by id (up to 20,000 characters).",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}, "query": {"type": "string"}, "folder": {"type": "string"}, "limit": {"type": "integer"}}}}),
json!({"name": "mac_write_notes", "description": "Create a note (title + body, optional folder), or append to one (id + its currentTitle + body).",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}, "currentTitle": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}, "folder": {"type": "string"}}, "required": ["body"]}}),
json!({"name": "mac_read_mail", "description": "Search the person's Mail (query, mailbox, unreadOnly, days, limit), or read one message by id (up to 20,000 characters).",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}, "query": {"type": "string"}, "mailbox": {"type": "string"}, "unreadOnly": {"type": "boolean"}, "days": {"type": "integer"}, "limit": {"type": "integer"}}}}),
json!({"name": "mac_draft_mail", "description": "Open a draft in Mail for the person to review (to + subject + body, or replyToId + body). It is not sent.",
"parameters": {"type": "object", "properties": {"to": strs, "cc": strs, "subject": {"type": "string"}, "body": {"type": "string"}, "replyToId": {"type": "string"}}, "required": ["body"]}}),
json!({"name": "mac_send_mail", "description": "Send an email from the person's Mail. The person approves every message.",
"parameters": {"type": "object", "properties": {"to": strs, "cc": strs, "subject": {"type": "string"}, "body": {"type": "string"}, "account": {"type": "string"}}, "required": ["to", "subject", "body"]}}),
json!({"name": "mac_mail_accounts", "description": "List the person's Mail accounts and their addresses.",
"parameters": {"type": "object", "properties": {}}}),
json!({"name": SEND_MESSAGE, "description": "Send an iMessage/SMS from the person's Messages app to a phone number or email. The person approves every message.",
"parameters": {"type": "object", "properties": {"to": {"type": "string"}, "text": {"type": "string"}}, "required": ["to", "text"]}}),
]
}
/// Run a JXA script with the arguments as JSON (argv[0]). Returns its printed result.
fn jxa(script: &str, args: &Value) -> Result<String, String> {
use std::io::Read;
let mut child = std::process::Command::new("osascript")
.args(["-l", "JavaScript", "-e", script, "--", &args.to_string()])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("無法執行 osascript:{e}"))?;
let mut so = child.stdout.take().unwrap();
let mut se = child.stderr.take().unwrap();
let ot = std::thread::spawn(move || {
let mut v = String::new();
let _ = so.read_to_string(&mut v);
v
});
let et = std::thread::spawn(move || {
let mut v = String::new();
let _ = se.read_to_string(&mut v);
v
});
let start = Instant::now();
let st = loop {
if let Ok(Some(st)) = child.try_wait() {
break st;
}
if start.elapsed() > Duration::from_millis(CALL_TIMEOUT_MS) {
let _ = child.kill();
return Err("Mac 的 App 沒有在 30 秒內回應。".into());
}
std::thread::sleep(Duration::from_millis(20));
};
let out = ot.join().unwrap_or_default();
let err = et.join().unwrap_or_default();
if st.success() {
Ok(out.trim().to_string())
} else {
Err(err.trim().to_string())
}
}
/// The original's wording when macOS says no (a4e), pointed at AnyBot.
fn permission_hint(app: &str, err: &str) -> String {
let e = err.to_lowercase();
if e.contains("-1743") || e.contains("not allowed") || e.contains("不允許") || e.contains("not authorized") {
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation").spawn();
return format!("AnyBot 還不能控制這台 Mac 的「{app}」。我已經打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{app}」,再叫我試一次。");
}
format!("「{app}」回報錯誤:{}", err.lines().last().unwrap_or(err))
}
const JS_HELPERS: &str = r#"
function clip(t, n) { t = String(t || ''); return t.length > n ? t.slice(0, n) + '\n…(後面省略 ' + (t.length - n) + ' 個字)' : t; }
function iso(d) { try { return d ? d.toISOString() : null; } catch (e) { return null; } }
function has(hay, q) { return !q || String(hay || '').toLowerCase().indexOf(q.toLowerCase()) >= 0; }
"#;
fn script(body: &str) -> String {
format!("{JS_HELPERS}\nfunction run(argv) {{ const a = JSON.parse(argv[0] || '{{}}'); {body} }}")
}
pub fn run(name: &str, args: &str) -> (String, bool) {
if !cfg!(target_os = "macos") {
return ("這個工具只支援 macOS".into(), false);
}
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(json!({}));
let r = run_inner(name, &v);
match r {
Ok(s) => (s, true),
Err(e) => (e, false),
}
}
fn run_inner(name: &str, v: &Value) -> Result<String, String> {
let app = app_of(name);
let call = |body: &str, a: &Value| jxa(&script(body), a).map_err(|e| permission_hint(app, &e));
match name {
"ios_read_reminders" => {
let a = json!({"list": s(v, "list"), "done": v["includeCompleted"].as_bool().unwrap_or(false), "q": s(v, "query"), "limit": v["limit"].as_u64().unwrap_or(50).min(200)});
call(r#"const R = Application('Reminders'); const out = [];
const lists = a.list ? R.lists.whose({name: a.list})() : R.lists();
for (const l of lists) { const rs = a.done ? l.reminders() : l.reminders.whose({completed: false})();
for (const r of rs) { if (out.length >= a.limit) break; const n = r.name(); if (!has(n + ' ' + (r.body() || ''), a.q)) continue;
out.push({id: r.id(), title: n, list: l.name(), due: iso(r.dueDate()), completed: r.completed(), notes: clip(r.body(), 500)}); } }
return JSON.stringify(out);"#, &a)
.map(|o| format!("提醒事項:{o}"))
}
"ios_write_reminders" => {
let action = s(v, "action");
if action.is_empty() {
return Err("請給 action(add、update、complete、delete)".into());
}
if action != "add" && s(v, "id").is_empty() {
return Err("修改、完成或刪除要給 id(先用 ios_read_reminders 查)".into());
}
if action == "add" && s(v, "title").is_empty() {
return Err("新的提醒事項要有 title".into());
}
let a = json!({"action": action, "id": s(v, "id"), "title": s(v, "title"), "notes": s(v, "notes"), "due": s(v, "due"), "list": s(v, "list")});
call(r#"const R = Application('Reminders');
if (a.action === 'add') { const l = a.list ? R.lists.byName(a.list) : R.defaultList();
const props = {name: a.title}; if (a.notes) props.body = a.notes; if (a.due) props.dueDate = new Date(a.due);
const r = R.Reminder(props); l.reminders.push(r); return JSON.stringify({action: 'added', id: r.id(), title: a.title}); }
const r = R.reminders.byId(a.id); const t = r.name();
if (a.action === 'complete') { r.completed = true; return JSON.stringify({action: 'completed', title: t}); }
if (a.action === 'delete') { R.delete(r); return JSON.stringify({action: 'deleted', title: t}); }
if (a.title) r.name = a.title; if (a.notes) r.body = a.notes; if (a.due) r.dueDate = new Date(a.due);
return JSON.stringify({action: 'updated', title: r.name()});"#, &a)
}
"mobile_read_calendar" => {
if s(v, "from").is_empty() || s(v, "to").is_empty() {
return Err("請給 from 和 to(例如 2026-11-04 和 2026-11-11)".into());
}
let a = json!({"from": s(v, "from"), "to": s(v, "to"), "cal": s(v, "calendar")});
call(r#"const C = Application('Calendar'); const from = new Date(a.from), to = new Date(a.to); const out = [];
const cals = a.cal ? C.calendars.whose({name: a.cal})() : C.calendars();
for (const c of cals) { const evs = c.events.whose({_and: [{startDate: {_greaterThan: from}}, {startDate: {_lessThan: to}}]})();
for (const e of evs) out.push({id: e.uid(), calendar: c.name(), title: e.summary(), start: iso(e.startDate()), end: iso(e.endDate()), allDay: e.alldayEvent(), location: e.location() || ''}); }
out.sort((x, y) => x.start < y.start ? -1 : 1); return JSON.stringify(out.slice(0, 200));"#, &a)
.map(|o| format!("行事曆:{o}"))
}
"mobile_write_calendar" => {
let action = s(v, "action");
if action != "add" && s(v, "id").is_empty() {
return Err("修改或刪除要給 id(先用 mobile_read_calendar 查)".into());
}
if action == "add" && (s(v, "title").is_empty() || s(v, "start").is_empty()) {
return Err("新的行程要有 title 和 start".into());
}
let a = json!({"action": action, "id": s(v, "id"), "title": s(v, "title"), "start": s(v, "start"), "end": s(v, "end"), "loc": s(v, "location"), "notes": s(v, "notes"), "cal": s(v, "calendar"), "allDay": v["allDay"].as_bool().unwrap_or(false)});
call(r#"const C = Application('Calendar');
function find(id) { for (const c of C.calendars()) { const m = c.events.whose({uid: id})(); if (m.length) return m[0]; } return null; }
if (a.action === 'add') { const cal = a.cal ? C.calendars.byName(a.cal) : C.calendars.whose({writable: true})()[0];
const s = new Date(a.start); const e = a.end ? new Date(a.end) : new Date(s.getTime() + 3600000);
const props = {summary: a.title, startDate: s, endDate: e, alldayEvent: a.allDay}; if (a.loc) props.location = a.loc; if (a.notes) props.description = a.notes;
const ev = C.Event(props); cal.events.push(ev); return JSON.stringify({action: 'added', id: ev.uid(), calendar: cal.name()}); }
const ev = find(a.id); if (!ev) throw new Error('那個行程已經不在這台 Mac 上了'); const t = ev.summary();
if (a.action === 'delete') { C.delete(ev); return JSON.stringify({action: 'deleted', title: t}); }
if (a.title) ev.summary = a.title; if (a.start) ev.startDate = new Date(a.start); if (a.end) ev.endDate = new Date(a.end); if (a.loc) ev.location = a.loc; if (a.notes) ev.description = a.notes;
return JSON.stringify({action: 'updated', title: ev.summary()});"#, &a)
}
"mobile_read_contacts" => {
let q = s(v, "query");
if q.is_empty() {
return Err("請給要找的名字、電子郵件或電話".into());
}
let a = json!({"q": q, "limit": v["limit"].as_u64().unwrap_or(10).min(CONTACT_BATCH as u64)});
call(r#"const P = Application('Contacts'); const out = [];
let ps = P.people.whose({name: {_contains: a.q}})();
if (!ps.length) ps = P.people().filter(p => p.emails().some(e => has(e.value(), a.q)) || p.phones().some(ph => has(String(ph.value()).replace(/\D/g, ''), a.q.replace(/\D/g, '')) && a.q.replace(/\D/g, '').length >= 4));
for (const p of ps.slice(0, a.limit)) out.push({id: p.id(), name: p.name(), organization: p.organization() || '', emails: p.emails().map(e => e.value()), phones: p.phones().map(x => x.value())});
return JSON.stringify(out);"#, &a)
.map(|o| format!("聯絡人:{o}"))
}
"mac_read_notes" => {
let a = json!({"id": s(v, "id"), "q": s(v, "query"), "folder": s(v, "folder"), "limit": v["limit"].as_u64().unwrap_or(20).min(100), "max": MAX_CHARS});
call(r#"const N = Application('Notes');
if (a.id) { const n = N.notes.byId(a.id); return JSON.stringify({id: n.id(), title: n.name(), modified: iso(n.modificationDate()), body: clip(n.plaintext(), a.max)}); }
const src = a.folder ? N.folders.byName(a.folder).notes : N.notes;
const ns = a.q ? src.whose({_or: [{name: {_contains: a.q}}, {plaintext: {_contains: a.q}}]})() : src();
return JSON.stringify(ns.slice(0, a.limit).map(n => ({id: n.id(), title: n.name(), modified: iso(n.modificationDate()), preview: clip(n.plaintext(), 200)})));"#, &a)
}
"mac_write_notes" => {
let body = s(v, "body");
if body.is_empty() {
return Err("mac_write_notes: body is required".into());
}
let id = s(v, "id");
if !id.is_empty() && v.get("currentTitle").and_then(Value::as_str).is_none() {
return Err("mac_write_notes: currentTitle is required with id".into());
}
if id.is_empty() && s(v, "title").is_empty() {
return Err("mac_write_notes: a new note needs a title".into());
}
let a = json!({"id": id, "cur": s(v, "currentTitle"), "title": s(v, "title"), "body": body, "folder": s(v, "folder")});
call(r#"const N = Application('Notes');
function html(t) { return t.split('\n').map(l => '<div>' + (l.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') || '<br>') + '</div>').join(''); }
if (a.id) { const n = N.notes.byId(a.id); if (n.name() !== a.cur) throw new Error('那則備忘錄的標題已經變了(現在是「' + n.name() + '」),請重新讀一次');
n.body = n.body() + html(a.body); return JSON.stringify({action: 'appended', id: n.id(), title: n.name()}); }
const target = a.folder ? N.folders.byName(a.folder) : N.defaultAccount().defaultFolder();
const n = N.Note({body: '<h1>' + html(a.title).slice(5, -6) + '</h1>' + html(a.body)}); target.notes.push(n);
return JSON.stringify({action: 'created', id: n.id(), title: a.title});"#, &a)
}
"mac_read_mail" => {
let a = json!({"id": s(v, "id"), "q": s(v, "query"), "mailbox": s(v, "mailbox"), "unread": v["unreadOnly"].as_bool().unwrap_or(false), "days": v["days"].as_u64().unwrap_or(0), "limit": v["limit"].as_u64().unwrap_or(20).min(100), "scan": MAIL_SCAN, "max": MAX_CHARS});
call(r#"const M = Application('Mail');
if (a.id) { for (const acc of M.accounts()) for (const mb of acc.mailboxes()) { const m = mb.messages.whose({id: Number(a.id)})(); if (m.length) { const x = m[0];
return JSON.stringify({id: String(x.id()), subject: x.subject(), from: x.sender(), to: x.toRecipients().map(r => r.address()), date: iso(x.dateReceived()), body: clip(x.content(), a.max)}); } }
const m = M.inbox.messages.whose({id: Number(a.id)})(); if (!m.length) throw new Error('那封郵件已經不在這台 Mac 上了'); }
let box = M.inbox; if (a.mailbox) { for (const acc of M.accounts()) { const f = acc.mailboxes.whose({name: a.mailbox})(); if (f.length) { box = f[0]; break; } } }
const since = a.days ? new Date(Date.now() - a.days * 86400000) : null;
const msgs = box.messages(); const out = [];
for (let i = 0; i < Math.min(msgs.length, a.scan) && out.length < a.limit; i++) { const x = msgs[i];
if (a.unread && x.readStatus()) continue; const d = x.dateReceived(); if (since && d < since) continue;
const subj = x.subject(), from = x.sender(); if (a.q && !has(subj + ' ' + from, a.q)) continue;
out.push({id: String(x.id()), subject: subj, from: from, date: iso(d), read: x.readStatus()}); }
return JSON.stringify(out);"#, &a)
.map(|o| format!("郵件:{o}"))
}
"mac_draft_mail" => {
let body = s(v, "body");
if body.is_empty() {
return Err("mac_draft_mail: body is required".into());
}
let to = list(v, "to");
if to.is_empty() && s(v, "replyToId").is_empty() {
return Err("mac_draft_mail: a new email needs to".into());
}
let a = json!({"to": to, "cc": list(v, "cc"), "subject": s(v, "subject"), "body": body, "reply": s(v, "replyToId")});
call(r#"const M = Application('Mail'); M.activate(); let m;
if (a.reply) { let orig = null; for (const acc of M.accounts()) { for (const mb of acc.mailboxes()) { const f = mb.messages.whose({id: Number(a.reply)})(); if (f.length) { orig = f[0]; break; } } if (orig) break; }
if (!orig) throw new Error('找不到要回覆的郵件'); m = M.reply(orig, {openingWindow: true}); m.content = a.body + '\n\n' + m.content(); }
else { m = M.OutgoingMessage({subject: a.subject, content: a.body, visible: true}); M.outgoingMessages.push(m);
for (const t of a.to) m.toRecipients.push(M.Recipient({address: t})); for (const c of a.cc) m.ccRecipients.push(M.CcRecipient({address: c})); }
return JSON.stringify({action: 'drafted', subject: m.subject(), note: '草稿已在「郵件」打開,使用者檢查後可以自己寄出。'});"#, &a)
}
"mac_send_mail" => {
let to = list(v, "to");
if to.is_empty() || s(v, "body").is_empty() {
return Err("寄信要有 to 和 body".into());
}
let a = json!({"to": to, "cc": list(v, "cc"), "subject": s(v, "subject"), "body": s(v, "body"), "account": s(v, "account")});
call(r#"const M = Application('Mail');
const m = M.OutgoingMessage({subject: a.subject, content: a.body, visible: false}); M.outgoingMessages.push(m);
if (a.account) m.sender = a.account;
for (const t of a.to) m.toRecipients.push(M.Recipient({address: t})); for (const c of a.cc) m.ccRecipients.push(M.CcRecipient({address: c}));
m.send(); return JSON.stringify({action: 'sent', to: a.to, subject: a.subject});"#, &a)
}
"mac_mail_accounts" => call(r#"const M = Application('Mail'); return JSON.stringify(M.accounts().map(x => ({name: x.name(), addresses: x.emailAddresses(), enabled: x.enabled()})));"#, &json!({})),
SEND_MESSAGE => {
let (to, text) = (s(v, "to"), s(v, "text"));
if to.is_empty() || text.is_empty() {
return Err("要給 to(電話或電子郵件)和 text".into());
}
let a = json!({"to": to, "text": text});
call(r#"const Msg = Application('Messages');
let svc = Msg.services.whose({serviceType: 'iMessage'})(); if (!svc.length) svc = Msg.services();
const buddy = svc[0].buddies.byName(a.to);
Msg.send(a.text, {to: buddy}); return JSON.stringify({action: 'sent', to: a.to});"#, &a)
}
other => Err(format!("This Mac has no \"{other}\" tool.")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_and_sends_are_told_apart() {
assert!(is_write("mac_write_notes") && is_write("mac_draft_mail") && is_write("ios_write_reminders"));
assert!(!is_write("mac_read_mail"));
assert!(is_send("mac_send_mail") && is_send(SEND_MESSAGE));
assert_eq!(app_of("mobile_read_contacts"), "聯絡人");
assert_eq!(specs().len(), NAMES.len() + 1);
}
#[test]
fn input_rules_match_the_original() {
assert!(run_inner("mac_write_notes", &json!({"body": "x"})).unwrap_err().contains("needs a title"));
assert!(run_inner("mac_write_notes", &json!({"id": "x", "body": "x"})).unwrap_err().contains("currentTitle"));
assert!(run_inner("mac_draft_mail", &json!({"body": "x"})).unwrap_err().contains("needs to"));
assert!(run_inner("mac_draft_mail", &json!({"to": ["a@b.c"]})).unwrap_err().contains("body is required"));
}
/// `cargo test --release -- --ignored mac_apps_read --nocapture` (macOS asks for Automation once).
#[test]
#[ignore]
fn mac_apps_read() {
for (n, a) in [("ios_read_reminders", "{}"), ("mobile_read_calendar", r#"{"from":"2026-11-01","to":"2026-11-30"}"#), ("mac_read_notes", r#"{"limit":3}"#), ("mac_mail_accounts", "{}")] {
let (o, ok) = run(n, a);
println!("{n}: ok={ok} {}", o.chars().take(300).collect::<String>());
}
}
}