AnyBot/src/phone.rs

310 lines
16 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.

// 專屬號碼、打電話、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::<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");
}
}