AnyBot/src/phone.rs

312 lines
16 KiB
Rust
Raw Normal View History

// 專屬號碼、打電話、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 {
fn enc(s: &str) -> String {
s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect()
}
pairs.iter().map(|(k, v)| format!("{}={}", enc(k), enc(v))).collect::<Vec<_>>().join("&")
}
fn read(mut r: ureq::http::Response<ureq::Body>) -> Result<Value, String> {
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<Value, String> {
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<Value, String> {
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<String, String> {
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<String>) -> 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// 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<String, String> {
let twiml = format!("<Response><Say language=\"zh-TW\">{}</Say><Pause length=\"1\"/><Say language=\"zh-TW\">{}</Say></Response>", 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<Value, String> {
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<String, String> {
let mut body = json!({"text": text.chars().take(X_MAX).collect::<String>()});
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<String, String> = 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<String>,
}
pub fn accounts() -> &'static Mutex<Accounts> {
static A: OnceLock<Mutex<Accounts>> = OnceLock::new();
A.get_or_init(|| Mutex::new(Accounts::default()))
}
pub fn specs() -> Vec<Value> {
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<Mutex<Vec<(String, String, String)>>>) {
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<_>>(), 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");
}
}