// 專屬號碼、打電話、X — with the person's own accounts (the original uses its own phone and X // services; here: a Twilio number and an X developer app). // // • 簡訊 (Twilio): the bot answers texts sent to the number (polled, no public address needed) and // replies from it. // • 打電話 (Twilio): the bot can call someone and read out a message (asked every time). // • X: the bot answers @mentions as replies, and can post (posting is asked every time). // // Test servers can stand in: ANYBOT_TWILIO_API and ANYBOT_X_API. use base64::Engine; use serde_json::{json, Value}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; pub const SMS_MAX: usize = 1_600; pub const X_MAX: usize = 280; fn twilio() -> String { std::env::var("ANYBOT_TWILIO_API").unwrap_or_else(|_| "https://api.twilio.com".into()) } fn x_api() -> String { std::env::var("ANYBOT_X_API").unwrap_or_else(|_| "https://api.x.com".into()) } fn agent() -> ureq::Agent { ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into() } /// "AccountSID:AuthToken" → (sid, Basic header). pub fn twilio_auth(token: &str) -> Result<(String, String), String> { let (sid, secret) = token.trim().split_once(':').ok_or("Twilio 金鑰要填「Account SID:Auth Token」")?; Ok((sid.trim().to_string(), format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", sid.trim(), secret.trim()))))) } fn form(pairs: &[(&str, &str)]) -> String { let enc = crate::httpd::url_encode; pairs.iter().map(|(k, v)| format!("{}={}", enc(k), enc(v))).collect::>().join("&") } fn read(mut r: ureq::http::Response) -> Result { let st = r.status().as_u16(); let text = r.body_mut().read_to_string().unwrap_or_default(); let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); if !(200..300).contains(&st) { let msg = v["message"].as_str().or(v["detail"].as_str()).or(v["title"].as_str()).map(String::from).unwrap_or_else(|| text.chars().take(160).collect()); return Err(format!("HTTP {st}:{msg}")); } Ok(v) } fn twilio_post(token: &str, path: &str, pairs: &[(&str, &str)]) -> Result { let (sid, auth) = twilio_auth(token)?; let r = agent() .post(&format!("{}/2010-04-01/Accounts/{sid}/{path}", twilio())) .header("Authorization", &auth) .header("Content-Type", "application/x-www-form-urlencoded") .send(form(pairs).as_bytes()) .map_err(|e| format!("連不到 Twilio:{e}"))?; read(r) } pub fn twilio_get(token: &str, path_query: &str) -> Result { let (sid, auth) = twilio_auth(token)?; let r = agent().get(&format!("{}/2010-04-01/Accounts/{sid}/{path_query}", twilio())).header("Authorization", &auth).call().map_err(|e| format!("連不到 Twilio:{e}"))?; read(r) } pub fn twilio_test(token: &str) -> Result { let v = twilio_get(token, ".json")?; Ok(format!("已連上 Twilio:{}", v["friendly_name"].as_str().unwrap_or("帳號"))) } pub fn send_sms(token: &str, from: &str, to: &str, text: &str) -> Result<(), String> { for part in crate::channels::split(text, SMS_MAX) { twilio_post(token, "Messages.json", &[("From", from), ("To", to), ("Body", &part)])?; } Ok(()) } /// New texts to `number` (oldest first), skipping ones already seen. (sid, from, body) pub fn sms_new(list: &Value, seen: &mut std::collections::HashSet) -> Vec<(String, String, String)> { let mut v: Vec<(String, String, String)> = list["messages"] .as_array() .into_iter() .flatten() .filter(|m| m["direction"].as_str() == Some("inbound")) .filter_map(|m| Some((m["sid"].as_str()?.to_string(), m["from"].as_str().unwrap_or("").to_string(), m["body"].as_str().unwrap_or("").to_string()))) .filter(|(sid, _, _)| seen.insert(sid.clone())) .collect(); // Twilio lists newest first. v.reverse(); v } fn xml_escape(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } /// Call `to` from the Twilio number and read `message` aloud (Traditional Chinese voice). pub fn call(token: &str, from: &str, to: &str, message: &str) -> Result { let twiml = format!("{}{}", xml_escape(message), xml_escape(message)); let v = twilio_post(token, "Calls.json", &[("From", from), ("To", to), ("Twiml", &twiml)])?; Ok(format!("已撥給 {to}(通話編號 {},對方接起後會聽到兩次)。", v["sid"].as_str().unwrap_or("?"))) } // ---------- X ---------- fn x_get(token: &str, path: &str) -> Result { let r = agent().get(&format!("{}{path}", x_api())).header("Authorization", &format!("Bearer {}", token.trim())).call().map_err(|e| format!("連不到 X:{e}"))?; read(r) } pub fn x_me(token: &str) -> Result<(String, String), String> { let v = x_get(token, "/2/users/me")?; Ok((v["data"]["id"].as_str().unwrap_or("").to_string(), v["data"]["username"].as_str().unwrap_or("").to_string())) } /// Post (optionally as a reply). Returns the new post's address. pub fn x_post(token: &str, text: &str, reply_to: &str) -> Result { let mut body = json!({"text": text.chars().take(X_MAX).collect::()}); if !reply_to.is_empty() { body["reply"] = json!({"in_reply_to_tweet_id": reply_to}); } let r = agent() .post(&format!("{}/2/tweets", x_api())) .header("Authorization", &format!("Bearer {}", token.trim())) .header("Content-Type", "application/json") .send(body.to_string().as_bytes()) .map_err(|e| format!("連不到 X:{e}"))?; let v = read(r)?; Ok(format!("https://x.com/i/status/{}", v["data"]["id"].as_str().unwrap_or(""))) } /// Mentions newer than `since_id`, oldest first: (id, author username, text). Returns the newest id. pub fn x_mentions(token: &str, user_id: &str, since_id: &str) -> Result<(String, Vec<(String, String, String)>), String> { let q = if since_id.is_empty() { String::new() } else { format!("&since_id={since_id}") }; let v = x_get(token, &format!("/2/users/{user_id}/mentions?max_results=20&expansions=author_id&user.fields=username{q}"))?; Ok(mentions_new(&v, since_id)) } pub fn mentions_new(v: &Value, since_id: &str) -> (String, Vec<(String, String, String)>) { let names: std::collections::HashMap = v["includes"]["users"].as_array().into_iter().flatten().map(|u| (u["id"].as_str().unwrap_or("").to_string(), u["username"].as_str().unwrap_or("").to_string())).collect(); let newest = v["meta"]["newest_id"].as_str().unwrap_or(since_id).to_string(); let mut items: Vec<(String, String, String)> = v["data"] .as_array() .into_iter() .flatten() .map(|t| (t["id"].as_str().unwrap_or("").to_string(), names.get(t["author_id"].as_str().unwrap_or("")).cloned().unwrap_or_default(), t["text"].as_str().unwrap_or("").to_string())) .collect(); items.reverse(); (newest, items) } // ---------- what bots can use ---------- /// The configured accounts (set when the channels start): (Twilio token, Twilio number), X token. #[derive(Clone, Default)] pub struct Accounts { pub twilio: Option<(String, String)>, pub x: Option, } pub fn accounts() -> &'static Mutex { static A: OnceLock> = OnceLock::new(); A.get_or_init(|| Mutex::new(Accounts::default())) } pub fn specs() -> Vec { let a = accounts().lock().unwrap().clone(); let mut v = vec![]; if a.twilio.is_some() { v.push(json!({"name": "place_call", "description": "Phone someone from the person's own number and read a short message aloud (Traditional Chinese voice). The person is asked every time.", "parameters": {"type": "object", "properties": {"to": {"type": "string", "description": "Phone number in international format, e.g. +886912345678"}, "message": {"type": "string"}}, "required": ["to", "message"]}})); v.push(json!({"name": "send_sms", "description": "Send a text message from the person's own number. The person is asked every time.", "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "text": {"type": "string"}}, "required": ["to", "text"]}})); } if a.x.is_some() { v.push(json!({"name": "post_to_x", "description": "Post on X (Twitter) as the person, optionally as a reply. At most 280 characters. The person is asked every time.", "parameters": {"type": "object", "properties": {"text": {"type": "string"}, "reply_to": {"type": "string", "description": "Post id to reply to (optional)"}}, "required": ["text"]}})); } v } pub fn is_phone_tool(name: &str) -> bool { matches!(name, "place_call" | "send_sms" | "post_to_x") } /// What the person is asked. pub fn describe(name: &str, args: &str) -> String { let v = crate::tools::args_of(args); let s = |k: &str| v[k].as_str().unwrap_or("").to_string(); match name { "place_call" => format!("打電話給 {},唸:「{}」", s("to"), s("message")), "send_sms" => format!("傳簡訊給 {}:「{}」", s("to"), s("text")), "post_to_x" => format!("用你的帳號在 X 發文{}:「{}」", if s("reply_to").is_empty() { String::new() } else { "(回覆)".into() }, s("text")), _ => String::new(), } } pub fn run(name: &str, args: &str) -> (String, bool) { let v = crate::tools::args_of(args); let s = |k: &str| v[k].as_str().unwrap_or("").trim().to_string(); let a = accounts().lock().unwrap().clone(); let r = match (name, &a.twilio, &a.x) { ("place_call", Some((t, from)), _) => call(t, from, &s("to"), &s("message")), ("send_sms", Some((t, from)), _) => send_sms(t, from, &s("to"), &s("text")).map(|_| format!("已傳簡訊給 {}。", s("to"))), ("post_to_x", _, Some(t)) => x_post(t, &s("text"), &s("reply_to")).map(|u| format!("已發文:{u}")), _ => Err("這個帳號還沒設定(設定 › 訊息管道)".into()), }; match r { Ok(t) => (t, true), Err(e) => (e, false), } } #[cfg(test)] mod tests { use super::*; use std::io::{Read, Write}; /// A stand-in for Twilio and X: records requests, answers like they do. fn fake() -> (u16, std::sync::Arc>>) { let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = l.local_addr().unwrap().port(); let log = std::sync::Arc::new(Mutex::new(vec![])); let lg = log.clone(); std::thread::spawn(move || { for c in l.incoming().flatten() { let mut c = c; let mut got = vec![]; let mut buf = [0u8; 8192]; loop { let n = c.read(&mut buf).unwrap_or(0); got.extend_from_slice(&buf[..n]); let t = String::from_utf8_lossy(&got).to_string(); if let Some((h, b)) = t.split_once("\r\n\r\n") { let len: usize = h.lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap_or(0))).unwrap_or(0); if b.len() >= len || n == 0 { break; } } if n == 0 { break; } } let t = String::from_utf8_lossy(&got).to_string(); let (head, body) = t.split_once("\r\n\r\n").unwrap_or((&t, "")); let line = head.lines().next().unwrap_or("").to_string(); let auth = head.lines().find_map(|l| l.strip_prefix("Authorization: ").or(l.strip_prefix("authorization: "))).unwrap_or("").to_string(); lg.lock().unwrap().push((line.clone(), auth, body.to_string())); let resp = if line.contains("/Messages.json") && line.starts_with("GET") { json!({"messages": [{"sid": "SM2", "direction": "inbound", "from": "+886900000002", "body": "第二則"}, {"sid": "SM9", "direction": "outbound-api", "from": "+1555", "body": "我發的"}, {"sid": "SM1", "direction": "inbound", "from": "+886900000001", "body": "第一則"}]}) } else if line.contains("/Calls.json") { json!({"sid": "CA123"}) } else if line.contains("/users/me") { json!({"data": {"id": "42", "username": "daniel"}}) } else if line.contains("/tweets") { json!({"data": {"id": "777", "text": "x"}}) } else { json!({"sid": "SMx", "friendly_name": "My account"}) }; let b = resp.to_string(); let _ = c.write_all(format!("HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", b.len()).as_bytes()); } }); (port, log) } #[test] fn twilio_and_x_requests_look_right() { let (port, log) = fake(); std::env::set_var("ANYBOT_TWILIO_API", format!("http://127.0.0.1:{port}")); std::env::set_var("ANYBOT_X_API", format!("http://127.0.0.1:{port}")); let tok = "AC123:secret"; assert!(twilio_test(tok).unwrap().contains("My account")); send_sms(tok, "+15550001", "+886912345678", "你好 & 再見").unwrap(); let out = call(tok, "+15550001", "+886912345678", "會議改到 <三點>").unwrap(); assert!(out.contains("CA123")); let mut seen = Default::default(); let list = twilio_get(tok, "Messages.json?To=%2B15550001&PageSize=20").unwrap(); let new = sms_new(&list, &mut seen); assert_eq!(new.iter().map(|m| m.2.as_str()).collect::>(), vec!["第一則", "第二則"]); assert!(sms_new(&list, &mut seen).is_empty()); assert_eq!(x_me("xtok").unwrap().1, "daniel"); assert_eq!(x_post("xtok", "嗨", "555").unwrap(), "https://x.com/i/status/777"); let log = log.lock().unwrap(); let sms = log.iter().find(|r| r.0.starts_with("POST") && r.0.contains("Messages.json")).unwrap(); assert!(sms.0.contains("/2010-04-01/Accounts/AC123/Messages.json")); assert_eq!(sms.1, format!("Basic {}", base64::engine::general_purpose::STANDARD.encode("AC123:secret"))); assert!(sms.2.contains("To=%2B886912345678") && sms.2.contains("Body=%E4%BD%A0%E5%A5%BD%20%26%20")); let callr = log.iter().find(|r| r.0.contains("Calls.json")).unwrap(); assert!(callr.2.contains("Twiml=") && callr.2.contains("%26lt%3B"), "TwiML must be escaped: {}", callr.2); let tw = log.iter().find(|r| r.0.contains("/2/tweets")).unwrap(); assert_eq!(tw.1, "Bearer xtok"); let body: Value = serde_json::from_str(&tw.2).unwrap(); assert_eq!(body["reply"]["in_reply_to_tweet_id"], "555"); } #[test] fn mentions_come_oldest_first_with_names() { let v = json!({"data": [{"id": "9", "author_id": "1", "text": "@daniel 新的"}, {"id": "8", "author_id": "2", "text": "@daniel 舊的"}], "includes": {"users": [{"id": "1", "username": "amy"}, {"id": "2", "username": "bob"}]}, "meta": {"newest_id": "9"}}); let (newest, items) = mentions_new(&v, "7"); assert_eq!(newest, "9"); assert_eq!(items[0], ("8".into(), "bob".into(), "@daniel 舊的".into())); assert_eq!(mentions_new(&json!({"meta": {"result_count": 0}}), "9").0, "9"); } }